1
0
Fork 0

add auto-scroll for history, broadcast player list on updates

This commit is contained in:
Sean Sube 2024-05-05 20:17:00 -05:00
parent 84499982f0
commit 778f94c0aa
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
6 changed files with 151 additions and 50 deletions

View File

@ -3,7 +3,7 @@ from collections import deque
from json import dumps, loads from json import dumps, loads
from logging import getLogger from logging import getLogger
from threading import Thread from threading import Thread
from typing import Dict from typing import Dict, Literal
from uuid import uuid4 from uuid import uuid4
import websockets import websockets
@ -101,9 +101,8 @@ async def handler(websocket):
set_actor_agent_for_name(actor.name, actor, player) set_actor_agent_for_name(actor.name, actor, player)
# notify all clients that this character is now active # notify all clients that this character is now active
send_and_append( player_event(character_name, id, "join")
{"type": "player", "name": character_name, "id": id} player_list()
)
elif message_type == "input" and id in characters: elif message_type == "input" and id in characters:
player = characters[id] player = characters[id]
logger.info("queueing input for player %s: %s", player.name, data) logger.info("queueing input for player %s: %s", player.name, data)
@ -121,11 +120,16 @@ async def handler(websocket):
player = characters[id] player = characters[id]
del characters[id] del characters[id]
logger.info("Disconnecting player for %s", player.name)
player_event(player.name, id, "leave")
player_list()
actor, _ = get_actor_agent_for_name(player.name) actor, _ = get_actor_agent_for_name(player.name)
if actor: if actor and player.fallback_agent:
logger.info("Restoring LLM agent for %s", player.name)
set_actor_agent_for_name(player.name, actor, player.fallback_agent) set_actor_agent_for_name(player.name, actor, player.fallback_agent)
logger.info("Client disconnected") logger.info("Client disconnected: %s", id)
socket_thread = None socket_thread = None
@ -200,3 +204,21 @@ def server_event(message: str):
"type": "event", "type": "event",
} }
send_and_append(json_broadcast) send_and_append(json_broadcast)
def player_event(character: str, id: str, event: Literal["join", "leave"]):
json_broadcast = {
"type": "player",
"character": character,
"id": id,
"event": event,
}
send_and_append(json_broadcast)
def player_list():
json_broadcast ={
"type": "players",
"players": {player.name: player_id for player_id, player in characters.items()},
}
send_and_append(json_broadcast)

View File

