Enhance project setup and configuration for RolePlay application

- Update .gitignore to include additional build outputs and IDE files.
- Modify pom.xml to add new plugin versions and configurations for frontend and OpenAPI TypeScript client generation.
- Introduce project guidelines in roleplay-project.mdc, detailing architecture, coding standards, and testing practices.
- Add initial documentation for the RolePlay concept and specifications.
- Implement catch-all JAX-RS resource for serving the React application and establish API base path in application.yml.
- Create foundational web components and TypeScript configuration for the frontend application.
This commit is contained in:
Konrad Neitzel 2026-02-20 18:08:24 +01:00
parent e8bb6a64b7
commit f91604aea6
23 changed files with 3829 additions and 2 deletions

View File

@ -0,0 +1,69 @@
---
description: RolePlay project context, architecture, and coding standards (Java/Quarkus, Maven, frontend)
alwaysApply: true
---
# RolePlay Project Instructions
## Project context
- **Project**: RolePlay
- **Java**: 21
- **Build**: Maven
- **Framework**: Quarkus (latest stable)
- **GroupId**: de.neitzel | **ArtifactId**: roleplay
- **Base package**: `de.neitzel.roleplay`
- **Sub-packages**: `business`, `common`, `data`, `fascade`
- Startup code lives in the base package.
- **DB migrations**: Liquibase; scripts in `src/main/resources/db/migration`.
## Architecture and package rules
- **Startup/bootstrap**: `de.neitzel.roleplay`
- **Business logic**: `de.neitzel.roleplay.business`
- **Shared utilities, cross-cutting types**: `de.neitzel.roleplay.common`
- **Persistence / data access**: `de.neitzel.roleplay.data`
- **External-facing facades (REST, API)**: `de.neitzel.roleplay.fascade`
- Keep clear package boundaries; avoid circular dependencies.
## Coding standards (Java)
- Use Lombok to reduce boilerplate where it helps.
- Prefer immutability: `final` fields, constructor injection.
- Use Quarkus idioms; avoid heavyweight frameworks.
- Keep methods small and focused; one responsibility per class.
- Use Java 21 features when they improve clarity.
- Add concise comments only for non-obvious logic.
- All classes, fields, and methods (including private) must have JavaDoc describing purpose and usage.
## Testing
- Add or update unit tests for new or changed logic.
- Use JUnit 5 and Mockito; ArrangeActAssert; keep tests deterministic.
- Mock external dependencies; add integration tests only when requested.
## Maven and dependencies
- Target Java 21 in the Maven toolchain.
- Use the latest stable Quarkus platform BOM.
- Keep dependencies minimal; no new libraries without need.
- Keep versions up to date (especially for security).
- Put plugin and dependency versions in the `properties` section of `pom.xml`.
- Use `quarkus-maven-plugin` for build/run; `maven-surefire-plugin` for tests (JUnit 5); `maven-compiler-plugin` with Java 21.
## Frontend (React / Vite)
- Frontend lives under `src/main/web` (React, TypeScript, Vite, MUI).
- OpenAPI TypeScript client is generated into `src/main/web/src/api/generated/` by Maven (`mvn generate-sources`); do not edit by hand.
- Production build output: `target/web-dist`; copied to `META-INF/resources` by `maven-resources-plugin` for the Quarkus SPA.
## Do and dont
- **Do**: keep code in the correct package; add tests for business logic.
- **Dont**: put startup code outside the base package; add unnecessary abstraction layers.
## Output expectations
- Provide complete, compilable code with package declarations and imports.
- Prefer Quarkus-friendly APIs and annotations.
- When adding or changing logic, update or add unit tests.

19
.gitignore vendored
View File

@ -1,2 +1,21 @@
# Maven build output
/target/ /target/
# Vite build output (written into resources so it gets into the JAR)
src/main/resources/META-INF/resources/
# IDE
/.idea/ /.idea/
*.iml
# Node dependencies
node_modules/
# OpenAPI-generated TypeScript client (regenerated by mvn generate-sources)
src/main/web/src/api/generated/
# TypeScript incremental build cache
*.tsbuildinfo
# OS metadata
.DS_Store

