Add update session functionality and enhance scenario support

- Introduce UpdateSessionRequest model for partial updates to session state, allowing modification of situation and characters.
- Implement updateSession method in SessionService to handle updates, ensuring omitted fields remain unchanged.
- Enhance InMemorySessionService to support scenario-based session creation, populating initial situation and characters.
- Update SessionResource to delegate update requests to the SessionService.
- Add corresponding API documentation for the update session endpoint in OpenAPI specification.
- Enhance frontend components to allow editing of session scene and characters, integrating with the new update functionality.
- Include unit tests to verify the behavior of session updates and scenario handling.
This commit is contained in:
Konrad Neitzel 2026-02-21 10:04:07 +01:00
parent ffb97f6b8e
commit f21f1e7520
12 changed files with 942 additions and 5 deletions

View File

@ -68,6 +68,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-config</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>

View File

@ -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<CharacterState> buildCharactersFromScenario(final ScenarioSetup scenario) {
List<CharacterState> 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}
*
* <p>Updates situation and/or characters when provided; omitted fields are unchanged.
*/
@Override
public Optional<SessionResponse> 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}
*/

View File

@ -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<SessionResponse> 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<SessionResponse> 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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<boolean>(true)
const [submitting, setSubmitting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [sceneDialogOpen, setSceneDialogOpen] = useState<boolean>(false)
const [sceneDraft, setSceneDraft] = useState<SituationState>({})
const [charactersDialogOpen, setCharactersDialogOpen] = useState<boolean>(false)
const [charactersDraft, setCharactersDraft] = useState<CharacterState[]>([])
/** 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<CharacterState>) => {
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 (
<Box display="flex" flexDirection="column" minHeight="100vh">
<AppBar position="static" elevation={0} color="transparent"
@ -123,6 +218,58 @@ export default function SessionPage() {
<NarrativeView narrative={narrative} turnNumber={turnNumber}/>
)}
{(session?.situation?.setting || session?.situation?.currentScene) && (
<Card variant="outlined">
<CardContent sx={{'&:last-child': {pb: 2}}}>
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box display="flex" alignItems="center" gap={1}>
<PlaceIcon color="action"/>
<Typography variant="subtitle2" color="text.secondary">Scene</Typography>
</Box>
<Button size="small" startIcon={<EditIcon/>} onClick={openSceneDialog}>
Edit scene
</Button>
</Box>
{session.situation.setting && (
<Typography variant="body2" sx={{mt: 1}}>
<strong>Setting:</strong> {session.situation.setting}
</Typography>
)}
{session.situation.currentScene && (
<Typography variant="body2" sx={{mt: 0.5}}>
<strong>Current:</strong> {session.situation.currentScene}
</Typography>
)}
</CardContent>
</Card>
)}
{session?.characters && session.characters.length > 0 && (
<Card variant="outlined">
<CardContent sx={{'&:last-child': {pb: 2}}}>
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box display="flex" alignItems="center" gap={1}>
<PersonIcon color="action"/>
<Typography variant="subtitle2" color="text.secondary">Characters</Typography>
</Box>
<Button size="small" startIcon={<EditIcon/>} onClick={openCharactersDialog}>
Edit characters
</Button>
</Box>
<List dense disablePadding sx={{mt: 1}}>
{session.characters.map((c, i) => (
<ListItem key={c.id ?? i} dense disablePadding>
<ListItemText
primary={c.name}
secondary={c.role ? `${c.role}${c.isUserCharacter ? ' (you)' : ''}` : c.isUserCharacter ? '(you)' : undefined}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{suggestions.length > 0 && (
<>
<Divider/>
@ -140,6 +287,115 @@ export default function SessionPage() {
</Box>
)}
</Container>
<Dialog open={sceneDialogOpen} onClose={() => setSceneDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit scene</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<Typography variant="body2" color="text.secondary">
Setting (place, time, atmosphere)
</Typography>
<TextField
label="Setting"
value={sceneDraft.setting ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, setting: e.target.value }))}
fullWidth
multiline
minRows={2}
/>
<Typography variant="body2" color="text.secondary">
Current scene (what is in focus)
</Typography>
<TextField
label="Current scene"
value={sceneDraft.currentScene ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, currentScene: e.target.value }))}
fullWidth
multiline
minRows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setSceneDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={saveScene}>Save</Button>
</DialogActions>
</Dialog>
<Dialog open={charactersDialogOpen} onClose={() => setCharactersDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit characters</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<List dense disablePadding>
{charactersDraft.map((c, i) => (
<ListItem
key={c.id ? `${c.id}-${i}` : i}
sx={{flexWrap: 'wrap', alignItems: 'flex-start', gap: 1}}
secondaryAction={
<IconButton size="small" onClick={() => removeCharacterDraft(i)} aria-label="Remove character">
<DeleteIcon/>
</IconButton>
}
>
<Box display="flex" flexDirection="column" gap={0.5} sx={{flex: 1, minWidth: 0}}>
<TextField
size="small"
label="ID"
value={c.id}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { id: e.target.value })}
fullWidth
/>
<TextField
size="small"
label="Name"
value={c.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { name: e.target.value })}
fullWidth
/>
<TextField
size="small"
label="Role"
value={c.role ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { role: e.target.value })}
fullWidth
/>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="caption">User character</Typography>
<Button
size="small"
variant={c.isUserCharacter ? 'contained' : 'outlined'}
onClick={() => updateCharacterDraft(i, { isUserCharacter: true })}
>
Yes
</Button>
<Button
size="small"
variant={!c.isUserCharacter ? 'contained' : 'outlined'}
onClick={() => updateCharacterDraft(i, { isUserCharacter: false })}
>
No
</Button>
</Box>
</Box>
</ListItem>
))}
</List>
<Button size="small" startIcon={<PersonIcon/>} onClick={addCharacterDraft}>
Add character
</Button>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCharactersDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={saveCharacters}
disabled={charactersDraft.some((c) => !c.id?.trim() || !c.name?.trim())}
>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@ -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<boolean>(false)
const [error, setError] = useState<string | null>(null)
/** Optional scenario (collapsible). */
const [scenarioExpanded, setScenarioExpanded] = useState<boolean>(false)
const [setting, setSetting] = useState<string>('')
const [initialConflict, setInitialConflict] = useState<string>('')
const [userCharacter, setUserCharacter] = useState<CharacterDefinition | null>(null)
const [aiCharacters, setAiCharacters] = useState<CharacterDefinition[]>([])
/** 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
/>
<Accordion
expanded={scenarioExpanded}
onChange={() => setScenarioExpanded((b) => !b)}
disabled={starting}
sx={{width: '100%'}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
<Typography>Scenario (optional)</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={2}>
<TextField
label="Setting"
value={setting}
onChange={(e) => setSetting(e.target.value)}
placeholder="Place, time, and atmosphere"
fullWidth
size="small"
/>
<TextField
label="Initial conflict"
value={initialConflict}
onChange={(e) => setInitialConflict(e.target.value)}
placeholder="The hook or starting conflict"
fullWidth
size="small"
/>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Your character
</Typography>
{userCharacter ? (
<ListItem
dense
secondaryAction={
<IconButton edge="end" size="small" onClick={openUserCharacterDialog}>
<EditIcon/>
</IconButton>
}
>
<ListItemText
primary={userCharacter.name}
secondary={`${userCharacter.role} (id: ${userCharacter.id})`}
/>
</ListItem>
) : (
<Button size="small" startIcon={<PersonAddIcon/>} onClick={openUserCharacterDialog}>
Add your character
</Button>
)}
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
AI characters
</Typography>
<List dense disablePadding>
{aiCharacters.map((c, i) => (
<ListItem
key={c.id + i}
dense
secondaryAction={
<Box component="span">
<IconButton size="small" onClick={() => openAiCharacterDialog(i)}>
<EditIcon/>
</IconButton>
<IconButton size="small" onClick={() => removeAiCharacter(i)}>
<DeleteIcon/>
</IconButton>
</Box>
}
>
<ListItemText primary={c.name} secondary={c.role}/>
</ListItem>
))}
</List>
<Button size="small" startIcon={<PersonAddIcon/>} onClick={() => openAiCharacterDialog(-1)}>
Add AI character
</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Button
variant="contained"
size="large"
@ -140,6 +324,135 @@ export default function StartPage() {
</Box>
)}
</Paper>
<Dialog open={characterDialog !== null} onClose={() => setCharacterDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle>
{characterDialog?.mode === 'user' ? 'Your character' : (characterDialog?.index ?? -1) >= 0 ? 'Edit AI character' : 'Add AI character'}
</DialogTitle>
<DialogContent>
{characterDialog && (
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<TextField
label="ID"
value={characterDialog.draft.id}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, id: e.target.value}})
}
required
fullWidth
size="small"
helperText="Unique identifier, e.g. captain_morgan"
/>
<TextField
label="Name"
value={characterDialog.draft.name}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, name: e.target.value}})
}
required
fullWidth
size="small"
/>
<TextField
label="Role"
value={characterDialog.draft.role}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, role: e.target.value}})
}
required
fullWidth
size="small"
/>
{characterDialog.mode === 'ai' && (
<>
<TextField
label="Backstory"
value={characterDialog.draft.backstory ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
}
multiline
rows={2}
fullWidth
size="small"
/>
<TextField
label="Speaking style"
value={characterDialog.draft.speakingStyle ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
}
fullWidth
size="small"
/>
<TextField
label="Personality traits"
value={Array.isArray(characterDialog.draft.personalityTraits) ? characterDialog.draft.personalityTraits.join(', ') : ''}
onChange={(e) =>
setCharacterDialog({
...characterDialog,
draft: {
...characterDialog.draft,
personalityTraits: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
},
})
}
fullWidth
size="small"
helperText="Comma-separated"
/>
<TextField
label="Goals"
value={Array.isArray(characterDialog.draft.goals) ? characterDialog.draft.goals.join(', ') : ''}
onChange={(e) =>
setCharacterDialog({
...characterDialog,
draft: {
...characterDialog.draft,
goals: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
},
})
}
fullWidth
size="small"
helperText="Comma-separated"
/>
</>
)}
{characterDialog.mode === 'user' && (
<>
<TextField
label="Backstory"
value={characterDialog.draft.backstory ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
}
multiline
rows={2}
fullWidth
size="small"
/>
<TextField
label="Speaking style"
value={characterDialog.draft.speakingStyle ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
}
fullWidth
size="small"
/>
</>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setCharacterDialog(null)}>Cancel</Button>
<Button variant="contained" onClick={saveCharacterDialog} disabled={!characterDialog?.draft.id?.trim() || !characterDialog?.draft.name?.trim() || !characterDialog?.draft.role?.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

View File

@ -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<SessionResponse> 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<SessionResponse> 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<SessionResponse> result = sessionService.updateSession("unknown-id", request);
assertTrue(result.isEmpty());
}
}