@ -18,6 +18,8 @@ import {
Stack, Stack,
Alert, Alert,
Switch, Switch,
FormGroup,
FormControlLabel,
} from '@mui/material'; } from '@mui/material';
import { Allotment } from 'allotment'; import { Allotment } from 'allotment';
@ -27,6 +29,7 @@ import { PlayerPanel } from './player.js';
import 'allotment/dist/style.css'; import 'allotment/dist/style.css';
import './main.css'; import './main.css';
import { HistoryPanel } from './history.js';
const useWebSocket = (useWebSocketModule as any).default; const useWebSocket = (useWebSocketModule as any).default;
@ -38,11 +41,6 @@ const statusStrings = {
[ReadyState.UNINSTANTIATED]: 'Unready', [ReadyState.UNINSTANTIATED]: 'Unready',
}; };
export function interleave(arr: Array<any>) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1);
}
export interface AppProps { export interface AppProps {
socketUrl: string; socketUrl: string;
} }
@ -77,12 +75,14 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Ite
export function App(props: AppProps) { export function App(props: AppProps) {
const [ activeTurn, setActiveTurn ] = useState<boolean>(false); const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
const [ autoScroll, setAutoScroll ] = useState<boolean>(true);
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined); const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined); const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
const [ clientId, setClientId ] = useState<string>(''); const [ clientId, setClientId ] = useState<string>('');
const [ world, setWorld ] = useState<Maybe<World>>(undefined); const [ world, setWorld ] = useState<Maybe<World>>(undefined);
const [ themeMode, setThemeMode ] = useState('light'); const [ themeMode, setThemeMode ] = useState('light');
const [ history, setHistory ] = useState<Array<string>>([]); const [ history, setHistory ] = useState<Array<string>>([]);
const [ players, setPlayers ] = useState<Record<string, string>>({});
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl); const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
function setPlayer(actor: Maybe<Actor>) { function setPlayer(actor: Maybe<Actor>) {
@ -112,13 +112,15 @@ export function App(props: AppProps) {
const data = JSON.parse(lastMessage.data); const data = JSON.parse(lastMessage.data);
if (data.type === 'id') { if (data.type === 'id') {
// unicast the client id to the player
setClientId(data.id); setClientId(data.id);
return; return;
} }
if (data.type === 'prompt') { if (data.type === 'prompt') {
// prompts are broadcast to all players
if (data.id === clientId) { if (data.id === clientId) {
// notify the player and show the prompt // only notify the active player
setActiveTurn(true); setActiveTurn(true);
} else { } else {
const message = `Waiting for ${data.character} to take their turn`; const message = `Waiting for ${data.character} to take their turn`;
@ -127,6 +129,11 @@ export function App(props: AppProps) {
return; return;
} }
if (data.type === 'players') {
setPlayers(data.players);
return;
}
setHistory((prev) => prev.concat(data)); setHistory((prev) => prev.concat(data));
// if we get a world event, update the last world state // if we get a world event, update the last world state
@ -134,17 +141,15 @@ export function App(props: AppProps) {
setWorld(data.world); setWorld(data.world);
} }
if (data.type === 'player' && data.id === clientId) { if (doesExist(world) && data.type === 'player' && data.id === clientId && data.event === 'join') {
// find the actor that matches the player name // find the actor that matches the player name
const { name } = data; const { character: characterName } = data;
// eslint-disable-next-line no-restricted-syntax const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName);
const actor = world?.rooms.flatMap((room) => room.actors).find((a) => a.name === name);
setCharacter(actor); setCharacter(actor);
} }
} }
}, [lastMessage]); }, [lastMessage]);
const items = history.map((item, index) => <EventItem key={`item-${index}`} event={item} />);
return <ThemeProvider theme={theme}> return <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
@ -156,24 +161,30 @@ export function App(props: AppProps) {
<Typography> <Typography>
Status: {connectionStatus} Status: {connectionStatus}
</Typography> </Typography>
<Switch <FormGroup row>
checked={themeMode === 'dark'} <FormControlLabel control={<Switch
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} checked={themeMode === 'dark'}
inputProps={{ 'aria-label': 'controlled' }} onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
sx={{ marginLeft: 'auto' }} inputProps={{ 'aria-label': 'controlled' }}
/> sx={{ marginLeft: 'auto' }}
/>} label="Dark Mode" />
<FormControlLabel control={<Switch
checked={autoScroll}
onChange={() => setAutoScroll(autoScroll === false)}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>} label="Auto Scroll" />
</FormGroup>
</Stack> </Stack>
</Alert> </Alert>
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<Allotment className='body-allotment'> <Allotment className='body-allotment'>
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history"> <Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} /> <PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
</Stack> </Stack>
<Stack direction="column" sx={{ minWidth: 600 }}> <Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
<List sx={{ width: '100%', bgcolor: 'background.paper' }} className="scroll-history"> <HistoryPanel history={history} scroll={autoScroll ? 'instant' : false} />
{interleave(items)}
</List>
</Stack> </Stack>
</Allotment> </Allotment>
</Stack> </Stack>

View File