66
pom.xml
View File

@ -11,6 +11,7 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version> <maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version> <maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id> <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id> <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
@ -20,6 +21,9 @@
<junit.jupiter.version>5.10.3</junit.jupiter.version> <junit.jupiter.version>5.10.3</junit.jupiter.version>
<mockito.version>5.12.0</mockito.version> <mockito.version>5.12.0</mockito.version>
<openapi.generator.version>7.11.0</openapi.generator.version> <openapi.generator.version>7.11.0</openapi.generator.version>
<frontend.plugin.version>1.15.1</frontend.plugin.version>
<node.version>v22.13.1</node.version>
<npm.version>10.9.2</npm.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@ -139,6 +143,26 @@
<generateSupportingFiles>false</generateSupportingFiles> <generateSupportingFiles>false</generateSupportingFiles>
</configuration> </configuration>
</execution> </execution>
<execution>
<id>generate-typescript-client</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
<generatorName>typescript-fetch</generatorName>
<output>${project.basedir}/src/main/web/src/api/generated</output>
<configOptions>
<supportsES6>true</supportsES6>
<useSingleRequestParameter>true</useSingleRequestParameter>
<enumPropertyNaming>original</enumPropertyNaming>
<modelPropertyNaming>camelCase</modelPropertyNaming>
<stringEnums>true</stringEnums>
<withInterfaces>false</withInterfaces>
</configOptions>
<generateSupportingFiles>true</generateSupportingFiles>
</configuration>
</execution>
</executions> </executions>
</plugin> </plugin>
<plugin> <plugin>
@ -164,6 +188,48 @@
<argLine>-Dnet.bytebuddy.experimental=true</argLine> <argLine>-Dnet.bytebuddy.experimental=true</argLine>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend.plugin.version}</version>
<configuration>
<workingDirectory>src/main/web</workingDirectory>
<installDirectory>${project.build.directory}/node</installDirectory>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<phase>initialize</phase>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion>
</configuration>
</execution>
<execution>
<id>npm-install</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>npm-build</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@ -0,0 +1,69 @@
package de.neitzel.roleplay.fascade;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
/**
* Catch-all JAX-RS resource that serves {@code index.html} for every path
* that is not handled by a more specific resource. This enables React Router's
* client-side routing: when the user navigates directly to
* {@code /session/abc123} the browser receives {@code index.html} and React
* Router takes over.
*
* <p>JAX-RS selects the most specific matching path first, so all
* {@code /api/v1/...} routes defined by the generated API interfaces always
* take priority over this catch-all.
*/
@Path("/")
public class SpaFallbackResource {
/**
* Returns {@code index.html} for the application root.
*
* @return 200 response with the React application shell
*/
@GET
@Produces(MediaType.TEXT_HTML)
public Response index() {
return serveIndex();
}
/**
* Loads {@code index.html} from {@code META-INF/resources} on the
* classpath and streams it as the response body.
*
* @return 200 with index.html, or 404 if the file is not on the classpath
*/
private Response serveIndex() {
InputStream stream = getClass()
.getClassLoader()
.getResourceAsStream("META-INF/resources/index.html");
if (stream == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("index.html not found run 'mvn process-resources' first.")
.build();
}
return Response.ok(stream, MediaType.TEXT_HTML).build();
}
/**
* Returns {@code index.html} for any sub-path not matched by a more
* specific JAX-RS resource.
*
* @param path the unmatched path segment (ignored)
* @return 200 response with the React application shell
*/
@GET
@Path("{path:(?!api/).*}")
@Produces(MediaType.TEXT_HTML)
public Response fallback(@PathParam("path") String path) {
return serveIndex();
}
}

View File

@ -9,5 +9,5 @@ quarkus:
url: http://debian:11434 url: http://debian:11434
connect-timeout: 5000 connect-timeout: 5000
read-timeout: 120000 read-timeout: 120000
http: rest:
root-path: /api/v1 path: /api/v1

