diff --git a/pom.xml b/pom.xml index 47fb3d3..1402fbe 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,10 @@ io.quarkus quarkus-liquibase + + io.quarkus + quarkus-rest-client-config + io.quarkus quarkus-rest-client-jackson diff --git a/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java index 25460ed..e77fc59 100644 --- a/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java +++ b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java @@ -1,11 +1,18 @@ package de.neitzel.roleplay.business; +import de.neitzel.roleplay.fascade.model.CharacterDefinition; +import de.neitzel.roleplay.fascade.model.CharacterState; import de.neitzel.roleplay.fascade.model.CreateSessionRequest; +import de.neitzel.roleplay.fascade.model.ScenarioSetup; import de.neitzel.roleplay.fascade.model.SessionResponse; +import de.neitzel.roleplay.fascade.model.SituationState; import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnResponse; +import de.neitzel.roleplay.fascade.model.UpdateSessionRequest; import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -53,10 +60,90 @@ public class InMemorySessionService implements SessionService { // to produce a real opening scene and initial state. session.setNarrative("Session initialised. Ollama integration pending."); + if (request.getScenario() != null) { + session.setSituation(buildSituationFromScenario(request.getScenario())); + session.setCharacters(buildCharactersFromScenario(request.getScenario())); + } + sessions.put(sessionId, session); return session; } + /** + * Builds initial situation state from the scenario setup. + * + * @param scenario the scenario from the create request + * @return situation state with setting, initialConflict and currentScene derived + */ + private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) { + SituationState situation = new SituationState(); + situation.setSetting(scenario.getSetting()); + situation.setCurrentScene( + scenario.getSetting() != null && scenario.getInitialConflict() != null + ? scenario.getSetting() + " " + scenario.getInitialConflict() + : scenario.getSetting() != null + ? scenario.getSetting() + : scenario.getInitialConflict()); + return situation; + } + + /** + * Builds initial character list from the scenario (user character + AI characters). + * + * @param scenario the scenario from the create request + * @return list of character states + */ + private static List buildCharactersFromScenario(final ScenarioSetup scenario) { + List characters = new ArrayList<>(); + if (scenario.getUserCharacter() != null) { + characters.add(toCharacterState(scenario.getUserCharacter(), true)); + } + if (scenario.getAiCharacters() != null) { + for (CharacterDefinition def : scenario.getAiCharacters()) { + characters.add(toCharacterState(def, false)); + } + } + return characters; + } + + /** + * Maps a character definition to initial character state. + * + * @param def the definition + * @param isUserCharacter whether this is the user-controlled character + * @return character state with id, name, role and isUserCharacter set + */ + private static CharacterState toCharacterState(final CharacterDefinition def, + final boolean isUserCharacter) { + CharacterState state = new CharacterState(def.getId(), def.getName(), isUserCharacter); + state.setRole(def.getRole()); + return state; + } + + /** + * {@inheritDoc} + * + *

Updates situation and/or characters when provided; omitted fields are unchanged. + */ + @Override + public Optional updateSession(final String sessionId, + final UpdateSessionRequest request) { + SessionResponse session = sessions.get(sessionId); + if (session == null) { + return Optional.empty(); + } + if (request != null) { + if (request.getSituation() != null) { + session.setSituation(request.getSituation()); + } + if (request.getCharacters() != null) { + session.setCharacters(new ArrayList<>(request.getCharacters())); + } + } + sessions.put(sessionId, session); + return Optional.of(session); + } + /** * {@inheritDoc} */ diff --git a/src/main/java/de/neitzel/roleplay/business/SessionService.java b/src/main/java/de/neitzel/roleplay/business/SessionService.java index 8ab5463..a70cfa4 100644 --- a/src/main/java/de/neitzel/roleplay/business/SessionService.java +++ b/src/main/java/de/neitzel/roleplay/business/SessionService.java @@ -4,6 +4,7 @@ import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.SessionResponse; import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnResponse; +import de.neitzel.roleplay.fascade.model.UpdateSessionRequest; import java.util.Optional; @@ -31,6 +32,16 @@ public interface SessionService { */ Optional getSession(String sessionId); + /** + * Partially updates an existing session (situation and/or characters). + * Omitted fields in the request are left unchanged. + * + * @param sessionId the unique session identifier + * @param request the update payload; may be null or have null fields + * @return an {@link Optional} containing the updated session, or empty if not found + */ + Optional updateSession(String sessionId, UpdateSessionRequest request); + /** * Processes a user's turn within an existing session. Runs the two-call * Ollama pattern and returns the resulting narrative with updated state. diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java index f269849..304ebec 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java @@ -3,14 +3,17 @@ package de.neitzel.roleplay.fascade; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; /** * Quarkus declarative REST client for the Ollama HTTP API. * Configuration (base URL, timeouts) is read from * {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}. + * All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}. */ @RegisterRestClient(configKey = "ollama-api") +@RegisterProvider(OllamaClientLoggingFilter.class) @Path("/api") public interface OllamaApi { diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java index 90fa5e5..4ff2686 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java @@ -6,6 +6,8 @@ import de.neitzel.roleplay.common.StateUpdateResponse; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Collections; import java.util.List; @@ -18,6 +20,11 @@ import java.util.List; @ApplicationScoped public class OllamaClient { + private static final Logger LOG = LoggerFactory.getLogger(OllamaClient.class); + + /** Maximum characters of narrative/JSON to log at TRACE (avoid huge logs). */ + private static final int TRACE_CONTENT_LIMIT = 200; + /** Low-level REST client for the Ollama HTTP API. */ private final OllamaApi ollamaApi; @@ -43,10 +50,14 @@ public class OllamaClient { * @return model metadata, or an empty list if none are installed */ public List listModels() { + LOG.debug("Fetching Ollama models (GET /api/tags)"); OllamaTagsResponse response = ollamaApi.getTags(); if (response == null || response.getModels() == null) { + LOG.debug("Received null or empty models list from Ollama"); return Collections.emptyList(); } + int count = response.getModels().size(); + LOG.debug("Received {} model(s) from Ollama", count); return response.getModels(); } @@ -62,6 +73,11 @@ public class OllamaClient { public String generateNarrative(final String model, final String systemPrompt, final String userContent) { + int systemLen = systemPrompt != null ? systemPrompt.length() : 0; + int userLen = userContent != null ? userContent.length() : 0; + LOG.debug("Calling Ollama for narrative: model={}, systemPromptLength={}, userContentLength={}", + model, systemLen, userLen); + OllamaChatRequest request = OllamaChatRequest.builder() .model(model) .messages(List.of( @@ -77,7 +93,16 @@ public class OllamaClient { .build(); OllamaChatResponse response = ollamaApi.chat(request); - return response.getMessage().getContent(); + String content = response.getMessage().getContent(); + int len = content != null ? content.length() : 0; + LOG.debug("Received narrative from Ollama, length={}", len); + if (LOG.isTraceEnabled() && content != null && !content.isEmpty()) { + String snippet = content.length() <= TRACE_CONTENT_LIMIT + ? content + : content.substring(0, TRACE_CONTENT_LIMIT) + "..."; + LOG.trace("Narrative snippet: {}", snippet); + } + return content; } /** @@ -94,6 +119,11 @@ public class OllamaClient { public StateUpdateResponse generateStateUpdate(final String model, final String systemPrompt, final String userContent) { + int systemLen = systemPrompt != null ? systemPrompt.length() : 0; + int userLen = userContent != null ? userContent.length() : 0; + LOG.debug("Calling Ollama for state update: model={}, systemPromptLength={}, userContentLength={}", + model, systemLen, userLen); + OllamaChatRequest request = OllamaChatRequest.builder() .model(model) .format("json") @@ -111,10 +141,21 @@ public class OllamaClient { OllamaChatResponse response = ollamaApi.chat(request); String json = response.getMessage().getContent(); + int jsonLen = json != null ? json.length() : 0; + LOG.debug("Received state update from Ollama, JSON length={}", jsonLen); + if (LOG.isTraceEnabled() && json != null && !json.isEmpty()) { + String snippet = json.length() <= TRACE_CONTENT_LIMIT + ? json + : json.substring(0, TRACE_CONTENT_LIMIT) + "..."; + LOG.trace("State update JSON snippet: {}", snippet); + } try { - return objectMapper.readValue(json, StateUpdateResponse.class); + StateUpdateResponse result = objectMapper.readValue(json, StateUpdateResponse.class); + LOG.debug("Parsed state update successfully"); + return result; } catch (JsonProcessingException e) { + LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage()); throw new OllamaParseException( "Failed to parse state update JSON from Ollama response", e); } diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaClientLoggingFilter.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaClientLoggingFilter.java new file mode 100644 index 0000000..f843cfe --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaClientLoggingFilter.java @@ -0,0 +1,53 @@ +package de.neitzel.roleplay.fascade; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * JAX-RS client filter that logs every outgoing request and response for the + * Ollama REST client. Registered only on {@link OllamaApi} via + * {@link org.eclipse.microprofile.rest.client.annotation.RegisterProvider}. + * Logs method, URI and (for responses) status and content-type so that + * Ollama communication can be inspected without logging full bodies here + * (body logging is configured via Quarkus REST client logging). + */ +public class OllamaClientLoggingFilter implements ClientRequestFilter, ClientResponseFilter { + + private static final Logger LOG = LoggerFactory.getLogger(OllamaClientLoggingFilter.class); + + /** + * Logs the outgoing request method and URI before the request is sent. + * + * @param requestContext the request context + */ + @Override + public void filter(final ClientRequestContext requestContext) throws IOException { + String method = requestContext.getMethod(); + String uri = requestContext.getUri().toString(); + LOG.debug("Ollama request: {} {}", method, uri); + } + + /** + * Logs the response status and content-type after the response is received. + * + * @param requestContext the request context (unused) + * @param responseContext the response context + */ + @Override + public void filter(final ClientRequestContext requestContext, + final ClientResponseContext responseContext) throws IOException { + int status = responseContext.getStatus(); + String statusInfo = responseContext.getStatusInfo() != null + ? responseContext.getStatusInfo().getReasonPhrase() + : ""; + String contentType = responseContext.getHeaderString("Content-Type"); + LOG.debug("Ollama response: {} {} Content-Type: {}", + status, statusInfo, contentType != null ? contentType : "(none)"); + } +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java b/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java index fa5a0dd..e3d4fb4 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java +++ b/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java @@ -3,6 +3,7 @@ package de.neitzel.roleplay.fascade; import de.neitzel.roleplay.business.SessionService; import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.SessionResponse; +import de.neitzel.roleplay.fascade.model.UpdateSessionRequest; import de.neitzel.roleplay.generated.api.SessionsApi; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -54,5 +55,20 @@ public class SessionResource implements SessionsApi { .orElseThrow(() -> new NotFoundException( "No session found with id: " + sessionId)); } + + /** + * {@inheritDoc} + * + *

Delegates to {@link SessionService#updateSession(String, UpdateSessionRequest)}. + * Returns 404 if the session is not found. + */ + @Override + public SessionResponse updateSession(final String sessionId, + final UpdateSessionRequest updateSessionRequest) { + return sessionService.updateSession(sessionId, + updateSessionRequest != null ? updateSessionRequest : new UpdateSessionRequest()) + .orElseThrow(() -> new NotFoundException( + "No session found with id: " + sessionId)); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 63e784e..5b5ef23 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,5 +20,14 @@ quarkus: url: http://debian:11434 connect-timeout: 5000 read-timeout: 120000 + logging: + scope: request-response + body-limit: 2048 rest: path: /api/v1 + log: + category: + "de.neitzel.roleplay.fascade.OllamaClient": + level: DEBUG + "org.jboss.resteasy.reactive.client.logging": + level: DEBUG diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml index c2f8158..865e5f3 100644 --- a/src/main/resources/openapi-roleplay-public-v1.yml +++ b/src/main/resources/openapi-roleplay-public-v1.yml @@ -101,6 +101,40 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + patch: + operationId: updateSession + summary: Update session state + description: | + Partially updates an existing session. Provide situation and/or characters + to replace the current values. Omitted fields are left unchanged. + tags: + - sessions + parameters: + - $ref: '#/components/parameters/SessionId' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSessionRequest' + responses: + "200": + description: Session updated; full state returned. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + "404": + description: Session not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "400": + description: Invalid request body. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /sessions/{sessionId}/turns: post: @@ -245,6 +279,19 @@ components: items: $ref: '#/components/schemas/CharacterDefinition' + UpdateSessionRequest: + type: object + description: Request body for partially updating a session. + properties: + situation: + $ref: '#/components/schemas/SituationState' + description: Replace session situation when provided. + characters: + type: array + items: + $ref: '#/components/schemas/CharacterState' + description: Replace session character list when provided. + CharacterDefinition: type: object description: Definition of a character for session initialisation. diff --git a/src/main/web/src/pages/SessionPage.tsx b/src/main/web/src/pages/SessionPage.tsx index 4eb64f0..cfb311f 100644 --- a/src/main/web/src/pages/SessionPage.tsx +++ b/src/main/web/src/pages/SessionPage.tsx @@ -1,20 +1,43 @@ +import type {ChangeEvent} from 'react' import {useEffect, useState} from 'react' import {useNavigate, useParams} from 'react-router-dom' import { Alert, AppBar, Box, + Button, + Card, + CardContent, CircularProgress, Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Divider, IconButton, + List, + ListItem, + ListItemText, + TextField, Toolbar, Tooltip, Typography, } from '@mui/material' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import AutoStoriesIcon from '@mui/icons-material/AutoStories' -import type {SessionResponse, Suggestion, TurnRequest} from '../api/generated/index' +import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' +import PersonIcon from '@mui/icons-material/Person' +import PlaceIcon from '@mui/icons-material/Place' +import type { + CharacterState, + SessionResponse, + SituationState, + Suggestion, + TurnRequest, + UpdateSessionRequest, +} from '../api/generated/index' import {Configuration, SessionsApi, TurnsApi, UserActionRequestTypeEnum,} from '../api/generated/index' import NarrativeView from '../components/NarrativeView' import SuggestionList from '../components/SuggestionList' @@ -40,6 +63,10 @@ export default function SessionPage() { const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const [sceneDialogOpen, setSceneDialogOpen] = useState(false) + const [sceneDraft, setSceneDraft] = useState({}) + const [charactersDialogOpen, setCharactersDialogOpen] = useState(false) + const [charactersDraft, setCharactersDraft] = useState([]) /** Load existing session on mount. */ useEffect(() => { @@ -84,6 +111,74 @@ export default function SessionPage() { void handleTurnSubmit(request) } + /** Open scene edit dialog with current situation. */ + const openSceneDialog = () => { + setSceneDraft({ + setting: session?.situation?.setting ?? '', + currentScene: session?.situation?.currentScene ?? '', + }) + setSceneDialogOpen(true) + } + + /** Save scene and PATCH session. */ + const saveScene = async () => { + if (!sessionId) return + setError(null) + try { + const request: UpdateSessionRequest = { situation: sceneDraft } + const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request }) + setSession(updated) + setNarrative(updated.narrative ?? '') + setSuggestions(updated.suggestions ?? []) + setTurnNumber(updated.turnNumber) + setSceneDialogOpen(false) + } catch { + setError('Failed to update scene.') + } + } + + /** Open characters edit dialog with current characters. */ + const openCharactersDialog = () => { + setCharactersDraft(session?.characters ? [...session.characters] : []) + setCharactersDialogOpen(true) + } + + /** Save characters and PATCH session. */ + const saveCharacters = async () => { + if (!sessionId) return + setError(null) + try { + const request: UpdateSessionRequest = { characters: charactersDraft } + const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request }) + setSession(updated) + setNarrative(updated.narrative ?? '') + setSuggestions(updated.suggestions ?? []) + setTurnNumber(updated.turnNumber) + setCharactersDialogOpen(false) + } catch { + setError('Failed to update characters.') + } + } + + /** Add a new character to the draft list. */ + const addCharacterDraft = () => { + setCharactersDraft((prev) => [...prev, { id: '', name: '', role: '', isUserCharacter: false }]) + } + + /** Update a character in the draft list. */ + const updateCharacterDraft = (index: number, patch: Partial) => { + setCharactersDraft((prev) => { + const next = [...prev] + next[index] = { ...next[index], ...patch } + return next + }) + } + + /** Remove a character from the draft list. */ + const removeCharacterDraft = (index: number) => { + setCharactersDraft((prev) => prev.filter((_, i) => i !== index)) + } + return ( )} + {(session?.situation?.setting || session?.situation?.currentScene) && ( + + + + + + Scene + + + + {session.situation.setting && ( + + Setting: {session.situation.setting} + + )} + {session.situation.currentScene && ( + + Current: {session.situation.currentScene} + + )} + + + )} + + {session?.characters && session.characters.length > 0 && ( + + + + + + Characters + + + + + {session.characters.map((c, i) => ( + + + + ))} + + + + )} + {suggestions.length > 0 && ( <> @@ -140,6 +287,115 @@ export default function SessionPage() { )} + +

setSceneDialogOpen(false)} maxWidth="sm" fullWidth> + Edit scene + + + + Setting (place, time, atmosphere) + + ) => setSceneDraft((prev) => ({ ...prev, setting: e.target.value }))} + fullWidth + multiline + minRows={2} + /> + + Current scene (what is in focus) + + ) => setSceneDraft((prev) => ({ ...prev, currentScene: e.target.value }))} + fullWidth + multiline + minRows={2} + /> + + + + + + + + + setCharactersDialogOpen(false)} maxWidth="sm" fullWidth> + Edit characters + + + + {charactersDraft.map((c, i) => ( + removeCharacterDraft(i)} aria-label="Remove character"> + + + } + > + + ) => updateCharacterDraft(i, { id: e.target.value })} + fullWidth + /> + ) => updateCharacterDraft(i, { name: e.target.value })} + fullWidth + /> + ) => updateCharacterDraft(i, { role: e.target.value })} + fullWidth + /> + + User character + + + + + + ))} + + + + + + + + + ) } diff --git a/src/main/web/src/pages/StartPage.tsx b/src/main/web/src/pages/StartPage.tsx index 2657666..a92b818 100644 --- a/src/main/web/src/pages/StartPage.tsx +++ b/src/main/web/src/pages/StartPage.tsx @@ -1,13 +1,24 @@ import {useEffect, useState} from 'react' import {useNavigate} from 'react-router-dom' import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, Box, Button, CircularProgress, Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControl, + IconButton, InputLabel, + List, + ListItem, + ListItemText, MenuItem, Paper, Select, @@ -16,8 +27,22 @@ import { Typography, } from '@mui/material' import AutoStoriesIcon from '@mui/icons-material/AutoStories' -import type {CreateSessionRequest, ModelInfo} from '../api/generated/index' -import {Configuration, CreateSessionRequestSafetyLevelEnum, ModelsApi, SessionsApi,} from '../api/generated/index' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import PersonAddIcon from '@mui/icons-material/PersonAdd' +import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' +import type { + CharacterDefinition, + CreateSessionRequest, + ModelInfo, + ScenarioSetup, +} from '../api/generated/index' +import { + Configuration, + CreateSessionRequestSafetyLevelEnum, + ModelsApi, + SessionsApi, +} from '../api/generated/index' /***** API base path – must match quarkus.rest.path in application.yml */ const API_BASE = '/api/v1' @@ -39,6 +64,19 @@ export default function StartPage() { const [starting, setStarting] = useState(false) const [error, setError] = useState(null) + /** Optional scenario (collapsible). */ + const [scenarioExpanded, setScenarioExpanded] = useState(false) + const [setting, setSetting] = useState('') + const [initialConflict, setInitialConflict] = useState('') + const [userCharacter, setUserCharacter] = useState(null) + const [aiCharacters, setAiCharacters] = useState([]) + /** Dialog for add/edit character: 'user' | 'ai', and index (-1 = add). */ + const [characterDialog, setCharacterDialog] = useState<{ + mode: 'user' | 'ai' + index: number + draft: CharacterDefinition + } | null>(null) + /** Load available models on mount. */ useEffect(() => { modelsApi.listModels() @@ -56,6 +94,68 @@ export default function StartPage() { setSelectedModel(event.target.value) } + /** Build scenario from form state if any field is filled. */ + const buildScenario = (): ScenarioSetup | undefined => { + const hasSetting = setting.trim() !== '' + const hasConflict = initialConflict.trim() !== '' + const hasUser = userCharacter && userCharacter.id.trim() && userCharacter.name.trim() && userCharacter.role.trim() + const hasAi = aiCharacters.length > 0 && aiCharacters.every( + (c) => c.id.trim() !== '' && c.name.trim() !== '' && c.role.trim() !== '' + ) + if (!hasSetting && !hasConflict && !hasUser && !hasAi) return undefined + return { + setting: hasSetting ? setting.trim() : undefined, + initialConflict: hasConflict ? initialConflict.trim() : undefined, + userCharacter: hasUser ? userCharacter : undefined, + aiCharacters: hasAi ? aiCharacters : undefined, + } + } + + /** Open dialog to add or edit user character. */ + const openUserCharacterDialog = () => { + setCharacterDialog({ + mode: 'user', + index: -1, + draft: userCharacter ?? { id: 'player', name: '', role: '' }, + }) + } + + /** Open dialog to add or edit an AI character. */ + const openAiCharacterDialog = (index: number) => { + const list = index >= 0 ? aiCharacters[index] : { id: '', name: '', role: '' } + setCharacterDialog({ + mode: 'ai', + index, + draft: { ...list, personalityTraits: list.personalityTraits ?? [], goals: list.goals ?? [] }, + }) + } + + /** Save character from dialog and close. */ + const saveCharacterDialog = () => { + if (!characterDialog) return + const { draft } = characterDialog + if (!draft.id?.trim() || !draft.name?.trim() || !draft.role?.trim()) return + if (characterDialog.mode === 'user') { + setUserCharacter({ ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() }) + } else { + const next = [...aiCharacters] + const char = { ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() } + const idx = characterDialog.index + if (idx !== undefined && idx >= 0) { + next[idx] = char + } else { + next.push(char) + } + setAiCharacters(next) + } + setCharacterDialog(null) + } + + /** Remove an AI character. */ + const removeAiCharacter = (index: number) => { + setAiCharacters((prev) => prev.filter((_, i) => i !== index)) + } + /** Create a new session and navigate to the session page. */ const handleStart = async () => { if (!selectedModel) return @@ -66,6 +166,7 @@ export default function StartPage() { model: selectedModel, language, safetyLevel: CreateSessionRequestSafetyLevelEnum.standard, + scenario: buildScenario(), } const session = await sessionsApi.createSession({createSessionRequest: request}) navigate(`/session/${session.sessionId}`) @@ -128,6 +229,89 @@ export default function StartPage() { fullWidth /> + setScenarioExpanded((b) => !b)} + disabled={starting} + sx={{width: '100%'}} + > + }> + Scenario (optional) + + + + setSetting(e.target.value)} + placeholder="Place, time, and atmosphere" + fullWidth + size="small" + /> + setInitialConflict(e.target.value)} + placeholder="The hook or starting conflict" + fullWidth + size="small" + /> + + + Your character + + {userCharacter ? ( + + + + } + > + + + ) : ( + + )} + + + + AI characters + + + {aiCharacters.map((c, i) => ( + + openAiCharacterDialog(i)}> + + + removeAiCharacter(i)}> + + + + } + > + + + ))} + + + + + + + + + + ) } diff --git a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java index e1447f4..48cce78 100644 --- a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java +++ b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java @@ -1,13 +1,19 @@ package de.neitzel.roleplay.business; +import de.neitzel.roleplay.fascade.model.CharacterDefinition; +import de.neitzel.roleplay.fascade.model.CharacterState; import de.neitzel.roleplay.fascade.model.CreateSessionRequest; +import de.neitzel.roleplay.fascade.model.ScenarioSetup; import de.neitzel.roleplay.fascade.model.SessionResponse; +import de.neitzel.roleplay.fascade.model.SituationState; import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnResponse; +import de.neitzel.roleplay.fascade.model.UpdateSessionRequest; import de.neitzel.roleplay.fascade.model.UserActionRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -164,5 +170,96 @@ class InMemorySessionServiceTest { // Assert assertEquals("en", response.getLanguage()); } + + /** + * Verifies that creating a session with a scenario populates situation and characters. + */ + @Test + void createSessionWithScenarioPopulatesSituationAndCharacters() { + // Arrange + CharacterDefinition userChar = new CharacterDefinition("hero", "The Hero", "protagonist"); + CharacterDefinition aiChar = new CharacterDefinition("mentor", "Old Sage", "wise guide"); + ScenarioSetup scenario = new ScenarioSetup(); + scenario.setSetting("A fog-covered harbour at dawn, 1923"); + scenario.setInitialConflict("Strange noises from the cargo hold"); + scenario.setUserCharacter(userChar); + scenario.setAiCharacters(List.of(aiChar)); + + CreateSessionRequest request = new CreateSessionRequest("llama3:latest"); + request.setScenario(scenario); + + // Act + SessionResponse response = sessionService.createSession(request); + + // Assert + assertNotNull(response.getSituation()); + assertEquals("A fog-covered harbour at dawn, 1923", response.getSituation().getSetting()); + assertNotNull(response.getSituation().getCurrentScene()); + assertTrue(response.getSituation().getCurrentScene() + .contains("A fog-covered harbour at dawn, 1923")); + assertTrue(response.getSituation().getCurrentScene().contains("Strange noises")); + + assertNotNull(response.getCharacters()); + assertEquals(2, response.getCharacters().size()); + CharacterState userState = response.getCharacters().stream() + .filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter())) + .findFirst().orElseThrow(); + assertEquals("hero", userState.getId()); + assertEquals("The Hero", userState.getName()); + assertEquals("protagonist", userState.getRole()); + CharacterState aiState = response.getCharacters().stream() + .filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter())) + .findFirst().orElseThrow(); + assertEquals("mentor", aiState.getId()); + assertEquals("Old Sage", aiState.getName()); + } + + /** + * Verifies that updateSession updates situation and characters when provided. + */ + @Test + void updateSessionUpdatesSituationAndCharacters() { + // Arrange + CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest"); + SessionResponse session = sessionService.createSession(createRequest); + String sessionId = session.getSessionId(); + + SituationState newSituation = new SituationState(); + newSituation.setSetting("New setting"); + newSituation.setCurrentScene("New scene focus"); + CharacterState newChar = new CharacterState("npc1", "First NPC", false); + newChar.setRole("supporting"); + UpdateSessionRequest updateRequest = new UpdateSessionRequest(); + updateRequest.setSituation(newSituation); + updateRequest.setCharacters(List.of(newChar)); + + // Act + Optional updated = sessionService.updateSession(sessionId, updateRequest); + + // Assert + assertTrue(updated.isPresent()); + assertEquals("New setting", updated.get().getSituation().getSetting()); + assertEquals("New scene focus", updated.get().getSituation().getCurrentScene()); + assertEquals(1, updated.get().getCharacters().size()); + assertEquals("npc1", updated.get().getCharacters().get(0).getId()); + + Optional got = sessionService.getSession(sessionId); + assertTrue(got.isPresent()); + assertEquals("New setting", got.get().getSituation().getSetting()); + assertEquals(1, got.get().getCharacters().size()); + } + + /** + * Verifies that updateSession returns empty for unknown session. + */ + @Test + void updateSessionReturnsEmptyForUnknownSession() { + UpdateSessionRequest request = new UpdateSessionRequest(); + request.setSituation(new SituationState()); + + Optional result = sessionService.updateSession("unknown-id", request); + + assertTrue(result.isEmpty()); + } }