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>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-liquibase</artifactId>
|
<artifactId>quarkus-liquibase</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client-config</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
package de.neitzel.roleplay.business;
|
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.CreateSessionRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
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.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -53,10 +60,90 @@ public class InMemorySessionService implements SessionService {
|
|||||||
// to produce a real opening scene and initial state.
|
// to produce a real opening scene and initial state.
|
||||||
session.setNarrative("Session initialised. Ollama integration pending.");
|
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);
|
sessions.put(sessionId, session);
|
||||||
return 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}
|
* {@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.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@ -31,6 +32,16 @@ public interface SessionService {
|
|||||||
*/
|
*/
|
||||||
Optional<SessionResponse> getSession(String sessionId);
|
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
|
* Processes a user's turn within an existing session. Runs the two-call
|
||||||
* Ollama pattern and returns the resulting narrative with updated state.
|
* 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.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quarkus declarative REST client for the Ollama HTTP API.
|
* Quarkus declarative REST client for the Ollama HTTP API.
|
||||||
* Configuration (base URL, timeouts) is read from
|
* Configuration (base URL, timeouts) is read from
|
||||||
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
||||||
|
* All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}.
|
||||||
*/
|
*/
|
||||||
@RegisterRestClient(configKey = "ollama-api")
|
@RegisterRestClient(configKey = "ollama-api")
|
||||||
|
@RegisterProvider(OllamaClientLoggingFilter.class)
|
||||||
@Path("/api")
|
@Path("/api")
|
||||||
public interface OllamaApi {
|
public interface OllamaApi {
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import de.neitzel.roleplay.common.StateUpdateResponse;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -18,6 +20,11 @@ import java.util.List;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class OllamaClient {
|
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. */
|
/** Low-level REST client for the Ollama HTTP API. */
|
||||||
private final OllamaApi ollamaApi;
|
private final OllamaApi ollamaApi;
|
||||||
|
|
||||||
@ -43,10 +50,14 @@ public class OllamaClient {
|
|||||||
* @return model metadata, or an empty list if none are installed
|
* @return model metadata, or an empty list if none are installed
|
||||||
*/
|
*/
|
||||||
public List<OllamaModelInfo> listModels() {
|
public List<OllamaModelInfo> listModels() {
|
||||||
|
LOG.debug("Fetching Ollama models (GET /api/tags)");
|
||||||
OllamaTagsResponse response = ollamaApi.getTags();
|
OllamaTagsResponse response = ollamaApi.getTags();
|
||||||
if (response == null || response.getModels() == null) {
|
if (response == null || response.getModels() == null) {
|
||||||
|
LOG.debug("Received null or empty models list from Ollama");
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
int count = response.getModels().size();
|
||||||
|
LOG.debug("Received {} model(s) from Ollama", count);
|
||||||
return response.getModels();
|
return response.getModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +73,11 @@ public class OllamaClient {
|
|||||||
public String generateNarrative(final String model,
|
public String generateNarrative(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
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()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.messages(List.of(
|
.messages(List.of(
|
||||||
@ -77,7 +93,16 @@ public class OllamaClient {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
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,
|
public StateUpdateResponse generateStateUpdate(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
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()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.format("json")
|
.format("json")
|
||||||
@ -111,10 +141,21 @@ public class OllamaClient {
|
|||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
OllamaChatResponse response = ollamaApi.chat(request);
|
||||||
String json = response.getMessage().getContent();
|
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 {
|
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) {
|
} catch (JsonProcessingException e) {
|
||||||
|
LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage());
|
||||||
throw new OllamaParseException(
|
throw new OllamaParseException(
|
||||||
"Failed to parse state update JSON from Ollama response", e);
|
"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.business.SessionService;
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
import de.neitzel.roleplay.generated.api.SessionsApi;
|
import de.neitzel.roleplay.generated.api.SessionsApi;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@ -54,5 +55,20 @@ public class SessionResource implements SessionsApi {
|
|||||||
.orElseThrow(() -> new NotFoundException(
|
.orElseThrow(() -> new NotFoundException(
|
||||||
"No session found with id: " + sessionId));
|
"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
|
url: http://debian:11434
|
||||||
connect-timeout: 5000
|
connect-timeout: 5000
|
||||||
read-timeout: 120000
|
read-timeout: 120000
|
||||||
|
logging:
|
||||||
|
scope: request-response
|
||||||
|
body-limit: 2048
|
||||||
rest:
|
rest:
|
||||||
path: /api/v1
|
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:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/sessions/{sessionId}/turns:
|
||||||
post:
|
post:
|
||||||
@ -245,6 +279,19 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
$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:
|
CharacterDefinition:
|
||||||
type: object
|
type: object
|
||||||
description: Definition of a character for session initialisation.
|
description: Definition of a character for session initialisation.
|
||||||
|
|||||||
@ -1,20 +1,43 @@
|
|||||||
|
import type {ChangeEvent} from 'react'
|
||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {useNavigate, useParams} from 'react-router-dom'
|
import {useNavigate, useParams} from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AppBar,
|
AppBar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
TextField,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
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 {Configuration, SessionsApi, TurnsApi, UserActionRequestTypeEnum,} from '../api/generated/index'
|
||||||
import NarrativeView from '../components/NarrativeView'
|
import NarrativeView from '../components/NarrativeView'
|
||||||
import SuggestionList from '../components/SuggestionList'
|
import SuggestionList from '../components/SuggestionList'
|
||||||
@ -40,6 +63,10 @@ export default function SessionPage() {
|
|||||||
const [loading, setLoading] = useState<boolean>(true)
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false)
|
const [submitting, setSubmitting] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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. */
|
/** Load existing session on mount. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -84,6 +111,74 @@ export default function SessionPage() {
|
|||||||
void handleTurnSubmit(request)
|
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 (
|
return (
|
||||||
<Box display="flex" flexDirection="column" minHeight="100vh">
|
<Box display="flex" flexDirection="column" minHeight="100vh">
|
||||||
<AppBar position="static" elevation={0} color="transparent"
|
<AppBar position="static" elevation={0} color="transparent"
|
||||||
@ -123,6 +218,58 @@ export default function SessionPage() {
|
|||||||
<NarrativeView narrative={narrative} turnNumber={turnNumber}/>
|
<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 && (
|
{suggestions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider/>
|
<Divider/>
|
||||||
@ -140,6 +287,115 @@ export default function SessionPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,24 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {useNavigate} from 'react-router-dom'
|
import {useNavigate} from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
@ -16,8 +27,22 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
||||||
import type {CreateSessionRequest, ModelInfo} from '../api/generated/index'
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
import {Configuration, CreateSessionRequestSafetyLevelEnum, ModelsApi, SessionsApi,} from '../api/generated/index'
|
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 */
|
/***** API base path – must match quarkus.rest.path in application.yml */
|
||||||
const API_BASE = '/api/v1'
|
const API_BASE = '/api/v1'
|
||||||
@ -39,6 +64,19 @@ export default function StartPage() {
|
|||||||
const [starting, setStarting] = useState<boolean>(false)
|
const [starting, setStarting] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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. */
|
/** Load available models on mount. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modelsApi.listModels()
|
modelsApi.listModels()
|
||||||
@ -56,6 +94,68 @@ export default function StartPage() {
|
|||||||
setSelectedModel(event.target.value)
|
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. */
|
/** Create a new session and navigate to the session page. */
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
if (!selectedModel) return
|
if (!selectedModel) return
|
||||||
@ -66,6 +166,7 @@ export default function StartPage() {
|
|||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
language,
|
language,
|
||||||
safetyLevel: CreateSessionRequestSafetyLevelEnum.standard,
|
safetyLevel: CreateSessionRequestSafetyLevelEnum.standard,
|
||||||
|
scenario: buildScenario(),
|
||||||
}
|
}
|
||||||
const session = await sessionsApi.createSession({createSessionRequest: request})
|
const session = await sessionsApi.createSession({createSessionRequest: request})
|
||||||
navigate(`/session/${session.sessionId}`)
|
navigate(`/session/${session.sessionId}`)
|
||||||
@ -128,6 +229,89 @@ export default function StartPage() {
|
|||||||
fullWidth
|
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
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
@ -140,6 +324,135 @@ export default function StartPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
package de.neitzel.roleplay.business;
|
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.CreateSessionRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
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.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@ -164,5 +170,96 @@ class InMemorySessionServiceTest {
|
|||||||
// Assert
|
// Assert
|
||||||
assertEquals("en", response.getLanguage());
|
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