12
src/main/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RolePlay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2624
src/main/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "roleplay-web",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@mui/icons-material": "^6.4.4",
"@mui/material": "^6.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.1.0"
}
}

View File

@ -0,0 +1,19 @@
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import StartPage from './pages/StartPage'
import SessionPage from './pages/SessionPage'
/**
* Root application component. Sets up client-side routing between the model
* selection page and the active session page.
*/
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<StartPage/>}/>
<Route path="/session/:sessionId" element={<SessionPage/>}/>
</Routes>
</BrowserRouter>
)
}

View File

@ -0,0 +1,12 @@
/**
* Central re-export of the generated OpenAPI client and its models.
*
* The actual sources under ./generated/ are produced by
* openapi-generator-maven-plugin (typescript-fetch generator) during
* `mvn generate-sources`. Run `mvn generate-sources` once before opening
* the project in an IDE to make these imports resolve.
*/
export * from './generated/index'
/** Base URL used by the generated fetch client. Always points to /api/v1. */
export const API_BASE = '/api/v1'

View File

@ -0,0 +1,156 @@
import {useState} from 'react'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
CircularProgress,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import DirectionsRunIcon from '@mui/icons-material/DirectionsRun'
import SendIcon from '@mui/icons-material/Send'
import type {RecommendationRequest, TurnRequest} from '../api/generated/index'
import {UserActionRequestTypeEnum,} from '../api/generated/index'
/***** Props for the ActionInput component. */
interface ActionInputProps {
/** Called when the user submits an action. */
onSubmit: (request: TurnRequest) => void
/** Whether the submit button should be disabled (e.g. waiting for response). */
disabled?: boolean
}
type ActionType = 'speech' | 'action'
/**
* Provides the user interface for composing and submitting a turn action.
* Includes a type toggle (speech / action), a free-text input, and an
* optional collapsible section for narrative recommendations.
*/
export default function ActionInput({onSubmit, disabled}: ActionInputProps) {
const [actionType, setActionType] = useState<ActionType>('speech')
const [content, setContent] = useState<string>('')
const [desiredTone, setDesiredTone] = useState<string>('')
const [preferredDirection, setPreferredDirection] = useState<string>('')
const handleSubmit = () => {
if (!content.trim()) return
const recommendation: RecommendationRequest | undefined =
desiredTone.trim() || preferredDirection.trim()
? {
desiredTone: desiredTone.trim() || undefined,
preferredDirection: preferredDirection.trim() || undefined,
}
: undefined
const typeEnum =
actionType === 'speech'
? UserActionRequestTypeEnum.speech
: UserActionRequestTypeEnum.action
const request: TurnRequest = {
userAction: {
type: typeEnum,
content: content.trim(),
},
recommendation,
}
onSubmit(request)
setContent('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSubmit()
}
}
return (
<Box display="flex" flexDirection="column" gap={2}>
<ToggleButtonGroup
value={actionType}
exclusive
onChange={(_, v: ActionType | null) => v && setActionType(v)}
size="small"
disabled={disabled}
>
<ToggleButton value="speech" aria-label="speech">
<RecordVoiceOverIcon sx={{mr: 0.5, fontSize: 18}}/>
Speech
</ToggleButton>
<ToggleButton value="action" aria-label="action">
<DirectionsRunIcon sx={{mr: 0.5, fontSize: 18}}/>
Action
</ToggleButton>
</ToggleButtonGroup>
<TextField
multiline
minRows={2}
maxRows={6}
fullWidth
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
actionType === 'speech'
? 'What does your character say?'
: 'What does your character do?'
}
disabled={disabled}
helperText="Ctrl+Enter to submit"
/>
<Accordion disableGutters elevation={0} sx={{border: '1px solid', borderColor: 'divider', borderRadius: 1}}>
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
<Typography variant="body2" color="text.secondary">
Narrative recommendation (optional)
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={2}>
<TextField
label="Desired tone"
value={desiredTone}
onChange={(e) => setDesiredTone(e.target.value)}
helperText='e.g. "tense", "humorous", "mysterious"'
disabled={disabled}
fullWidth
size="small"
/>
<TextField
label="Preferred direction"
value={preferredDirection}
onChange={(e) => setPreferredDirection(e.target.value)}
helperText="Free-text hint for the narrator"
disabled={disabled}
fullWidth
size="small"
multiline
maxRows={3}
/>
</Box>
</AccordionDetails>
</Accordion>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
endIcon={disabled ? <CircularProgress size={16} color="inherit"/> : <SendIcon/>}
onClick={handleSubmit}
disabled={!content.trim() || disabled}
>
{disabled ? 'Processing…' : 'Submit'}
</Button>
</Box>
</Box>
)
}

