add auto-scroll for history, broadcast player list on updates
This commit is contained in:
parent
84499982f0
commit
778f94c0aa
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
<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>
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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)} />
|
||||
<Button variant="contained" onClick={() => {
|
||||
setInput('');
|
||||
<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={() => {
|
||||
sendInput(input);
|
||||
setInput('');
|
||||
}}>Send</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
@ -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">
|
||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">
|
||||
No world data available
|
||||
</Typography>;
|
||||
</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">
|
||||
|
|
Loading…
Reference in New Issue