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:
parent
ffb97f6b8e
commit
f21f1e7520
4
pom.xml
4
pom.xml
@ -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>
|
||||
|
||||
@ -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}
|
||||
*/
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user