View File

@ -0,0 +1,35 @@
import {Divider, Paper, Typography} from '@mui/material'
/** Props for the NarrativeView component. */
interface NarrativeViewProps {
/** The narrative text to display. */
narrative: string
/** Current turn number, displayed as subtitle. */
turnNumber: number
}
/**
* Displays the AI-generated narrative text for the current turn inside a
* styled MUI Paper card with atmospheric typography.
*/
export default function NarrativeView({narrative, turnNumber}: NarrativeViewProps) {
return (
<Paper elevation={3} sx={{p: 3, borderRadius: 2}}>
<Typography variant="overline" color="text.secondary">
{turnNumber === 0 ? 'Opening Scene' : `Turn ${turnNumber}`}
</Typography>
<Divider sx={{my: 1}}/>
{narrative.split('\n').filter(Boolean).map((paragraph, i) => (
<Typography
key={i}
variant="body1"
paragraph
sx={{lineHeight: 1.8, fontStyle: 'italic', mb: 1.5}}
>
{paragraph}
</Typography>
))}
</Paper>
)
}

View File

@ -0,0 +1,85 @@
import {Box, Chip, Paper, Typography} from '@mui/material'
import type {Suggestion} from '../api/generated/index'
/** Props for the SuggestionList component. */
interface SuggestionListProps {
/** Suggestions to display. */
suggestions: Suggestion[]
/** Called when the user selects a suggestion. */
onSelect: (suggestion: Suggestion) => void
/** Whether interaction is currently disabled (e.g. while submitting). */
disabled?: boolean
}
/** Colour map from risk level to MUI chip colour. */
const riskColour: Record<string, 'success' | 'warning' | 'error'> = {
low: 'success',
medium: 'warning',
high: 'error',
}
/** Icon map from suggestion type to emoji label. */
const typeIcon: Record<string, string> = {
player_action: '🗡️',
world_event: '🌍',
npc_action: '🧑',
twist: '🌀',
}
/**
* Displays a grid of suggestion cards. Each card can be clicked to select
* that suggestion as the user's next action (type = "choice").
*/
export default function SuggestionList({suggestions, onSelect, disabled}: SuggestionListProps) {
if (suggestions.length === 0) return null
return (
<Box>
<Typography variant="overline" color="text.secondary" display="block" mb={1}>
What could happen next?
</Typography>
<Box
display="grid"
gridTemplateColumns="repeat(auto-fill, minmax(220px, 1fr))"
gap={2}
>
{suggestions.map((s) => (
<Paper
key={s.id}
elevation={2}
sx={{
p: 2,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
border: '1px solid transparent',
transition: 'border-color 0.2s, transform 0.15s',
'&:hover': disabled ? {} : {
borderColor: 'primary.main',
transform: 'translateY(-2px)',
},
}}
onClick={() => !disabled && onSelect(s)}
>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Typography variant="body2" fontWeight={700} sx={{flex: 1, pr: 1}}>
{typeIcon[s.type] ?? '•'} {s.title}
</Typography>
{s.riskLevel && (
<Chip
label={s.riskLevel}
color={riskColour[s.riskLevel] ?? 'default'}
size="small"
sx={{fontSize: '0.65rem', height: 20}}
/>
)}
</Box>
<Typography variant="caption" color="text.secondary">
{s.description}
</Typography>
</Paper>
))}
</Box>
</Box>
)
}

