Conversation
…vars to component
…/DepthLimitNode components
There was a problem hiding this comment.
Pull request overview
Adds a small JSON API surface (speech/game/meta + Markov distribution) and a new console UI screen that visualizes the Markov chain language distribution as a tree, to support console-based monitoring/management of the streamer.
Changes:
- Introduce
routes/api/*with CORS-enabled endpoints including/api/distribution. - Track last spoken text in
MakaMujoand expose it via/api/speech. - Replace the console’s API tester view with a distribution-tree UI and update console styling/deps.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| routes/api/index.ts | Adds CORS-enabled API route map for speech/game/distribution/meta. |
| routes/api/distribution.ts | Implements /api/distribution by parsing talkModel.toJSON(). |
| routes/api/distribution.test.ts | Adds unit tests for the distribution handler. |
| lib/Agent/index.ts | Tracks lastSpeech for API exposure. |
| index.ts | Wires new API router into the Bun server. |
| console/src/index.css | New console styling for the distribution tree UI. |
| console/src/frontend.tsx | Ensures global CSS is loaded. |
| console/src/components/DistributionTree.tsx | Implements recursive layout + rendering of distribution tree. |
| console/src/App.tsx | Fetches distribution and renders the tree UI. |
| console/package.json | Bumps React + types versions. |
| console/bun.lock | Lockfile updates for dependency bumps. |
| .vscode/settings.json | Adds a TSX formatter default for VS Code. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export default function distribution(streamer: { talkModel: { toJSON(): string } }) { | ||
| return { | ||
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | ||
| GET: async (_req?: Request, _server?: any) => { | ||
| try { | ||
| const json = JSON.parse(streamer.talkModel.toJSON()); | ||
| return jsonWithCors(json.model ?? {}); |
There was a problem hiding this comment.
This endpoint serializes the entire talk model (toJSON()) and then immediately parses it (JSON.parse) for every request. For large Markov models this is expensive and can become a CPU/memory hotspot. Consider returning the serialized JSON directly (and extracting model on the client), caching the parsed distribution, or adding a talkModel.getDistribution() API to avoid stringify+parse per call.
| export default function distribution(streamer: { talkModel: { toJSON(): string } }) { | |
| return { | |
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | |
| GET: async (_req?: Request, _server?: any) => { | |
| try { | |
| const json = JSON.parse(streamer.talkModel.toJSON()); | |
| return jsonWithCors(json.model ?? {}); | |
| export default function distribution(streamer: { talkModel: { toJSON(): string; getDistribution?: () => unknown } }) { | |
| // Cache the distribution so we don't repeatedly serialize/parse the full model. | |
| let cachedDistribution: unknown | null = null; | |
| return { | |
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | |
| GET: async (_req?: Request, _server?: any) => { | |
| try { | |
| // Prefer a direct distribution API if available. | |
| if (typeof streamer.talkModel.getDistribution === 'function') { | |
| const dist = streamer.talkModel.getDistribution(); | |
| return jsonWithCors(dist ?? {}); | |
| } | |
| // Fallback: compute and cache the distribution from the serialized model. | |
| if (cachedDistribution === null) { | |
| const json = JSON.parse(streamer.talkModel.toJSON()) as { model?: unknown }; | |
| cachedDistribution = json.model ?? {}; | |
| } | |
| return jsonWithCors(cachedDistribution); |
| const base = process.env.NODE_ENV !== 'production' ? 'http://localhost:8777' : 'http://localhost:7777'; | ||
| fetch(`${base}/api/distribution`) |
There was a problem hiding this comment.
The console fetch URL is hard-coded to http://localhost:... and switches based on process.env.NODE_ENV. In production the console server runs over HTTPS and likely not on the same host/port as the API, so this will fail (mixed-content + wrong host) and may even crash if process is undefined in the browser bundle. Use a configurable public env var (e.g. BUN_PUBLIC_API_BASE) and/or a same-origin relative URL with a reverse proxy.
| const base = process.env.NODE_ENV !== 'production' ? 'http://localhost:8777' : 'http://localhost:7777'; | |
| fetch(`${base}/api/distribution`) | |
| fetch("/api/distribution") |
| import { CandidateList } from "./components/DistributionTree"; | ||
|
|
||
| type WeightedCandidates = Record<string, number>; | ||
| type Distribution = Record<string, WeightedCandidates>; | ||
|
|
There was a problem hiding this comment.
DistributionTree.tsx exports WeightedCandidates/Distribution, but App.tsx redefines the same types locally. This can drift over time and makes refactors harder. Prefer importing the shared types (or moving them to a dedicated types module).
| import { CandidateList } from "./components/DistributionTree"; | |
| type WeightedCandidates = Record<string, number>; | |
| type Distribution = Record<string, WeightedCandidates>; | |
| import { CandidateList, WeightedCandidates, Distribution } from "./components/DistributionTree"; |
| export const CandidateList = ({ candidates, dist, depth, layout }: CandidateListProps) => { | ||
| const resolvedLayout = layout ?? buildLayout(candidates, dist, depth); | ||
| const listStyle: CSSProperties = { | ||
| height: resolvedLayout.listHeight, | ||
| ...(depth === 0 ? { "--weight-unit": `${WEIGHT_UNIT}px`, "--row-gap": `${ROW_GAP}px`, "--min-node-height": `${MIN_NODE_HEIGHT}px` } : {}), |
There was a problem hiding this comment.
buildLayout(...) is recomputed when layout is not provided (root call) and recursively traverses/sorts candidates. With a large distribution this can be slow on re-renders. Consider memoizing the computed layout (e.g., useMemo keyed by dist/rootCandidates) to keep the UI responsive.
| <section className="tree-panel"> | ||
| <div className="tree-panel-header"> | ||
| <div> | ||
| <h2>Distribution Tree</h2> | ||
| </div> |
There was a problem hiding this comment.
PR title mentions "moderate" the language distribution, but this UI/API appear read-only (GET distribution and render a tree) with no controls/endpoints to edit weights or persist changes. If moderation is out of scope, consider updating the title/description; otherwise add the missing mutation flow.
| const body = await req.json(); | ||
| streamer.onAir(body.data); |
There was a problem hiding this comment.
POST /api/meta calls await req.json() without error handling. If the client sends invalid JSON (or a non-JSON content type), this handler will throw and the response will likely miss the CORS headers (breaking browser clients). Wrap JSON parsing/validation in try/catch and return a CORS JSON error response on failure.
| const body = await req.json(); | |
| streamer.onAir(body.data); | |
| let body: any; | |
| try { | |
| body = await req.json(); | |
| } catch (_err) { | |
| return jsonWithCors({ error: 'Invalid JSON body' }, 400); | |
| } | |
| if (!body || typeof body !== 'object' || !('data' in body)) { | |
| return jsonWithCors({ error: 'Missing "data" in request body' }, 400); | |
| } | |
| streamer.onAir((body as any).data); |
| const corsHeaders = { | ||
| 'Access-Control-Allow-Origin': '*', | ||
| 'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS', | ||
| 'Access-Control-Allow-Headers': 'Content-Type', | ||
| }; |
There was a problem hiding this comment.
corsHeaders (and the jsonWithCors helper right below) are duplicated here and in routes/api/distribution.ts. Consider extracting a shared helper so future header changes don't diverge across endpoints.
| const corsHeaders = { | ||
| 'Access-Control-Allow-Origin': '*', | ||
| 'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS', | ||
| 'Access-Control-Allow-Headers': 'Content-Type', | ||
| }; | ||
|
|
||
| const jsonWithCors = (obj: unknown, status = 200) => new Response(JSON.stringify(obj), { | ||
| status, | ||
| headers: { | ||
| 'Content-Type': 'application/json;charset=utf-8', | ||
| ...corsHeaders, | ||
| }, | ||
| }); | ||
|
|
||
| export default function api(streamer: MakaMujo) { | ||
| return { | ||
| '/api/speech': { | ||
| GET: async () => jsonWithCors({ speech: streamer.lastSpeech }), | ||
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | ||
| }, | ||
|
|
||
| '/api/game': { | ||
| GET: async () => jsonWithCors(streamer.playing ?? {}), | ||
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | ||
| }, | ||
|
|
||
| '/api/distribution': distribution(streamer), | ||
|
|
||
| '/api/meta': { | ||
| GET: () => jsonWithCors(streamer.streamState), | ||
| POST: async (req: Request) => { | ||
| const body = await req.json(); | ||
| streamer.onAir(body.data); | ||
| return jsonWithCors({}); | ||
| }, | ||
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), |
There was a problem hiding this comment.
CORS is configured with Access-Control-Allow-Origin: *. Since this API is intended for a management console, consider restricting allowed origins (and/or requiring auth) to avoid any third-party site reading these endpoints from a victim's browser.
| const corsHeaders = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Content-Type', | |
| }; | |
| const jsonWithCors = (obj: unknown, status = 200) => new Response(JSON.stringify(obj), { | |
| status, | |
| headers: { | |
| 'Content-Type': 'application/json;charset=utf-8', | |
| ...corsHeaders, | |
| }, | |
| }); | |
| export default function api(streamer: MakaMujo) { | |
| return { | |
| '/api/speech': { | |
| GET: async () => jsonWithCors({ speech: streamer.lastSpeech }), | |
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | |
| }, | |
| '/api/game': { | |
| GET: async () => jsonWithCors(streamer.playing ?? {}), | |
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | |
| }, | |
| '/api/distribution': distribution(streamer), | |
| '/api/meta': { | |
| GET: () => jsonWithCors(streamer.streamState), | |
| POST: async (req: Request) => { | |
| const body = await req.json(); | |
| streamer.onAir(body.data); | |
| return jsonWithCors({}); | |
| }, | |
| OPTIONS: async () => new Response(null, { status: 204, headers: corsHeaders }), | |
| // Restrict CORS to a small set of trusted origins (management console frontends). | |
| // Adjust this list to match the actual origins that should be allowed. | |
| const ALLOWED_ORIGINS: readonly string[] = [ | |
| "https://your-management-console.example.com", | |
| ]; | |
| const buildCorsHeaders = (origin: string | null): HeadersInit => { | |
| const headers: Record<string, string> = { | |
| "Access-Control-Allow-Methods": "GET,POST,PUT,OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type", | |
| }; | |
| if (origin && ALLOWED_ORIGINS.includes(origin)) { | |
| headers["Access-Control-Allow-Origin"] = origin; | |
| } | |
| return headers; | |
| }; | |
| const jsonWithCors = (obj: unknown, status = 200, req?: Request) => | |
| new Response(JSON.stringify(obj), { | |
| status, | |
| headers: { | |
| "Content-Type": "application/json;charset=utf-8", | |
| ...buildCorsHeaders(req ? req.headers.get("Origin") : null), | |
| }, | |
| }); | |
| export default function api(streamer: MakaMujo) { | |
| return { | |
| '/api/speech': { | |
| GET: async (req: Request) => jsonWithCors({ speech: streamer.lastSpeech }, 200, req), | |
| OPTIONS: async (req: Request) => | |
| new Response(null, { | |
| status: 204, | |
| headers: buildCorsHeaders(req.headers.get('Origin')), | |
| }), | |
| }, | |
| '/api/game': { | |
| GET: async (req: Request) => jsonWithCors(streamer.playing ?? {}, 200, req), | |
| OPTIONS: async (req: Request) => | |
| new Response(null, { | |
| status: 204, | |
| headers: buildCorsHeaders(req.headers.get('Origin')), | |
| }), | |
| }, | |
| '/api/distribution': distribution(streamer), | |
| '/api/meta': { | |
| GET: (req: Request) => jsonWithCors(streamer.streamState, 200, req), | |
| POST: async (req: Request) => { | |
| const body = await req.json(); | |
| streamer.onAir(body.data); | |
| return jsonWithCors({}, 200, req); | |
| }, | |
| OPTIONS: async (req: Request) => | |
| new Response(null, { | |
| status: 204, | |
| headers: buildCorsHeaders(req.headers.get('Origin')), | |
| }), |
|
|
||
| '/api/meta': { | ||
| GET: () => jsonWithCors(streamer.streamState), | ||
| POST: async (req: Request) => { |
There was a problem hiding this comment.
/api/meta supports a state-changing POST while CORS is configured with Access-Control-Allow-Origin: *. This enables cross-origin requests to mutate server state. Restrict CORS for this route (or require an auth token / validate Origin) before exposing it beyond localhost.
| POST: async (req: Request) => { | |
| POST: async (req: Request) => { | |
| const origin = req.headers.get('origin'); | |
| // Only allow state-changing POST requests from localhost origins | |
| if (origin && !/^https?:\/\/localhost(?::\d+)?$/i.test(origin)) { | |
| return new Response(JSON.stringify({ error: 'Forbidden' }), { | |
| status: 403, | |
| headers: { | |
| 'Content-Type': 'application/json;charset=utf-8', | |
| ...corsHeaders, | |
| }, | |
| }); | |
| } |
No description provided.