@ -1,11 +1,13 @@
import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material'; import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material';
import React from 'react'; import React, { MutableRefObject } from 'react';
import { formatters } from './format.js'; import { formatters } from './format.js';
export interface EventItemProps { export interface EventItemProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any; event: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
focusRef?: MutableRefObject<any>;
} }
export function ActionItem(props: EventItemProps) { export function ActionItem(props: EventItemProps) {
@ -13,7 +15,7 @@ export function ActionItem(props: EventItemProps) {
const { actor, room, type } = event; const { actor, room, type } = event;
const content = formatters[type](event); const content = formatters[type](event);
return <ListItem alignItems="flex-start"> return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" /> <Avatar alt={actor} src="/static/images/avatar/1.jpg" />
</ListItemAvatar> </ListItemAvatar>
@ -41,7 +43,7 @@ export function WorldItem(props: EventItemProps) {
const { step, world } = event; const { step, world } = event;
const { theme } = world; const { theme } = world;
return <ListItem alignItems="flex-start"> return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" /> <Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
</ListItemAvatar> </ListItemAvatar>
@ -65,7 +67,7 @@ export function MessageItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { message } = event; const { message } = event;
return <ListItem alignItems="flex-start"> return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt="System" src="/static/images/avatar/1.jpg" /> <Avatar alt="System" src="/static/images/avatar/1.jpg" />
</ListItemAvatar> </ListItemAvatar>
@ -87,14 +89,25 @@ export function MessageItem(props: EventItemProps) {
export function PlayerItem(props: EventItemProps) { export function PlayerItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { name } = event; const { character, event: innerEvent, id } = event;
return <ListItem alignItems="flex-start"> let primary = '';
let secondary = '';
if (innerEvent === 'join') {
primary = 'New Player';
secondary = `${id} is now playing as ${character}`;
}
if (innerEvent === 'leave') {
primary = 'Player Left';
secondary = `${id} has left the game. ${character} is now controlled by an LLM`;
}
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt={name} src="/static/images/avatar/1.jpg" /> <Avatar alt={character} src="/static/images/avatar/1.jpg" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="New Player" primary={primary}
secondary={ secondary={
<Typography <Typography
sx={{ display: 'block' }} sx={{ display: 'block' }}
@ -102,7 +115,7 @@ export function PlayerItem(props: EventItemProps) {
variant="body2" variant="body2"
color="text.primary" color="text.primary"
> >
Someone is playing as {name} {secondary}
</Typography> </Typography>
} }
/> />
@ -116,15 +129,15 @@ export function EventItem(props: EventItemProps) {
switch (type) { switch (type) {
case 'action': case 'action':
case 'result': case 'result':
return <ActionItem event={event} />; return <ActionItem event={event} focusRef={props.focusRef} />;
case 'event': case 'event':
return <MessageItem event={event} />; return <MessageItem event={event} focusRef={props.focusRef} />;
case 'player': case 'player':
return <PlayerItem event={event} />; return <PlayerItem event={event} focusRef={props.focusRef} />;
case 'world': case 'world':
return <WorldItem event={event} />; return <WorldItem event={event} focusRef={props.focusRef} />;
default: default:
return <ListItem> return <ListItem ref={props.focusRef}>
<ListItemText primary={`Unknown event type: ${type}`} /> <ListItemText primary={`Unknown event type: ${type}`} />
</ListItem>; </ListItem>;
} }

39
client/src/history.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Divider, List } from '@mui/material';
import React, { useEffect, useRef } from 'react';
import { Maybe } from '@apextoaster/js-utils';
import { EventItem } from './events';
export interface HistoryPanelProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
history: Array<any>;
scroll: 'auto' | 'instant' | 'smooth' | false;
}
export function HistoryPanel(props: HistoryPanelProps) {
const { history, scroll } = props;
const scrollRef = useRef<Maybe<Element>>(undefined);
useEffect(() => {
if (scrollRef.current && scroll !== false) {
scrollRef.current.scrollIntoView({ behavior: scroll as ScrollBehavior, block: 'end' });
}
}, [scrollRef.current, props.scroll]);
const items = history.map((item, index) => {
if (index === history.length - 1) {
return <EventItem key={`item-${index}`} event={item} focusRef={scrollRef} />;
}
return <EventItem key={`item-${index}`} event={item} />;
});
return <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{interleave(items)}
</List>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function interleave(arr: Array<any>) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1);
}

View File

@ -16,24 +16,36 @@ export function PlayerPanel(props: PlayerPanelProps) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
if (!actor) { if (!actor) {
return <Card> return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent> <CardContent>
<Typography variant="h6">No player character</Typography> <Typography variant="h6">No player character</Typography>
</CardContent> </CardContent>
</Card>; </Card>;
} }
return <Card> return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent> <CardContent>
<Stack direction="column" spacing={2}> <Stack direction="column" spacing={2}>
{activeTurn && <Alert severity="warning">It's your turn!</Alert>} {activeTurn && <Alert severity="warning">It's your turn!</Alert>}
<Typography variant="h6">Playing as: {actor.name}</Typography> <Typography variant="h6">Playing as: {actor.name}</Typography>
<Typography variant="body1">{actor.backstory}</Typography> <Typography variant="body1">{actor.backstory}</Typography>
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<TextField label="Input" variant="outlined" fullWidth value={input} onChange={(event) => setInput(event.target.value)} /> <TextField
fullWidth
label="Input"
variant="outlined"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendInput(input);
setInput('');
}
}}
/>
<Button variant="contained" onClick={() => { <Button variant="contained" onClick={() => {
setInput('');
sendInput(input); sendInput(input);
setInput('');
}}>Send</Button> }}>Send</Button>
</Stack> </Stack>
</Stack> </Stack>

View File

@ -98,12 +98,16 @@ export function WorldPanel(props: { world: Maybe<World> } & BaseEntityItemProps)
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
if (!doesExist(world)) { if (!doesExist(world)) {
return <Typography variant="h4"> return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
No world data available <CardContent>
</Typography>; <Typography variant="h6">
No world data available
</Typography>
</CardContent>
</Card>;
} }
return <Card> return <Card style={{ minHeight: 200, overflow: 'auto' }}>
<CardContent> <CardContent>
<Typography gutterBottom variant="h5" component="div">{world.name}</Typography> <Typography gutterBottom variant="h5" component="div">{world.name}</Typography>
<Typography variant="body1"> <Typography variant="body1">