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 logging import getLogger
from threading import Thread
from typing import Dict
from typing import Dict, Literal
from uuid import uuid4
import websockets
@ -101,9 +101,8 @@ async def handler(websocket):
set_actor_agent_for_name(actor.name, actor, player)
# notify all clients that this character is now active
send_and_append(
{"type": "player", "name": character_name, "id": id}
)
player_event(character_name, id, "join")
player_list()
elif message_type == "input" and id in characters:
player = characters[id]
logger.info("queueing input for player %s: %s", player.name, data)
@ -121,11 +120,16 @@ async def handler(websocket):
player = 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)
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)
logger.info("Client disconnected")
logger.info("Client disconnected: %s", id)
socket_thread = None
@ -200,3 +204,21 @@ def server_event(message: str):
"type": "event",
}
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,
Alert,
Switch,
FormGroup,
FormControlLabel,
} from '@mui/material';
import { Allotment } from 'allotment';
@ -27,6 +29,7 @@ import { PlayerPanel } from './player.js';
import 'allotment/dist/style.css';
import './main.css';
import { HistoryPanel } from './history.js';
const useWebSocket = (useWebSocketModule as any).default;
@ -38,11 +41,6 @@ const statusStrings = {
[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 {
socketUrl: string;
}
@ -77,12 +75,14 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Ite
export function App(props: AppProps) {
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
const [ autoScroll, setAutoScroll ] = useState<boolean>(true);
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
const [ clientId, setClientId ] = useState<string>('');
const [ world, setWorld ] = useState<Maybe<World>>(undefined);
const [ themeMode, setThemeMode ] = useState('light');
const [ history, setHistory ] = useState<Array<string>>([]);
const [ players, setPlayers ] = useState<Record<string, string>>({});
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
function setPlayer(actor: Maybe<Actor>) {
@ -112,13 +112,15 @@ export function App(props: AppProps) {
const data = JSON.parse(lastMessage.data);
if (data.type === 'id') {
// unicast the client id to the player
setClientId(data.id);
return;
}
if (data.type === 'prompt') {
// prompts are broadcast to all players
if (data.id === clientId) {
// notify the player and show the prompt
// only notify the active player
setActiveTurn(true);
} else {
const message = `Waiting for ${data.character} to take their turn`;
@ -127,6 +129,11 @@ export function App(props: AppProps) {
return;
}
if (data.type === 'players') {
setPlayers(data.players);
return;
}
setHistory((prev) => prev.concat(data));
// if we get a world event, update the last world state
@ -134,17 +141,15 @@ export function App(props: AppProps) {
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
const { name } = data;
// eslint-disable-next-line no-restricted-syntax
const actor = world?.rooms.flatMap((room) => room.actors).find((a) => a.name === name);
const { character: characterName } = data;
const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName);
setCharacter(actor);
}
}
}, [lastMessage]);
const items = history.map((item, index) => <EventItem key={`item-${index}`} event={item} />);
return <ThemeProvider theme={theme}>
<CssBaseline />
@ -156,24 +161,30 @@ export function App(props: AppProps) {
<Typography>
Status: {connectionStatus}
</Typography>
<Switch
checked={themeMode === 'dark'}
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>
<FormGroup row>
<FormControlLabel control={<Switch
checked={themeMode === 'dark'}
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
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>
</Alert>
<Stack direction="row" spacing={2}>
<Allotment className='body-allotment'>
<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} />
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
</Stack>
<Stack direction="column" sx={{ minWidth: 600 }}>
<List sx={{ width: '100%', bgcolor: 'background.paper' }} className="scroll-history">
{interleave(items)}
</List>
<Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
<HistoryPanel history={history} scroll={autoScroll ? 'instant' : false} />
</Stack>
</Allotment>
</Stack>

View File

@ -1,11 +1,13 @@
import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material';
import React from 'react';
import React, { MutableRefObject } from 'react';
import { formatters } from './format.js';
export interface EventItemProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
focusRef?: MutableRefObject<any>;
}
export function ActionItem(props: EventItemProps) {
@ -13,7 +15,7 @@ export function ActionItem(props: EventItemProps) {
const { actor, room, type } = event;
const content = formatters[type](event);
return <ListItem alignItems="flex-start">
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
@ -41,7 +43,7 @@ export function WorldItem(props: EventItemProps) {
const { step, world } = event;
const { theme } = world;
return <ListItem alignItems="flex-start">
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
@ -65,7 +67,7 @@ export function MessageItem(props: EventItemProps) {
const { event } = props;
const { message } = event;
return <ListItem alignItems="flex-start">
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
<Avatar alt="System" src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
@ -87,14 +89,25 @@ export function MessageItem(props: EventItemProps) {
export function PlayerItem(props: EventItemProps) {
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>
<Avatar alt={name} src="/static/images/avatar/1.jpg" />
<Avatar alt={character} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary="New Player"
primary={primary}
secondary={
<Typography
sx={{ display: 'block' }}
@ -102,7 +115,7 @@ export function PlayerItem(props: EventItemProps) {
variant="body2"
color="text.primary"
>
Someone is playing as {name}
{secondary}
</Typography>
}
/>
@ -116,15 +129,15 @@ export function EventItem(props: EventItemProps) {
switch (type) {
case 'action':
case 'result':
return <ActionItem event={event} />;
return <ActionItem event={event} focusRef={props.focusRef} />;
case 'event':
return <MessageItem event={event} />;
return <MessageItem event={event} focusRef={props.focusRef} />;
case 'player':
return <PlayerItem event={event} />;
return <PlayerItem event={event} focusRef={props.focusRef} />;
case 'world':
return <WorldItem event={event} />;
return <WorldItem event={event} focusRef={props.focusRef} />;
default:
return <ListItem>
return <ListItem ref={props.focusRef}>
<ListItemText primary={`Unknown event type: ${type}`} />
</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
if (!actor) {
return <Card>
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent>
<Typography variant="h6">No player character</Typography>
</CardContent>
</Card>;
}
return <Card>
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent>
<Stack direction="column" spacing={2}>
{activeTurn && <Alert severity="warning">It's your turn!</Alert>}
<Typography variant="h6">Playing as: {actor.name}</Typography>
<Typography variant="body1">{actor.backstory}</Typography>
<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={() => {
setInput('');
sendInput(input);
setInput('');
}}>Send</Button>
</Stack>
</Stack>

View File

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