View File

@ -0,0 +1,36 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {CssBaseline, ThemeProvider, createTheme} from '@mui/material'
import App from './App'
/** Dark-toned MUI theme that fits the atmospheric role-play genre. */
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#b39ddb',
},
secondary: {
main: '#80cbc4',
},
background: {
default: '#1a1a2e',
paper: '#16213e',
},
},
typography: {
fontFamily: '"Georgia", "Palatino", serif',
h4: {fontWeight: 700},
h5: {fontWeight: 600},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline/>
<App/>
</ThemeProvider>
</StrictMode>,
)

View File

@ -0,0 +1,145 @@
import {useEffect, useState} from 'react'
import {useNavigate, useParams} from 'react-router-dom'
import {
Alert,
AppBar,
Box,
CircularProgress,
Container,
Divider,
IconButton,
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 {Configuration, SessionsApi, TurnsApi, UserActionRequestTypeEnum,} from '../api/generated/index'
import NarrativeView from '../components/NarrativeView'
import SuggestionList from '../components/SuggestionList'
import ActionInput from '../components/ActionInput'
const API_BASE = '/api/v1'
const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
const turnsApi = new TurnsApi(new Configuration({basePath: API_BASE}))
/**
* Active role-play session page. Loads the session state, displays the
* current narrative, shows suggestions, and accepts user input for the next
* turn.
*/
export default function SessionPage() {
const {sessionId} = useParams<{ sessionId: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<SessionResponse | null>(null)
const [narrative, setNarrative] = useState<string>('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [turnNumber, setTurnNumber] = useState<number>(0)
const [loading, setLoading] = useState<boolean>(true)
const [submitting, setSubmitting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
/** Load existing session on mount. */
useEffect(() => {
if (!sessionId) return
sessionsApi.getSession({sessionId})
.then((s) => {
setSession(s)
setNarrative(s.narrative ?? '')
setSuggestions(s.suggestions ?? [])
setTurnNumber(s.turnNumber)
})
.catch(() => setError('Session not found or server unavailable.'))
.finally(() => setLoading(false))
}, [sessionId])
/** Submit a user action turn. */
const handleTurnSubmit = async (request: TurnRequest) => {
if (!sessionId) return
setSubmitting(true)
setError(null)
try {
const turn = await turnsApi.submitTurn({sessionId, turnRequest: request})
setNarrative(turn.narrative)
setSuggestions(turn.suggestions ?? [])
setTurnNumber(turn.turnNumber)
} catch {
setError('Failed to submit turn. Please try again.')
} finally {
setSubmitting(false)
}
}
/** Handle a suggestion click submit it as a "choice" action. */
const handleSuggestionSelect = (suggestion: Suggestion) => {
const request: TurnRequest = {
userAction: {
type: UserActionRequestTypeEnum.choice,
content: suggestion.title,
selectedSuggestionId: suggestion.id,
},
}
void handleTurnSubmit(request)
}
return (
<Box display="flex" flexDirection="column" minHeight="100vh">
<AppBar position="static" elevation={0} color="transparent"
sx={{borderBottom: '1px solid', borderColor: 'divider'}}>
<Toolbar>
<Tooltip title="Back to model selection">
<IconButton edge="start" onClick={() => navigate('/')} sx={{mr: 1}}>
<ArrowBackIcon/>
</IconButton>
</Tooltip>
<AutoStoriesIcon sx={{mr: 1, color: 'primary.main'}}/>
<Typography variant="h6" sx={{flex: 1}}>
RolePlay
</Typography>
{session && (
<Typography variant="caption" color="text.secondary">
{session.model} · {session.language}
</Typography>
)}
</Toolbar>
</AppBar>
<Container maxWidth="md" sx={{flex: 1, py: 4}}>
{error && (
<Alert severity="error" sx={{mb: 3}}>
{error}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress/>
</Box>
) : (
<Box display="flex" flexDirection="column" gap={3}>
{narrative && (
<NarrativeView narrative={narrative} turnNumber={turnNumber}/>
)}
{suggestions.length > 0 && (
<>
<Divider/>
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
disabled={submitting}
/>
</>
)}
<Divider/>
<ActionInput onSubmit={handleTurnSubmit} disabled={submitting}/>
</Box>
)}
</Container>
</Box>
)
}

View File

@ -0,0 +1,145 @@
import {useEffect, useState} from 'react'
import {useNavigate} from 'react-router-dom'
import {
Alert,
Box,
Button,
CircularProgress,
Container,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
SelectChangeEvent,
TextField,
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'
/***** API base path must match quarkus.rest.path in application.yml */
const API_BASE = '/api/v1'
const modelsApi = new ModelsApi(new Configuration({basePath: API_BASE}))
const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
/**
* Landing page where the user selects an Ollama model and optional settings
* before starting a new role-play session.
*/
export default function StartPage() {
const navigate = useNavigate()
const [models, setModels] = useState<ModelInfo[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
const [language, setLanguage] = useState<string>('en')
const [loading, setLoading] = useState<boolean>(true)
const [starting, setStarting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
/** Load available models on mount. */
useEffect(() => {
modelsApi.listModels()
.then((resp) => {
setModels(resp.models ?? [])
if (resp.models && resp.models.length > 0) {
setSelectedModel(resp.models[0].name)
}
})
.catch(() => setError('Could not load models. Is the Quarkus server running?'))
.finally(() => setLoading(false))
}, [])
const handleModelChange = (event: SelectChangeEvent) => {
setSelectedModel(event.target.value)
}
/** Create a new session and navigate to the session page. */
const handleStart = async () => {
if (!selectedModel) return
setStarting(true)
setError(null)
try {
const request: CreateSessionRequest = {
model: selectedModel,
language,
safetyLevel: CreateSessionRequestSafetyLevelEnum.standard,
}
const session = await sessionsApi.createSession({createSessionRequest: request})
navigate(`/session/${session.sessionId}`)
} catch {
setError('Failed to create session. Please try again.')
} finally {
setStarting(false)
}
}
return (
<Container maxWidth="sm" sx={{mt: 8}}>
<Paper elevation={4} sx={{p: 5, borderRadius: 3}}>
<Box display="flex" alignItems="center" gap={2} mb={4}>
<AutoStoriesIcon sx={{fontSize: 48, color: 'primary.main'}}/>
<Typography variant="h4" component="h1">
RolePlay
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" mb={4}>
Choose an AI model and start your story.
</Typography>
{error && (
<Alert severity="error" sx={{mb: 3}}>
{error}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress/>
</Box>
) : (
<Box display="flex" flexDirection="column" gap={3}>
<FormControl fullWidth disabled={starting}>
<InputLabel id="model-label">AI Model</InputLabel>
<Select
labelId="model-label"
value={selectedModel}
label="AI Model"
onChange={handleModelChange}
>
{models.map((m) => (
<MenuItem key={m.name} value={m.name}>
{m.displayName ?? m.name}
{m.family ? ` · ${m.family}` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Language"
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText="BCP-47 language tag, e.g. en, de, fr"
disabled={starting}
fullWidth
/>
<Button
variant="contained"
size="large"
onClick={handleStart}
disabled={!selectedModel || starting || loading}
startIcon={starting ? <CircularProgress size={18} color="inherit"/> : <AutoStoriesIcon/>}
>
{starting ? 'Starting…' : 'Begin Story'}
</Button>
</Box>
)}
</Paper>
</Container>
)
}

View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": [
"src"
]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": [
"ES2023",
"DOM"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/',
build: {
outDir: '../resources/META-INF/resources',
emptyOutDir: true,
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RolePlay</title>
<script type="module" crossorigin src="/assets/index-PUayaP4F.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>