feat: implement Phase 2 operator dashboard and structured logging
Implement complete Phase 2 features including: - Operator Dashboard (React + Vite + Tailwind): * Real-time session monitoring with live event feed * Vote tallies and witness cap tracking * Moderation queue for content review * Manual controls for session management * Analytics dashboard with event timelines * Accessible at /operator route - Structured Logging Service: * JSON-formatted logs with session/phase/event correlation * Configurable log levels (debug/info/warn/error) * Child loggers with inherited context * Production-ready logging architecture - Transcript & Vote UI enhancements: * Live SSE transcript stream with dedupe * Verdict/sentence poll bars with real-time updates * Phase-gated voting controls - Witness Caps & Recap System: * Token and time-based witness response limits * Configurable truncation markers * Judge recap events at configurable cadence - End-to-End Testing: * Full round E2E test with caps and recap * Integration tests for all new features * 102 tests passing (100 pass, 2 skipped) - Documentation & Configuration: * Updated API docs with new endpoints * Event taxonomy with new event types * README with dashboard and logging sections * Makefile targets for dashboard dev/build * Environment variables for all features Closes #17 Closes #18 Closes #19 Closes #20 Closes #21
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,8 +1,15 @@
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
LLM_MODEL=deepseek/deepseek-chat-v3-0324:free
|
||||
PORT=3001
|
||||
API_HOST_PORT=3001
|
||||
LLM_MOCK=false
|
||||
PORT=3000
|
||||
API_HOST_PORT=3000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/improv_court
|
||||
TTS_PROVIDER=noop
|
||||
LOG_LEVEL=info
|
||||
VERDICT_VOTE_WINDOW_MS=20000
|
||||
SENTENCE_VOTE_WINDOW_MS=20000
|
||||
WITNESS_MAX_TOKENS=150
|
||||
WITNESS_MAX_SECONDS=30
|
||||
WITNESS_TOKENS_PER_SECOND=3
|
||||
WITNESS_TRUNCATION_MARKER=[The witness was cut off by the judge.]
|
||||
JUDGE_RECAP_CADENCE=2
|
||||
|
||||
11
Makefile
11
Makefile
@@ -10,7 +10,7 @@ SHELL := /usr/bin/env bash
|
||||
NPM ?= npm
|
||||
DOCKER_COMPOSE ?= docker compose
|
||||
|
||||
.PHONY: help install dev lint build test test-spec ci start migrate migrate-dist docker-up docker-down docker-restart clean status
|
||||
.PHONY: help install dev dev-dashboard lint build build-dashboard test test-spec ci start migrate migrate-dist docker-up docker-down docker-restart clean status
|
||||
|
||||
help: ## Show available commands
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nImprov Court Make targets:\n\n"} /^[a-zA-Z0-9_.-]+:.*##/ { printf " %-18s %s\n", $$1, $$2 } END { printf "\n" }' $(MAKEFILE_LIST)
|
||||
@@ -21,12 +21,18 @@ install: ## Install Node dependencies
|
||||
dev: ## Start local dev server with watch mode
|
||||
$(NPM) run dev
|
||||
|
||||
dev-dashboard: ## Start operator dashboard dev server with Vite
|
||||
$(NPM) run dev:dashboard
|
||||
|
||||
lint: ## Run TypeScript type-check (no emit)
|
||||
$(NPM) run lint
|
||||
|
||||
build: ## Compile TypeScript to dist/
|
||||
build: ## Compile TypeScript to dist/ and build dashboard
|
||||
$(NPM) run build
|
||||
|
||||
build-dashboard: ## Build operator dashboard for production
|
||||
$(NPM) run build:dashboard
|
||||
|
||||
test: ## Run test suite
|
||||
$(NPM) test
|
||||
|
||||
@@ -36,6 +42,7 @@ test-spec: ## Run tests with spec reporter
|
||||
ci: ## Run local CI parity checks (lint + build + test)
|
||||
$(MAKE) lint
|
||||
$(MAKE) build
|
||||
$(MAKE) build-dashboard
|
||||
$(MAKE) test
|
||||
|
||||
start: ## Run compiled app from dist/
|
||||
|
||||
34
README.md
34
README.md
@@ -34,6 +34,17 @@ It does **not** depend on `subcult-corp` at runtime.
|
||||
- Overlay shell with phase timer, active speaker, and live captions
|
||||
- Verdict/sentence poll bars with live percentages and phase-gated voting
|
||||
- SSE analytics events for poll start/close and vote completion
|
||||
- **Operator Dashboard** (`/operator`)
|
||||
- Real-time session monitoring with live event feed
|
||||
- Vote tallies and witness cap tracking
|
||||
- Moderation queue for content review
|
||||
- Manual controls for session management
|
||||
- Analytics dashboard with event timelines
|
||||
- **Structured Logging Service**
|
||||
- JSON-formatted logs with session/phase/event correlation
|
||||
- Configurable log levels (debug/info/warn/error)
|
||||
- Child loggers with inherited context
|
||||
- Production-ready logging architecture
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -43,11 +54,18 @@ Key variables:
|
||||
|
||||
- `OPENROUTER_API_KEY` (optional for local mock mode; required for real LLM calls)
|
||||
- `LLM_MODEL`
|
||||
- `LLM_MOCK` (set to `true` to force deterministic mock responses)
|
||||
- `PORT`
|
||||
- `DATABASE_URL` (Postgres connection string for durable persistence)
|
||||
- `TTS_PROVIDER` (`noop` or `mock`; defaults to `noop`)
|
||||
- `VERDICT_VOTE_WINDOW_MS`
|
||||
- `SENTENCE_VOTE_WINDOW_MS`
|
||||
- `WITNESS_MAX_TOKENS`
|
||||
- `WITNESS_MAX_SECONDS`
|
||||
- `WITNESS_TOKENS_PER_SECOND`
|
||||
- `WITNESS_TRUNCATION_MARKER`
|
||||
- `JUDGE_RECAP_CADENCE`
|
||||
- `LOG_LEVEL` (debug, info, warn, error; defaults to `info`)
|
||||
|
||||
If `OPENROUTER_API_KEY` is empty, the app falls back to deterministic mock dialogue.
|
||||
|
||||
@@ -56,6 +74,8 @@ If `DATABASE_URL` is missing, the app falls back to in-memory storage (non-durab
|
||||
|
||||
`TTS_PROVIDER=noop` keeps TTS silent (default). `TTS_PROVIDER=mock` records adapter calls for local/testing workflows without requiring an external speech provider.
|
||||
|
||||
Witness response caps are controlled by `WITNESS_MAX_TOKENS` and `WITNESS_MAX_SECONDS`. The recap cadence uses `JUDGE_RECAP_CADENCE` (every N witness cycles). `WITNESS_TRUNCATION_MARKER` customizes the appended cutoff text.
|
||||
|
||||
## Run
|
||||
|
||||
1. Install dependencies:
|
||||
@@ -64,8 +84,12 @@ If `DATABASE_URL` is missing, the app falls back to in-memory storage (non-durab
|
||||
- `npm run migrate`
|
||||
3. Start dev server:
|
||||
- `npm run dev`
|
||||
4. Open:
|
||||
- `http://localhost:3001`
|
||||
4. Build operator dashboard:
|
||||
- `npm run build:dashboard` (production build)
|
||||
- `npm run dev:dashboard` (development mode on port 3001)
|
||||
5. Open:
|
||||
- Main app: `http://localhost:3000`
|
||||
- Operator dashboard: `http://localhost:3000/operator`
|
||||
|
||||
## Run with Docker (API + Postgres)
|
||||
|
||||
@@ -90,10 +114,12 @@ The API container runs migrations on startup (`npm run migrate:dist`) before sta
|
||||
|
||||
Endpoints when running with compose:
|
||||
|
||||
- API: `http://localhost:${API_HOST_PORT:-3001}`
|
||||
- Main app: `http://localhost:${API_HOST_PORT:-3000}`
|
||||
- Operator dashboard: `http://localhost:${API_HOST_PORT:-3000}/operator`
|
||||
- API: `http://localhost:${API_HOST_PORT:-3000}/api`
|
||||
- Postgres: internal-only by default (`db:5432` inside compose network)
|
||||
|
||||
If port `3001` is already in use on your machine, set `API_HOST_PORT` in `.env` (for example `API_HOST_PORT=3002`) and restart compose.
|
||||
If port `3000` is already in use on your machine, set `API_HOST_PORT` in `.env` (for example `API_HOST_PORT=3002`) and restart compose.
|
||||
|
||||
If you need host access to Postgres, add a `ports` mapping to the `db` service in `docker-compose.yml` (for example `"5433:5432"` to avoid conflicts with local Postgres).
|
||||
|
||||
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal 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>Improv Court - Operator Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
126
dashboard/src/App.tsx
Normal file
126
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SessionMonitor } from './components/SessionMonitor';
|
||||
import { ModerationQueue } from './components/ModerationQueue';
|
||||
import { ManualControls } from './components/ManualControls';
|
||||
import { Analytics } from './components/Analytics';
|
||||
import { useSSE } from './hooks/useSSE';
|
||||
import type { CourtEvent } from './types';
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'monitor' | 'moderation' | 'controls' | 'analytics'
|
||||
>('monitor');
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<CourtEvent[]>([]);
|
||||
|
||||
const { connected, error } = useSSE(event => {
|
||||
setEvents(prev => [...prev, event]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current session on mount
|
||||
fetch('/api/session')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.sessionId) {
|
||||
setSessionId(data.sessionId);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch session:', err));
|
||||
}, []);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'monitor', label: 'Session Monitor', icon: '📊' },
|
||||
{ id: 'moderation', label: 'Moderation Queue', icon: '🛡️' },
|
||||
{ id: 'controls', label: 'Manual Controls', icon: '🎛️' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: '📈' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-900 text-white'>
|
||||
{/* Header */}
|
||||
<header className='bg-gray-800 border-b border-gray-700 shadow-lg'>
|
||||
<div className='container mx-auto px-4 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-bold text-primary-400'>
|
||||
Improv Court
|
||||
</h1>
|
||||
<p className='text-sm text-gray-400'>
|
||||
Operator Dashboard
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
{sessionId && (
|
||||
<div className='text-sm text-gray-400'>
|
||||
<span className='font-medium'>
|
||||
Session:
|
||||
</span>{' '}
|
||||
<span className='font-mono text-primary-400'>
|
||||
{sessionId.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
<span className='text-sm text-gray-400'>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<nav className='bg-gray-800 border-b border-gray-700'>
|
||||
<div className='container mx-auto px-4'>
|
||||
<div className='flex gap-1'>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-6 py-3 font-medium transition-colors ${
|
||||
activeTab === tab.id ?
|
||||
'bg-gray-900 text-primary-400 border-b-2 border-primary-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className='mr-2'>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className='bg-red-900 border-l-4 border-red-500 text-white p-4'>
|
||||
<div className='container mx-auto'>
|
||||
<p className='font-medium'>Connection Error</p>
|
||||
<p className='text-sm text-red-200'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className='container mx-auto px-4 py-6'>
|
||||
{activeTab === 'monitor' && (
|
||||
<SessionMonitor events={events} sessionId={sessionId} />
|
||||
)}
|
||||
{activeTab === 'moderation' && (
|
||||
<ModerationQueue events={events} />
|
||||
)}
|
||||
{activeTab === 'controls' && (
|
||||
<ManualControls sessionId={sessionId} />
|
||||
)}
|
||||
{activeTab === 'analytics' && <Analytics events={events} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
180
dashboard/src/components/Analytics.tsx
Normal file
180
dashboard/src/components/Analytics.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { CourtEvent } from '../types';
|
||||
|
||||
interface AnalyticsProps {
|
||||
events: CourtEvent[];
|
||||
}
|
||||
|
||||
export function Analytics({ events }: AnalyticsProps) {
|
||||
const stats = useMemo(() => {
|
||||
const byType = events.reduce(
|
||||
(acc, event) => {
|
||||
acc[event.type] = (acc[event.type] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const byPhase = events
|
||||
.filter(e => e.phase)
|
||||
.reduce(
|
||||
(acc, event) => {
|
||||
acc[event.phase!] = (acc[event.phase!] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const votes = events.filter(e => e.type === 'VOTE_CAST');
|
||||
const statements = events.filter(e => e.type === 'STATEMENT_COMPLETE');
|
||||
const recaps = events.filter(e => e.type === 'RECAP_GENERATED');
|
||||
|
||||
return {
|
||||
total: events.length,
|
||||
byType,
|
||||
byPhase,
|
||||
votes: votes.length,
|
||||
statements: statements.length,
|
||||
recaps: recaps.length,
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Summary Stats */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
|
||||
<div className='bg-gray-800 rounded-lg p-6'>
|
||||
<div className='text-sm text-gray-400'>Total Events</div>
|
||||
<div className='text-3xl font-bold text-primary-400'>
|
||||
{stats.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-gray-800 rounded-lg p-6'>
|
||||
<div className='text-sm text-gray-400'>Statements</div>
|
||||
<div className='text-3xl font-bold text-blue-400'>
|
||||
{stats.statements}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-gray-800 rounded-lg p-6'>
|
||||
<div className='text-sm text-gray-400'>Votes</div>
|
||||
<div className='text-3xl font-bold text-green-400'>
|
||||
{stats.votes}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-gray-800 rounded-lg p-6'>
|
||||
<div className='text-sm text-gray-400'>Recaps</div>
|
||||
<div className='text-3xl font-bold text-purple-400'>
|
||||
{stats.recaps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events by Type */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Events by Type
|
||||
</h2>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(stats.byType)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count]) => (
|
||||
<div key={type} className='flex items-center gap-3'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex justify-between mb-1'>
|
||||
<span className='text-sm font-medium text-gray-300'>
|
||||
{type}
|
||||
</span>
|
||||
<span className='text-sm text-gray-400'>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<div className='w-full bg-gray-700 rounded-full h-2'>
|
||||
<div
|
||||
className='bg-primary-500 h-2 rounded-full transition-all'
|
||||
style={{
|
||||
width: `${(count / stats.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events by Phase */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Events by Phase
|
||||
</h2>
|
||||
{Object.keys(stats.byPhase).length === 0 ?
|
||||
<div className='text-gray-500 text-center py-4'>
|
||||
No phase data available
|
||||
</div>
|
||||
: <div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||
{Object.entries(stats.byPhase)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([phase, count]) => (
|
||||
<div
|
||||
key={phase}
|
||||
className='bg-gray-700 rounded-lg p-4'
|
||||
>
|
||||
<div className='text-sm text-gray-400'>
|
||||
{phase}
|
||||
</div>
|
||||
<div className='text-2xl font-bold text-primary-400'>
|
||||
{count}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{((count / stats.total) * 100).toFixed(
|
||||
1,
|
||||
)}
|
||||
% of all events
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Event Timeline
|
||||
</h2>
|
||||
<div className='space-y-1 max-h-96 overflow-y-auto'>
|
||||
{events.length === 0 ?
|
||||
<div className='text-gray-500 text-center py-4'>
|
||||
No events recorded
|
||||
</div>
|
||||
: events
|
||||
.slice(-50)
|
||||
.reverse()
|
||||
.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className='flex items-center gap-3 py-2 px-3 hover:bg-gray-700 rounded transition-colors'
|
||||
>
|
||||
<div className='text-xs text-gray-500 font-mono w-20'>
|
||||
{new Date(
|
||||
event.timestamp,
|
||||
).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<span className='text-sm font-medium text-primary-300'>
|
||||
{event.type}
|
||||
</span>
|
||||
{event.phase && (
|
||||
<span className='ml-2 text-xs text-gray-400'>
|
||||
({event.phase})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
dashboard/src/components/ManualControls.tsx
Normal file
263
dashboard/src/components/ManualControls.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ManualControlsProps {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function ManualControls({ sessionId }: ManualControlsProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleAction = async (
|
||||
action: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
if (!sessionId) {
|
||||
setMessage({ type: 'error', text: 'No active session' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/session/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Action failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `${action} completed successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: (err as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create session: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `New session created: ${data.sessionId}`,
|
||||
});
|
||||
|
||||
// Reload page to connect to new session
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: (err as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Status Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
message.type === 'success' ?
|
||||
'bg-green-900/30 border border-green-700 text-green-300'
|
||||
: 'bg-red-900/30 border border-red-700 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Control */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Session Control
|
||||
</h2>
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={loading}
|
||||
className='w-full px-4 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
{loading ? 'Processing...' : '🆕 Create New Session'}
|
||||
</button>
|
||||
{sessionId && (
|
||||
<button
|
||||
onClick={() => handleAction('reset')}
|
||||
disabled={loading}
|
||||
className='w-full px-4 py-3 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
{loading ?
|
||||
'Processing...'
|
||||
: '🔄 Reset Current Session'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Control */}
|
||||
{sessionId && (
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Phase Control
|
||||
</h2>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
|
||||
{[
|
||||
{
|
||||
phase: 'WITNESS_1',
|
||||
label: 'Start Witness 1',
|
||||
emoji: '👤',
|
||||
},
|
||||
{
|
||||
phase: 'WITNESS_2',
|
||||
label: 'Start Witness 2',
|
||||
emoji: '👥',
|
||||
},
|
||||
{
|
||||
phase: 'DELIBERATION',
|
||||
label: 'Start Deliberation',
|
||||
emoji: '⚖️',
|
||||
},
|
||||
{
|
||||
phase: 'VERDICT',
|
||||
label: 'Start Verdict',
|
||||
emoji: '📜',
|
||||
},
|
||||
].map(({ phase, label, emoji }) => (
|
||||
<button
|
||||
key={phase}
|
||||
onClick={() =>
|
||||
handleAction('advance-phase', {
|
||||
targetPhase: phase,
|
||||
})
|
||||
}
|
||||
disabled={loading}
|
||||
className='px-4 py-3 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors text-left'
|
||||
>
|
||||
<span className='mr-2'>{emoji}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statement Injection */}
|
||||
{sessionId && (
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Inject Statement
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const speaker = formData.get('speaker') as string;
|
||||
const content = formData.get('content') as string;
|
||||
handleAction('inject-statement', {
|
||||
speaker,
|
||||
content,
|
||||
});
|
||||
e.currentTarget.reset();
|
||||
}}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='speaker'
|
||||
className='block text-sm font-medium text-gray-300 mb-1'
|
||||
>
|
||||
Speaker
|
||||
</label>
|
||||
<select
|
||||
id='speaker'
|
||||
name='speaker'
|
||||
required
|
||||
className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500'
|
||||
>
|
||||
<option value='JUDGE'>Judge</option>
|
||||
<option value='WITNESS_1'>Witness 1</option>
|
||||
<option value='WITNESS_2'>Witness 2</option>
|
||||
<option value='NARRATOR'>Narrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='content'
|
||||
className='block text-sm font-medium text-gray-300 mb-1'
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id='content'
|
||||
name='content'
|
||||
required
|
||||
rows={3}
|
||||
className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500'
|
||||
placeholder='Enter statement content...'
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={loading}
|
||||
className='w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
{loading ?
|
||||
'Injecting...'
|
||||
: '💉 Inject Statement'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emergency Controls */}
|
||||
{sessionId && (
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg border-2 border-red-700'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-red-400'>
|
||||
⚠️ Emergency Controls
|
||||
</h2>
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={loading}
|
||||
className='w-full px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
{loading ? 'Processing...' : '⏸️ Pause Session'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('terminate')}
|
||||
disabled={loading}
|
||||
className='w-full px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
{loading ? 'Processing...' : '🛑 Terminate Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
dashboard/src/components/ModerationQueue.tsx
Normal file
184
dashboard/src/components/ModerationQueue.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { CourtEvent } from '../types';
|
||||
|
||||
interface ModerationQueueProps {
|
||||
events: CourtEvent[];
|
||||
}
|
||||
|
||||
interface FlaggedItem {
|
||||
id: string;
|
||||
type: 'statement' | 'vote';
|
||||
content: string;
|
||||
speaker?: string;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export function ModerationQueue({ events }: ModerationQueueProps) {
|
||||
const [queue, setQueue] = useState<FlaggedItem[]>([]);
|
||||
const [filter, setFilter] = useState<
|
||||
'all' | 'pending' | 'approved' | 'rejected'
|
||||
>('pending');
|
||||
|
||||
const handleApprove = (id: string) => {
|
||||
setQueue(prev =>
|
||||
prev.map(item =>
|
||||
item.id === id ?
|
||||
{ ...item, status: 'approved' as const }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleReject = (id: string) => {
|
||||
setQueue(prev =>
|
||||
prev.map(item =>
|
||||
item.id === id ?
|
||||
{ ...item, status: 'rejected' as const }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const filteredQueue = queue.filter(
|
||||
item => filter === 'all' || item.status === filter,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Stats */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
|
||||
<div className='bg-gray-800 rounded-lg p-4'>
|
||||
<div className='text-sm text-gray-400'>Total</div>
|
||||
<div className='text-2xl font-bold'>{queue.length}</div>
|
||||
</div>
|
||||
<div className='bg-yellow-900/30 border border-yellow-700 rounded-lg p-4'>
|
||||
<div className='text-sm text-gray-400'>Pending</div>
|
||||
<div className='text-2xl font-bold text-yellow-400'>
|
||||
{queue.filter(item => item.status === 'pending').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-green-900/30 border border-green-700 rounded-lg p-4'>
|
||||
<div className='text-sm text-gray-400'>Approved</div>
|
||||
<div className='text-2xl font-bold text-green-400'>
|
||||
{
|
||||
queue.filter(item => item.status === 'approved')
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-red-900/30 border border-red-700 rounded-lg p-4'>
|
||||
<div className='text-sm text-gray-400'>Rejected</div>
|
||||
<div className='text-2xl font-bold text-red-400'>
|
||||
{
|
||||
queue.filter(item => item.status === 'rejected')
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className='flex gap-2'>
|
||||
{(['all', 'pending', 'approved', 'rejected'] as const).map(
|
||||
status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === status ?
|
||||
'bg-primary-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queue */}
|
||||
<div className='bg-gray-800 rounded-lg shadow-lg'>
|
||||
{filteredQueue.length === 0 ?
|
||||
<div className='p-8 text-center text-gray-400'>
|
||||
{filter === 'pending' ?
|
||||
'No pending items'
|
||||
: `No ${filter} items`}
|
||||
</div>
|
||||
: <div className='divide-y divide-gray-700'>
|
||||
{filteredQueue.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='p-4 hover:bg-gray-750 transition-colors'
|
||||
>
|
||||
<div className='flex items-start justify-between mb-2'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
item.type === 'statement' ?
|
||||
'bg-blue-900/50 text-blue-300'
|
||||
: 'bg-purple-900/50 text-purple-300'
|
||||
}`}
|
||||
>
|
||||
{item.type}
|
||||
</span>
|
||||
{item.speaker && (
|
||||
<span className='text-sm text-gray-400'>
|
||||
by {item.speaker}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-xs text-gray-500'>
|
||||
{new Date(
|
||||
item.timestamp,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-white mb-2'>
|
||||
{item.content}
|
||||
</p>
|
||||
<p className='text-sm text-yellow-400'>
|
||||
⚠️ {item.reason}
|
||||
</p>
|
||||
</div>
|
||||
<div className='ml-4 flex gap-2'>
|
||||
{item.status === 'pending' ?
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleApprove(item.id)
|
||||
}
|
||||
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm font-medium transition-colors'
|
||||
>
|
||||
✓ Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleReject(item.id)
|
||||
}
|
||||
className='px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm font-medium transition-colors'
|
||||
>
|
||||
✗ Reject
|
||||
</button>
|
||||
</>
|
||||
: <span
|
||||
className={`px-3 py-1 rounded text-sm font-medium ${
|
||||
item.status === 'approved' ?
|
||||
'bg-green-900/50 text-green-300'
|
||||
: 'bg-red-900/50 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
dashboard/src/components/SessionMonitor.tsx
Normal file
220
dashboard/src/components/SessionMonitor.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { CourtEvent, SessionSnapshot, TranscriptEntry } from '../types';
|
||||
|
||||
interface SessionMonitorProps {
|
||||
events: CourtEvent[];
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function SessionMonitor({ events, sessionId }: SessionMonitorProps) {
|
||||
const [snapshot, setSnapshot] = useState<SessionSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
fetch('/api/session')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSnapshot(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch snapshot:', err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [sessionId, events.length]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-gray-400'>Loading session data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className='bg-gray-800 rounded-lg p-8 text-center'>
|
||||
<p className='text-gray-400 text-lg'>No active session</p>
|
||||
<p className='text-gray-500 text-sm mt-2'>
|
||||
Start a new session to begin monitoring
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestEvents = events.slice(-10).reverse();
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||
{/* Session Info */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Session Info
|
||||
</h2>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-gray-400'>Session ID:</span>
|
||||
<span className='font-mono text-sm'>
|
||||
{snapshot.sessionId.slice(0, 16)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-gray-400'>Current Phase:</span>
|
||||
<span className='font-semibold text-primary-400'>
|
||||
{snapshot.phase}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-gray-400'>
|
||||
Transcript Entries:
|
||||
</span>
|
||||
<span>{snapshot.transcript.length}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-gray-400'>Total Votes:</span>
|
||||
<span>
|
||||
{Object.values(snapshot.votes).reduce(
|
||||
(sum, v) => sum + v.total,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-gray-400'>Recap Count:</span>
|
||||
<span>{snapshot.recapCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Witness Caps */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Witness Caps
|
||||
</h2>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<div className='flex justify-between mb-2'>
|
||||
<span className='text-gray-400'>Witness 1</span>
|
||||
<span className='text-sm'>
|
||||
{snapshot.witnessCaps.witness1} /{' '}
|
||||
{snapshot.config.maxWitnessStatements}
|
||||
</span>
|
||||
</div>
|
||||
<div className='w-full bg-gray-700 rounded-full h-2.5'>
|
||||
<div
|
||||
className='bg-blue-500 h-2.5 rounded-full transition-all'
|
||||
style={{
|
||||
width: `${(snapshot.witnessCaps.witness1 / snapshot.config.maxWitnessStatements) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='flex justify-between mb-2'>
|
||||
<span className='text-gray-400'>Witness 2</span>
|
||||
<span className='text-sm'>
|
||||
{snapshot.witnessCaps.witness2} /{' '}
|
||||
{snapshot.config.maxWitnessStatements}
|
||||
</span>
|
||||
</div>
|
||||
<div className='w-full bg-gray-700 rounded-full h-2.5'>
|
||||
<div
|
||||
className='bg-purple-500 h-2.5 rounded-full transition-all'
|
||||
style={{
|
||||
width: `${(snapshot.witnessCaps.witness2 / snapshot.config.maxWitnessStatements) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-4 text-sm text-gray-400'>
|
||||
Recap interval: every {snapshot.config.recapInterval}{' '}
|
||||
statements
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vote Tallies */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Vote Tallies
|
||||
</h2>
|
||||
<div className='space-y-4'>
|
||||
{Object.entries(snapshot.votes).map(([phase, counts]) => (
|
||||
<div key={phase}>
|
||||
<div className='text-sm font-medium text-gray-300 mb-2'>
|
||||
{phase}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='bg-green-900/30 border border-green-700 rounded p-3'>
|
||||
<div className='text-xs text-gray-400'>
|
||||
Innocent
|
||||
</div>
|
||||
<div className='text-2xl font-bold text-green-400'>
|
||||
{counts.innocent}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-red-900/30 border border-red-700 rounded p-3'>
|
||||
<div className='text-xs text-gray-400'>
|
||||
Guilty
|
||||
</div>
|
||||
<div className='text-2xl font-bold text-red-400'>
|
||||
{counts.guilty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Event Feed */}
|
||||
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
|
||||
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
|
||||
Live Event Feed
|
||||
</h2>
|
||||
<div className='space-y-2 max-h-96 overflow-y-auto'>
|
||||
{latestEvents.length === 0 ?
|
||||
<div className='text-gray-500 text-center py-4'>
|
||||
No recent events
|
||||
</div>
|
||||
: latestEvents.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className='bg-gray-700 rounded p-3 text-sm border-l-4 border-primary-500'
|
||||
>
|
||||
<div className='flex justify-between items-start mb-1'>
|
||||
<span className='font-medium text-primary-300'>
|
||||
{event.type}
|
||||
</span>
|
||||
<span className='text-xs text-gray-400'>
|
||||
{new Date(
|
||||
event.timestamp,
|
||||
).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
{event.speaker && (
|
||||
<div className='text-gray-300'>
|
||||
<span className='text-gray-400'>
|
||||
Speaker:
|
||||
</span>{' '}
|
||||
{event.speaker}
|
||||
</div>
|
||||
)}
|
||||
{event.phase && (
|
||||
<div className='text-gray-300'>
|
||||
<span className='text-gray-400'>
|
||||
Phase:
|
||||
</span>{' '}
|
||||
{event.phase}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
dashboard/src/hooks/useSSE.ts
Normal file
72
dashboard/src/hooks/useSSE.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { CourtEvent } from '../types';
|
||||
|
||||
export function useSSE(onEvent: (event: CourtEvent) => void) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const es = new EventSource('/api/events');
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
console.log('SSE connected');
|
||||
};
|
||||
|
||||
es.onerror = err => {
|
||||
console.error('SSE error:', err);
|
||||
setConnected(false);
|
||||
setError('Connection lost. Reconnecting...');
|
||||
|
||||
// Clean up and schedule reconnect
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
es.onmessage = e => {
|
||||
try {
|
||||
const event = JSON.parse(e.data) as CourtEvent;
|
||||
onEvent(event);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE event:', err);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to create EventSource:', err);
|
||||
setError('Failed to connect to event stream');
|
||||
}
|
||||
}, [onEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { connected, error };
|
||||
}
|
||||
18
dashboard/src/index.css
Normal file
18
dashboard/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
40
dashboard/src/types.ts
Normal file
40
dashboard/src/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface CourtEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
sessionId?: string;
|
||||
phase?: string;
|
||||
speaker?: string;
|
||||
content?: string;
|
||||
voterId?: string;
|
||||
vote?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
sessionId: string;
|
||||
phase: string;
|
||||
transcript: TranscriptEntry[];
|
||||
votes: Record<string, VoteCount>;
|
||||
recapCount: number;
|
||||
witnessCaps: {
|
||||
witness1: number;
|
||||
witness2: number;
|
||||
};
|
||||
config: {
|
||||
maxWitnessStatements: number;
|
||||
recapInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TranscriptEntry {
|
||||
speaker: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
isRecap?: boolean;
|
||||
}
|
||||
|
||||
export interface VoteCount {
|
||||
guilty: number;
|
||||
innocent: number;
|
||||
total: number;
|
||||
}
|
||||
31
docs/api.md
31
docs/api.md
@@ -235,6 +235,7 @@ data: {"type":"turn","payload":{…}}\n\n
|
||||
votes: Record<string, number>;
|
||||
};
|
||||
};
|
||||
recapTurnIds?: string[];
|
||||
finalRuling?: {
|
||||
verdict: string;
|
||||
sentence: string;
|
||||
@@ -280,17 +281,19 @@ Every SSE payload is a `CourtEvent`:
|
||||
|
||||
### Event Types
|
||||
|
||||
| Type | When emitted | Key payload fields |
|
||||
| ------------------- | ------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `snapshot` | Immediately on SSE connect | `session`, `turns`, `verdictVotes`, `sentenceVotes` |
|
||||
| `session_created` | Session record inserted | `sessionId` |
|
||||
| `session_started` | Orchestration begins | `sessionId` |
|
||||
| `phase_changed` | Phase advances | `phase`, `durationMs` |
|
||||
| `turn` | A new dialogue turn is stored | `turn: CourtTurn` |
|
||||
| `vote_updated` | A vote is successfully cast | `voteType`, `choice`, `verdictVotes`, `sentenceVotes` |
|
||||
| `vote_closed` | Transitioned away from a vote phase; includes frozen tally snapshot | `pollType`, `closedAt`, `votes`, `nextPhase` |
|
||||
| `analytics_event` | Poll open/close lifecycle events | `event`, `phase` |
|
||||
| `moderation_action` | Turn content was flagged and redacted | `speaker`, `reasons` |
|
||||
| `vote_spam_blocked` | Vote rejected due to rate limiting | `ip`, `voteType` |
|
||||
| `session_completed` | Session reached `final_ruling` successfully | `sessionId`, `finalRuling` |
|
||||
| `session_failed` | Orchestration threw an unrecoverable error | `sessionId`, `reason` |
|
||||
| Type | When emitted | Key payload fields |
|
||||
| ------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `snapshot` | Immediately on SSE connect | `session`, `turns`, `verdictVotes`, `sentenceVotes`, `recapTurnIds` |
|
||||
| `session_created` | Session record inserted | `sessionId` |
|
||||
| `session_started` | Orchestration begins | `sessionId` |
|
||||
| `phase_changed` | Phase advances | `phase`, `durationMs` |
|
||||
| `turn` | A new dialogue turn is stored | `turn: CourtTurn` |
|
||||
| `vote_updated` | A vote is successfully cast | `voteType`, `choice`, `verdictVotes`, `sentenceVotes` |
|
||||
| `vote_closed` | Transitioned away from a vote phase; includes frozen tally snapshot | `pollType`, `closedAt`, `votes`, `nextPhase` |
|
||||
| `witness_response_capped` | Witness response was truncated due to caps | `turnId`, `speaker`, `phase`, `originalLength`, `truncatedLength`, `reason` |
|
||||
| `judge_recap_emitted` | Judge recap emitted during witness exam | `turnId`, `phase`, `cycleNumber` |
|
||||
| `analytics_event` | Poll open/close lifecycle events | `event`, `phase` |
|
||||
| `moderation_action` | Turn content was flagged and redacted | `speaker`, `reasons` |
|
||||
| `vote_spam_blocked` | Vote rejected due to rate limiting | `ip`, `voteType` |
|
||||
| `session_completed` | Session reached `final_ruling` successfully | `sessionId`, `finalRuling` |
|
||||
| `session_failed` | Orchestration threw an unrecoverable error | `sessionId`, `reason` |
|
||||
|
||||
@@ -244,6 +244,45 @@ Emitted once when a vote phase closes (i.e., when transitioning away from
|
||||
|
||||
---
|
||||
|
||||
### `witness_response_capped`
|
||||
|
||||
Emitted when a witness response is truncated due to configured response caps.
|
||||
|
||||
**Severity:** `info`
|
||||
|
||||
**Payload**
|
||||
|
||||
```ts
|
||||
{
|
||||
turnId: string;
|
||||
speaker: AgentId;
|
||||
phase: CourtPhase;
|
||||
originalLength: number; // token estimate
|
||||
truncatedLength: number; // token estimate after cap
|
||||
reason: 'tokens' | 'seconds';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `judge_recap_emitted`
|
||||
|
||||
Emitted when the judge recap is generated during witness examination.
|
||||
|
||||
**Severity:** `info`
|
||||
|
||||
**Payload**
|
||||
|
||||
```ts
|
||||
{
|
||||
turnId: string;
|
||||
phase: CourtPhase;
|
||||
cycleNumber: number; // witness cycle count when recap occurred
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `analytics_event`
|
||||
|
||||
Poll lifecycle signals. Three named sub-events are emitted under this type:
|
||||
@@ -428,11 +467,11 @@ Emitted when the orchestrator throws an unrecoverable error.
|
||||
|
||||
## Severity levels
|
||||
|
||||
| Severity | Event types |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `info` | `session_created`, `session_started`, `phase_changed`, `turn`, `vote_updated`, `vote_closed`, `analytics_event`, `session_completed` |
|
||||
| `warn` | `moderation_action`, `vote_spam_blocked` |
|
||||
| `error` | `session_failed` |
|
||||
| Severity | Event types |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `info` | `session_created`, `session_started`, `phase_changed`, `turn`, `vote_updated`, `vote_closed`, `witness_response_capped`, `judge_recap_emitted`, `analytics_event`, `session_completed` |
|
||||
| `warn` | `moderation_action`, `vote_spam_blocked` |
|
||||
| `error` | `session_failed` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
2726
package-lock.json
generated
2726
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -5,8 +5,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev:dashboard": "vite",
|
||||
"lint": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"build:dashboard": "vite build",
|
||||
"test": "node --import tsx --test src/*.test.ts src/**/*.test.ts",
|
||||
"start": "node dist/server.js",
|
||||
"migrate": "tsx src/scripts/migrate.ts",
|
||||
@@ -17,12 +19,21 @@
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"postgres": "^3.4.5"
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
239
public/app.js
239
public/app.js
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
createStreamState,
|
||||
isRecapTurn,
|
||||
markRecap,
|
||||
resetStreamState,
|
||||
shouldAppendTurn,
|
||||
} from './stream-state.js';
|
||||
|
||||
const topicInput = document.getElementById('topic');
|
||||
const caseTypeSelect = document.getElementById('caseType');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
@@ -8,6 +16,14 @@ const verdictTallies = document.getElementById('verdictTallies');
|
||||
const sentenceTallies = document.getElementById('sentenceTallies');
|
||||
const verdictActions = document.getElementById('verdictActions');
|
||||
const sentenceActions = document.getElementById('sentenceActions');
|
||||
const verdictStatus = document.getElementById('verdictStatus');
|
||||
const verdictCountdown = document.getElementById('verdictCountdown');
|
||||
const verdictError = document.getElementById('verdictError');
|
||||
const verdictNote = document.getElementById('verdictNote');
|
||||
const sentenceStatus = document.getElementById('sentenceStatus');
|
||||
const sentenceCountdown = document.getElementById('sentenceCountdown');
|
||||
const sentenceError = document.getElementById('sentenceError');
|
||||
const sentenceNote = document.getElementById('sentenceNote');
|
||||
const statusEl = document.getElementById('status');
|
||||
const phaseTimer = document.getElementById('phaseTimer');
|
||||
const phaseTimerFill = document.getElementById('phaseTimerFill');
|
||||
@@ -18,9 +34,26 @@ const connectionBanner = document.getElementById('connectionBanner');
|
||||
let activeSession = null;
|
||||
let source = null;
|
||||
let timerInterval = null;
|
||||
let voteCountdownInterval = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const streamState = createStreamState();
|
||||
const voteState = {
|
||||
verdict: {
|
||||
isOpen: false,
|
||||
closesAt: null,
|
||||
hasVoted: false,
|
||||
error: '',
|
||||
},
|
||||
sentence: {
|
||||
isOpen: false,
|
||||
closesAt: null,
|
||||
hasVoted: false,
|
||||
error: '',
|
||||
},
|
||||
};
|
||||
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 10_000;
|
||||
|
||||
@@ -52,15 +85,37 @@ function pulseActiveSpeaker() {
|
||||
activeSpeakerEl.classList.add('speaker-live');
|
||||
}
|
||||
|
||||
function appendTurn(turn) {
|
||||
function appendTurn(turn, { recap = false } = {}) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'turn';
|
||||
item.dataset.turnId = turn.id;
|
||||
if (recap) {
|
||||
item.classList.add('recap');
|
||||
}
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = `#${turn.turnNumber + 1} · ${turn.role} · ${turn.speaker} · ${turn.phase}`;
|
||||
|
||||
const turnNumber = document.createElement('span');
|
||||
turnNumber.className = 'turn-number';
|
||||
turnNumber.textContent = `#${turn.turnNumber + 1}`;
|
||||
|
||||
const roleBadge = document.createElement('span');
|
||||
roleBadge.className = 'role-badge';
|
||||
roleBadge.textContent = turn.role;
|
||||
|
||||
const speakerName = document.createElement('span');
|
||||
speakerName.className = 'speaker';
|
||||
speakerName.textContent = turn.speaker;
|
||||
|
||||
const phaseLabel = document.createElement('span');
|
||||
phaseLabel.className = 'phase-label';
|
||||
phaseLabel.textContent = turn.phase;
|
||||
|
||||
meta.append(turnNumber, roleBadge, speakerName, phaseLabel);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = turn.dialogue;
|
||||
|
||||
item.append(meta, body);
|
||||
@@ -71,6 +126,13 @@ function appendTurn(turn) {
|
||||
captionLineEl.textContent = turn.dialogue;
|
||||
}
|
||||
|
||||
function markTurnRecap(turnId) {
|
||||
const target = feed.querySelector(`[data-turn-id="${turnId}"]`);
|
||||
if (target) {
|
||||
target.classList.add('recap');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTally(container, map) {
|
||||
container.innerHTML = '';
|
||||
const entries = Object.entries(map || {});
|
||||
@@ -105,6 +167,8 @@ function renderTally(container, map) {
|
||||
|
||||
async function castVote(type, choice) {
|
||||
if (!activeSession) return;
|
||||
voteState[type].error = '';
|
||||
renderVoteMeta();
|
||||
|
||||
const res = await fetch(`/api/court/sessions/${activeSession.id}/vote`, {
|
||||
method: 'POST',
|
||||
@@ -114,13 +178,17 @@ async function castVote(type, choice) {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
setStatus(err.error || 'Vote failed', 'error');
|
||||
voteState[type].error = err.error || 'Vote failed';
|
||||
renderVoteMeta();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
renderTally(verdictTallies, data.verdictVotes);
|
||||
renderTally(sentenceTallies, data.sentenceVotes);
|
||||
voteState[type].hasVoted = true;
|
||||
renderActions(activeSession);
|
||||
renderVoteMeta();
|
||||
setStatus('Vote recorded.');
|
||||
}
|
||||
|
||||
@@ -137,7 +205,10 @@ function renderActions(session) {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = option;
|
||||
button.onclick = () => castVote('verdict', option);
|
||||
button.disabled = session.phase !== 'verdict_vote';
|
||||
button.disabled =
|
||||
session.phase !== 'verdict_vote' ||
|
||||
!voteState.verdict.isOpen ||
|
||||
voteState.verdict.hasVoted;
|
||||
verdictActions.appendChild(button);
|
||||
}
|
||||
|
||||
@@ -145,11 +216,111 @@ function renderActions(session) {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = option;
|
||||
button.onclick = () => castVote('sentence', option);
|
||||
button.disabled = session.phase !== 'sentence_vote';
|
||||
button.disabled =
|
||||
session.phase !== 'sentence_vote' ||
|
||||
!voteState.sentence.isOpen ||
|
||||
voteState.sentence.hasVoted;
|
||||
sentenceActions.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
function resetVoteState() {
|
||||
voteState.verdict.isOpen = false;
|
||||
voteState.verdict.closesAt = null;
|
||||
voteState.verdict.hasVoted = false;
|
||||
voteState.verdict.error = '';
|
||||
voteState.sentence.isOpen = false;
|
||||
voteState.sentence.closesAt = null;
|
||||
voteState.sentence.hasVoted = false;
|
||||
voteState.sentence.error = '';
|
||||
}
|
||||
|
||||
function formatCountdown(ms) {
|
||||
if (ms <= 0) return '00:00';
|
||||
const minutes = Math.floor(ms / 60000)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = Math.floor((ms % 60000) / 1000)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function renderVoteMeta() {
|
||||
verdictStatus.textContent = voteState.verdict.isOpen ? 'Open' : 'Closed';
|
||||
verdictStatus.className = `badge ${voteState.verdict.isOpen ? 'ok' : ''}`;
|
||||
verdictError.textContent = voteState.verdict.error || '';
|
||||
verdictNote.textContent =
|
||||
voteState.verdict.hasVoted ? 'Your vote is in.' : '';
|
||||
|
||||
sentenceStatus.textContent = voteState.sentence.isOpen ? 'Open' : 'Closed';
|
||||
sentenceStatus.className = `badge ${voteState.sentence.isOpen ? 'ok' : ''}`;
|
||||
sentenceError.textContent = voteState.sentence.error || '';
|
||||
sentenceNote.textContent =
|
||||
voteState.sentence.hasVoted ? 'Your vote is in.' : '';
|
||||
}
|
||||
|
||||
function updateVoteCountdowns() {
|
||||
const now = Date.now();
|
||||
const verdictCloseAt = voteState.verdict.closesAt;
|
||||
const sentenceCloseAt = voteState.sentence.closesAt;
|
||||
|
||||
if (verdictCloseAt) {
|
||||
const remaining = Math.max(0, verdictCloseAt - now);
|
||||
verdictCountdown.textContent = formatCountdown(remaining);
|
||||
if (remaining === 0 && voteState.verdict.isOpen) {
|
||||
voteState.verdict.isOpen = false;
|
||||
if (activeSession) {
|
||||
renderActions(activeSession);
|
||||
}
|
||||
renderVoteMeta();
|
||||
}
|
||||
} else {
|
||||
verdictCountdown.textContent = '--:--';
|
||||
}
|
||||
|
||||
if (sentenceCloseAt) {
|
||||
const remaining = Math.max(0, sentenceCloseAt - now);
|
||||
sentenceCountdown.textContent = formatCountdown(remaining);
|
||||
if (remaining === 0 && voteState.sentence.isOpen) {
|
||||
voteState.sentence.isOpen = false;
|
||||
if (activeSession) {
|
||||
renderActions(activeSession);
|
||||
}
|
||||
renderVoteMeta();
|
||||
}
|
||||
} else {
|
||||
sentenceCountdown.textContent = '--:--';
|
||||
}
|
||||
}
|
||||
|
||||
function startVoteCountdowns() {
|
||||
if (voteCountdownInterval) {
|
||||
clearInterval(voteCountdownInterval);
|
||||
}
|
||||
updateVoteCountdowns();
|
||||
voteCountdownInterval = setInterval(updateVoteCountdowns, 250);
|
||||
}
|
||||
|
||||
function openVoteWindow(type, phaseStartedAt, phaseDurationMs) {
|
||||
if (!phaseStartedAt || !phaseDurationMs) {
|
||||
return;
|
||||
}
|
||||
const start = Date.parse(phaseStartedAt);
|
||||
voteState[type].isOpen = true;
|
||||
voteState[type].hasVoted = false;
|
||||
voteState[type].error = '';
|
||||
voteState[type].closesAt = start + phaseDurationMs;
|
||||
renderVoteMeta();
|
||||
startVoteCountdowns();
|
||||
}
|
||||
|
||||
function closeVoteWindow(type, closedAt) {
|
||||
voteState[type].isOpen = false;
|
||||
voteState[type].closesAt = closedAt ? Date.parse(closedAt) : null;
|
||||
renderVoteMeta();
|
||||
}
|
||||
|
||||
function updateTimer(phaseStartedAt, phaseDurationMs) {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
@@ -246,19 +417,52 @@ function connectStream(sessionId, isReconnect = false) {
|
||||
);
|
||||
|
||||
feed.innerHTML = '';
|
||||
turns.forEach(appendTurn);
|
||||
resetStreamState(streamState, payload.payload);
|
||||
turns.forEach(turn => {
|
||||
appendTurn(turn, {
|
||||
recap: isRecapTurn(streamState, turn.id),
|
||||
});
|
||||
});
|
||||
if (turns.length === 0) {
|
||||
activeSpeakerEl.textContent = 'Waiting for first turn…';
|
||||
captionLineEl.textContent = 'Captions will appear here.';
|
||||
}
|
||||
renderTally(verdictTallies, verdictVotes);
|
||||
renderTally(sentenceTallies, sentenceVotes);
|
||||
resetVoteState();
|
||||
if (session.phase === 'verdict_vote') {
|
||||
openVoteWindow(
|
||||
'verdict',
|
||||
session.metadata.phaseStartedAt,
|
||||
session.metadata.phaseDurationMs,
|
||||
);
|
||||
}
|
||||
if (session.phase === 'sentence_vote') {
|
||||
openVoteWindow(
|
||||
'sentence',
|
||||
session.metadata.phaseStartedAt,
|
||||
session.metadata.phaseDurationMs,
|
||||
);
|
||||
}
|
||||
renderActions(session);
|
||||
renderVoteMeta();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'turn') {
|
||||
appendTurn(payload.payload.turn);
|
||||
const turn = payload.payload.turn;
|
||||
if (!shouldAppendTurn(streamState, turn)) {
|
||||
return;
|
||||
}
|
||||
appendTurn(turn, {
|
||||
recap: isRecapTurn(streamState, turn.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'judge_recap_emitted') {
|
||||
markRecap(streamState, payload.payload.turnId);
|
||||
markTurnRecap(payload.payload.turnId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,6 +480,20 @@ function connectStream(sessionId, isReconnect = false) {
|
||||
payload.payload.phaseStartedAt,
|
||||
payload.payload.phaseDurationMs,
|
||||
);
|
||||
if (payload.payload.phase === 'verdict_vote') {
|
||||
openVoteWindow(
|
||||
'verdict',
|
||||
payload.payload.phaseStartedAt,
|
||||
payload.payload.phaseDurationMs,
|
||||
);
|
||||
}
|
||||
if (payload.payload.phase === 'sentence_vote') {
|
||||
openVoteWindow(
|
||||
'sentence',
|
||||
payload.payload.phaseStartedAt,
|
||||
payload.payload.phaseDurationMs,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -293,12 +511,19 @@ function connectStream(sessionId, isReconnect = false) {
|
||||
setStatus(
|
||||
`${payload.payload.pollType} poll closed with ${voteTotal} vote${voteTotal === 1 ? '' : 's'}.`,
|
||||
);
|
||||
closeVoteWindow(payload.payload.pollType, payload.payload.closedAt);
|
||||
if (activeSession) {
|
||||
renderActions(activeSession);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'session_completed') {
|
||||
setStatus('Session complete. Verdict delivered.');
|
||||
updateTimer();
|
||||
voteState.verdict.isOpen = false;
|
||||
voteState.sentence.isOpen = false;
|
||||
renderVoteMeta();
|
||||
}
|
||||
|
||||
if (payload.type === 'session_failed') {
|
||||
|
||||
@@ -198,6 +198,50 @@
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.turn .meta .turn-number {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.turn .meta .role-badge {
|
||||
background: #2b2f41;
|
||||
color: #d9e6ff;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.turn .meta .speaker {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turn .meta .phase-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.turn .body {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.turn.recap {
|
||||
border-left-color: #f7d794;
|
||||
background: #1b1a12;
|
||||
}
|
||||
|
||||
.turn.recap .meta {
|
||||
color: #d0c199;
|
||||
}
|
||||
|
||||
.vote-grid {
|
||||
@@ -233,6 +277,25 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vote-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vote-error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.vote-note {
|
||||
color: var(--ok);
|
||||
font-size: 12px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
@@ -318,7 +381,13 @@
|
||||
<h3 style="font-size: 14px; margin-bottom: 6px">
|
||||
Verdict vote
|
||||
</h3>
|
||||
<div class="vote-meta">
|
||||
<span id="verdictStatus" class="badge">Closed</span>
|
||||
<span id="verdictCountdown" class="muted">--:--</span>
|
||||
</div>
|
||||
<div id="verdictTallies" class="vote-grid"></div>
|
||||
<div id="verdictError" class="vote-error"></div>
|
||||
<div id="verdictNote" class="vote-note"></div>
|
||||
<div class="vote-actions" id="verdictActions"></div>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +395,13 @@
|
||||
<h3 style="font-size: 14px; margin-bottom: 6px">
|
||||
Sentence vote
|
||||
</h3>
|
||||
<div class="vote-meta">
|
||||
<span id="sentenceStatus" class="badge">Closed</span>
|
||||
<span id="sentenceCountdown" class="muted">--:--</span>
|
||||
</div>
|
||||
<div id="sentenceTallies" class="vote-grid"></div>
|
||||
<div id="sentenceError" class="vote-error"></div>
|
||||
<div id="sentenceNote" class="vote-note"></div>
|
||||
<div class="vote-actions" id="sentenceActions"></div>
|
||||
</div>
|
||||
|
||||
|
||||
20
public/stream-state.d.ts
vendored
Normal file
20
public/stream-state.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface StreamState {
|
||||
seenTurnIds: Set<string>;
|
||||
recapTurnIds: Set<string>;
|
||||
}
|
||||
|
||||
export function createStreamState(): StreamState;
|
||||
|
||||
export function resetStreamState(
|
||||
state: StreamState,
|
||||
snapshot: { turns: Array<{ id: string }>; recapTurnIds?: string[] },
|
||||
): void;
|
||||
|
||||
export function shouldAppendTurn(
|
||||
state: StreamState,
|
||||
turn: { id: string },
|
||||
): boolean;
|
||||
|
||||
export function markRecap(state: StreamState, turnId: string): void;
|
||||
|
||||
export function isRecapTurn(state: StreamState, turnId: string): boolean;
|
||||
28
public/stream-state.js
Normal file
28
public/stream-state.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export function createStreamState() {
|
||||
return {
|
||||
seenTurnIds: new Set(),
|
||||
recapTurnIds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetStreamState(state, snapshot) {
|
||||
state.seenTurnIds = new Set(snapshot.turns.map(turn => turn.id));
|
||||
state.recapTurnIds = new Set(snapshot.recapTurnIds ?? []);
|
||||
}
|
||||
|
||||
export function shouldAppendTurn(state, turn) {
|
||||
if (state.seenTurnIds.has(turn.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.seenTurnIds.add(turn.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markRecap(state, turnId) {
|
||||
state.recapTurnIds.add(turnId);
|
||||
}
|
||||
|
||||
export function isRecapTurn(state, turnId) {
|
||||
return state.recapTurnIds.has(turnId);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { AGENTS } from '../agents.js';
|
||||
import { llmGenerate, sanitizeDialogue } from '../llm/client.js';
|
||||
import { moderateContent } from '../moderation/content-filter.js';
|
||||
import { createTTSAdapterFromEnv, type TTSAdapter } from '../tts/adapter.js';
|
||||
import { applyWitnessCap, resolveWitnessCapConfig } from './witness-caps.js';
|
||||
import type { WitnessCapConfig } from './witness-caps.js';
|
||||
import type {
|
||||
AgentId,
|
||||
CaseType,
|
||||
@@ -40,7 +42,10 @@ async function generateTurn(input: {
|
||||
speaker: AgentId;
|
||||
role: CourtRole;
|
||||
userInstruction: string;
|
||||
}): Promise<void> {
|
||||
maxTokens?: number;
|
||||
capConfig?: WitnessCapConfig;
|
||||
dialoguePrefix?: string;
|
||||
}): Promise<CourtTurn> {
|
||||
const { store, session, speaker, role, userInstruction } = input;
|
||||
|
||||
const systemPrompt = buildCourtSystemPrompt({
|
||||
@@ -58,11 +63,21 @@ async function generateTurn(input: {
|
||||
{ role: 'user', content: userInstruction },
|
||||
],
|
||||
temperature: session.phase === 'witness_exam' ? 0.8 : 0.7,
|
||||
maxTokens: 260,
|
||||
maxTokens: input.maxTokens ?? 260,
|
||||
});
|
||||
|
||||
const sanitized = sanitizeDialogue(raw);
|
||||
const moderation = moderateContent(sanitized);
|
||||
let dialogue = sanitizeDialogue(raw);
|
||||
const capResult =
|
||||
input.capConfig ? applyWitnessCap(dialogue, input.capConfig) : null;
|
||||
if (capResult?.capped) {
|
||||
dialogue = capResult.text;
|
||||
}
|
||||
|
||||
if (input.dialoguePrefix) {
|
||||
dialogue = `${input.dialoguePrefix} ${dialogue}`.trim();
|
||||
}
|
||||
|
||||
const moderation = moderateContent(dialogue);
|
||||
|
||||
if (moderation.flagged) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -71,7 +86,7 @@ async function generateTurn(input: {
|
||||
);
|
||||
}
|
||||
|
||||
await input.store.addTurn({
|
||||
const turn = await input.store.addTurn({
|
||||
sessionId: session.id,
|
||||
speaker,
|
||||
role,
|
||||
@@ -82,6 +97,19 @@ async function generateTurn(input: {
|
||||
{ flagged: true, reasons: moderation.reasons }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (capResult?.capped && !moderation.flagged) {
|
||||
store.emitEvent(session.id, 'witness_response_capped', {
|
||||
turnId: turn.id,
|
||||
speaker,
|
||||
phase: session.phase,
|
||||
originalLength: capResult.originalTokens,
|
||||
truncatedLength: capResult.truncatedTokens,
|
||||
reason: capResult.reason ?? 'tokens',
|
||||
});
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
function verdictOptions(caseType: CaseType): string[] {
|
||||
@@ -103,6 +131,15 @@ export async function runCourtSession(
|
||||
const session = await store.startSession(sessionId);
|
||||
const tts = options.ttsAdapter ?? createTTSAdapterFromEnv();
|
||||
const pause = options.sleepFn ?? sleep;
|
||||
const witnessCapConfig = resolveWitnessCapConfig();
|
||||
const recapCadenceRaw = Number.parseInt(
|
||||
process.env.JUDGE_RECAP_CADENCE ?? '2',
|
||||
10,
|
||||
);
|
||||
const recapCadence =
|
||||
Number.isFinite(recapCadenceRaw) && recapCadenceRaw > 0 ?
|
||||
recapCadenceRaw
|
||||
: 2;
|
||||
const ttsMetrics = {
|
||||
success: 0,
|
||||
failure: 0,
|
||||
@@ -215,6 +252,8 @@ export async function runCourtSession(
|
||||
role: `witness_${Math.min(index + 1, 3)}` as CourtRole,
|
||||
userInstruction:
|
||||
'Provide testimony in 1-3 sentences with one concrete detail and one comedic detail.',
|
||||
maxTokens: Math.min(260, witnessCapConfig.maxTokens),
|
||||
capConfig: witnessCapConfig,
|
||||
});
|
||||
await pause(600);
|
||||
|
||||
@@ -238,21 +277,28 @@ export async function runCourtSession(
|
||||
});
|
||||
|
||||
exchangeCount += 1;
|
||||
if (exchangeCount % 2 === 0) {
|
||||
if (exchangeCount % recapCadence === 0) {
|
||||
await pause(600);
|
||||
await generateTurn({
|
||||
const recapTurn = await generateTurn({
|
||||
store,
|
||||
session,
|
||||
speaker: judge,
|
||||
role: 'judge',
|
||||
userInstruction:
|
||||
'Give a two-sentence recap of what matters so far and keep the jury oriented.',
|
||||
dialoguePrefix: 'Recap:',
|
||||
});
|
||||
await store.recordRecap({
|
||||
sessionId: session.id,
|
||||
turnId: recapTurn.id,
|
||||
phase: session.phase,
|
||||
cycleNumber: exchangeCount,
|
||||
});
|
||||
await safelySpeak('speakRecap', () =>
|
||||
tts.speakRecap({
|
||||
sessionId: session.id,
|
||||
phase: 'witness_exam',
|
||||
text: 'The court has issued a recap for the jury.',
|
||||
text: recapTurn.dialogue,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
70
src/court/witness-caps.test.ts
Normal file
70
src/court/witness-caps.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyWitnessCap,
|
||||
estimateTokens,
|
||||
resolveWitnessCapConfig,
|
||||
} from './witness-caps.js';
|
||||
|
||||
test('estimateTokens counts words', () => {
|
||||
assert.equal(estimateTokens(''), 0);
|
||||
assert.equal(estimateTokens('one two three'), 3);
|
||||
assert.equal(estimateTokens(' spaced words '), 2);
|
||||
});
|
||||
|
||||
test('applyWitnessCap truncates when maxTokens exceeded', () => {
|
||||
const text = 'one two three four five six seven';
|
||||
const result = applyWitnessCap(text, {
|
||||
maxTokens: 5,
|
||||
maxSeconds: 0,
|
||||
tokensPerSecond: 3,
|
||||
truncationMarker: '[cut off]'.trim(),
|
||||
});
|
||||
|
||||
assert.equal(result.capped, true);
|
||||
assert.equal(result.originalTokens, 7);
|
||||
assert.equal(result.truncatedTokens, 5);
|
||||
assert.equal(result.reason, 'tokens');
|
||||
assert.match(result.text, /\[cut off\]$/);
|
||||
});
|
||||
|
||||
test('applyWitnessCap respects maxSeconds when stricter than maxTokens', () => {
|
||||
const text = 'one two three four five';
|
||||
const result = applyWitnessCap(text, {
|
||||
maxTokens: 10,
|
||||
maxSeconds: 1,
|
||||
tokensPerSecond: 2,
|
||||
truncationMarker: '[cut off]'.trim(),
|
||||
});
|
||||
|
||||
assert.equal(result.capped, true);
|
||||
assert.equal(result.truncatedTokens, 2);
|
||||
assert.equal(result.reason, 'seconds');
|
||||
});
|
||||
|
||||
test('applyWitnessCap returns original text when under limits', () => {
|
||||
const text = 'short response';
|
||||
const result = applyWitnessCap(text, {
|
||||
maxTokens: 50,
|
||||
maxSeconds: 0,
|
||||
tokensPerSecond: 3,
|
||||
truncationMarker: '[cut off]'.trim(),
|
||||
});
|
||||
|
||||
assert.equal(result.capped, false);
|
||||
assert.equal(result.text, text);
|
||||
});
|
||||
|
||||
test('resolveWitnessCapConfig falls back to defaults on invalid values', () => {
|
||||
const config = resolveWitnessCapConfig({
|
||||
WITNESS_MAX_TOKENS: 'not-a-number',
|
||||
WITNESS_MAX_SECONDS: '-1',
|
||||
WITNESS_TOKENS_PER_SECOND: '0',
|
||||
WITNESS_TRUNCATION_MARKER: '!!!',
|
||||
});
|
||||
|
||||
assert.equal(config.maxTokens, 150);
|
||||
assert.equal(config.maxSeconds, 30);
|
||||
assert.equal(config.tokensPerSecond, 3);
|
||||
assert.equal(config.truncationMarker, '!!!');
|
||||
});
|
||||
107
src/court/witness-caps.ts
Normal file
107
src/court/witness-caps.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export interface WitnessCapConfig {
|
||||
maxTokens: number;
|
||||
maxSeconds: number;
|
||||
tokensPerSecond: number;
|
||||
truncationMarker: string;
|
||||
}
|
||||
|
||||
export interface WitnessCapResult {
|
||||
text: string;
|
||||
capped: boolean;
|
||||
originalTokens: number;
|
||||
truncatedTokens: number;
|
||||
reason?: 'tokens' | 'seconds';
|
||||
}
|
||||
|
||||
const DEFAULTS: WitnessCapConfig = {
|
||||
maxTokens: 150,
|
||||
maxSeconds: 30,
|
||||
tokensPerSecond: 3,
|
||||
truncationMarker: '[The witness was cut off by the judge.]',
|
||||
};
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolveWitnessCapConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): WitnessCapConfig {
|
||||
return {
|
||||
maxTokens: parsePositiveInt(env.WITNESS_MAX_TOKENS, DEFAULTS.maxTokens),
|
||||
maxSeconds: parsePositiveInt(
|
||||
env.WITNESS_MAX_SECONDS,
|
||||
DEFAULTS.maxSeconds,
|
||||
),
|
||||
tokensPerSecond: parsePositiveInt(
|
||||
env.WITNESS_TOKENS_PER_SECOND,
|
||||
DEFAULTS.tokensPerSecond,
|
||||
),
|
||||
truncationMarker:
|
||||
env.WITNESS_TRUNCATION_MARKER ?? DEFAULTS.truncationMarker,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateTokens(text: string): number {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
return trimmed.split(/\s+/).length;
|
||||
}
|
||||
|
||||
export function truncateToTokens(text: string, maxTokens: number): string {
|
||||
if (maxTokens <= 0) return '';
|
||||
const parts = text.trim().split(/\s+/);
|
||||
if (parts.length <= maxTokens) return text.trim();
|
||||
return parts.slice(0, maxTokens).join(' ');
|
||||
}
|
||||
|
||||
function effectiveTokenLimit(config: WitnessCapConfig): {
|
||||
limit: number | undefined;
|
||||
reason?: 'tokens' | 'seconds';
|
||||
} {
|
||||
const tokenLimit =
|
||||
config.maxTokens > 0 ? config.maxTokens : Number.POSITIVE_INFINITY;
|
||||
const timeLimit =
|
||||
config.maxSeconds > 0 ?
|
||||
Math.floor(config.maxSeconds * config.tokensPerSecond)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
const limit = Math.min(tokenLimit, timeLimit);
|
||||
if (!Number.isFinite(limit)) {
|
||||
return { limit: undefined };
|
||||
}
|
||||
|
||||
if (timeLimit < tokenLimit) {
|
||||
return { limit, reason: 'seconds' };
|
||||
}
|
||||
|
||||
return { limit, reason: 'tokens' };
|
||||
}
|
||||
|
||||
export function applyWitnessCap(
|
||||
text: string,
|
||||
config: WitnessCapConfig,
|
||||
): WitnessCapResult {
|
||||
const originalTokens = estimateTokens(text);
|
||||
const { limit, reason } = effectiveTokenLimit(config);
|
||||
|
||||
if (!limit || originalTokens <= limit) {
|
||||
return {
|
||||
text,
|
||||
capped: false,
|
||||
originalTokens,
|
||||
truncatedTokens: originalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
const cappedText = truncateToTokens(text, Math.max(1, limit));
|
||||
return {
|
||||
text: `${cappedText} ${config.truncationMarker}`.trim(),
|
||||
capped: true,
|
||||
originalTokens,
|
||||
truncatedTokens: Math.max(1, limit),
|
||||
reason,
|
||||
};
|
||||
}
|
||||
128
src/e2e-round.test.ts
Normal file
128
src/e2e-round.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AGENT_IDS } from './agents.js';
|
||||
import { assignCourtRoles } from './court/roles.js';
|
||||
import { runCourtSession } from './court/orchestrator.js';
|
||||
import { MockTTSAdapter } from './tts/adapter.js';
|
||||
import { createCourtSessionStore } from './store/session-store.js';
|
||||
import type { CourtEvent } from './types.js';
|
||||
|
||||
async function createInMemoryStore() {
|
||||
const previousDatabaseUrl = process.env.DATABASE_URL;
|
||||
process.env.DATABASE_URL = '';
|
||||
try {
|
||||
return await createCourtSessionStore();
|
||||
} finally {
|
||||
if (previousDatabaseUrl === undefined) {
|
||||
delete process.env.DATABASE_URL;
|
||||
} else {
|
||||
process.env.DATABASE_URL = previousDatabaseUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('e2e round completes with witness caps and recap cadence', async () => {
|
||||
const previousCapTokens = process.env.WITNESS_MAX_TOKENS;
|
||||
const previousCapSeconds = process.env.WITNESS_MAX_SECONDS;
|
||||
const previousCadence = process.env.JUDGE_RECAP_CADENCE;
|
||||
|
||||
process.env.WITNESS_MAX_TOKENS = '5';
|
||||
process.env.WITNESS_MAX_SECONDS = '999';
|
||||
process.env.JUDGE_RECAP_CADENCE = '2';
|
||||
|
||||
try {
|
||||
const store = await createInMemoryStore();
|
||||
const participants = AGENT_IDS;
|
||||
const session = await store.createSession({
|
||||
topic: 'Did the defendant replace all office coffee with soup?',
|
||||
participants,
|
||||
metadata: {
|
||||
mode: 'improv_court',
|
||||
casePrompt:
|
||||
'Did the defendant replace all office coffee with soup?',
|
||||
caseType: 'criminal',
|
||||
roleAssignments: assignCourtRoles(participants),
|
||||
sentenceOptions: ['Fine', 'Community service'],
|
||||
verdictVoteWindowMs: 1,
|
||||
sentenceVoteWindowMs: 1,
|
||||
verdictVotes: {},
|
||||
sentenceVotes: {},
|
||||
},
|
||||
});
|
||||
|
||||
const recapEvents: CourtEvent[] = [];
|
||||
const capEvents: CourtEvent[] = [];
|
||||
const unsubscribe = store.subscribe(session.id, event => {
|
||||
if (event.type === 'judge_recap_emitted') {
|
||||
recapEvents.push(event);
|
||||
}
|
||||
if (event.type === 'witness_response_capped') {
|
||||
capEvents.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const voteFlags = { verdict: false, sentence: false };
|
||||
const sleepFn = async () => {
|
||||
const current = await store.getSession(session.id);
|
||||
if (!current) return;
|
||||
|
||||
if (current.phase === 'verdict_vote' && !voteFlags.verdict) {
|
||||
await store.castVote({
|
||||
sessionId: session.id,
|
||||
voteType: 'verdict',
|
||||
choice: 'guilty',
|
||||
});
|
||||
await store.castVote({
|
||||
sessionId: session.id,
|
||||
voteType: 'verdict',
|
||||
choice: 'guilty',
|
||||
});
|
||||
await store.castVote({
|
||||
sessionId: session.id,
|
||||
voteType: 'verdict',
|
||||
choice: 'not_guilty',
|
||||
});
|
||||
voteFlags.verdict = true;
|
||||
}
|
||||
|
||||
if (current.phase === 'sentence_vote' && !voteFlags.sentence) {
|
||||
await store.castVote({
|
||||
sessionId: session.id,
|
||||
voteType: 'sentence',
|
||||
choice: 'Fine',
|
||||
});
|
||||
voteFlags.sentence = true;
|
||||
}
|
||||
};
|
||||
|
||||
await runCourtSession(session.id, store, {
|
||||
ttsAdapter: new MockTTSAdapter(),
|
||||
sleepFn,
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const completed = await store.getSession(session.id);
|
||||
assert.equal(completed?.status, 'completed');
|
||||
assert.equal(completed?.metadata.finalRuling?.verdict, 'guilty');
|
||||
assert.ok((completed?.metadata.recapTurnIds ?? []).length >= 1);
|
||||
assert.ok(recapEvents.length >= 1);
|
||||
assert.ok(capEvents.length >= 1);
|
||||
} finally {
|
||||
if (previousCapTokens === undefined) {
|
||||
delete process.env.WITNESS_MAX_TOKENS;
|
||||
} else {
|
||||
process.env.WITNESS_MAX_TOKENS = previousCapTokens;
|
||||
}
|
||||
if (previousCapSeconds === undefined) {
|
||||
delete process.env.WITNESS_MAX_SECONDS;
|
||||
} else {
|
||||
process.env.WITNESS_MAX_SECONDS = previousCapSeconds;
|
||||
}
|
||||
if (previousCadence === undefined) {
|
||||
delete process.env.JUDGE_RECAP_CADENCE;
|
||||
} else {
|
||||
process.env.JUDGE_RECAP_CADENCE = previousCadence;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -94,6 +94,33 @@ test('assertEventPayload: vote_closed valid', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: witness_response_capped valid', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertEventPayload(
|
||||
makeEvent('witness_response_capped', {
|
||||
turnId: 'turn-1',
|
||||
speaker: 'chora',
|
||||
phase: 'witness_exam',
|
||||
originalLength: 180,
|
||||
truncatedLength: 120,
|
||||
reason: 'tokens',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: judge_recap_emitted valid', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertEventPayload(
|
||||
makeEvent('judge_recap_emitted', {
|
||||
turnId: 'turn-2',
|
||||
phase: 'witness_exam',
|
||||
cycleNumber: 2,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: analytics_event poll_started valid', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertEventPayload(
|
||||
@@ -252,6 +279,35 @@ test('assertEventPayload: vote_closed missing votes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: witness_response_capped missing turnId', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertEventPayload(
|
||||
makeEvent('witness_response_capped', {
|
||||
speaker: 'chora',
|
||||
phase: 'witness_exam',
|
||||
originalLength: 180,
|
||||
truncatedLength: 120,
|
||||
reason: 'tokens',
|
||||
}),
|
||||
),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: judge_recap_emitted missing cycleNumber', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertEventPayload(
|
||||
makeEvent('judge_recap_emitted', {
|
||||
turnId: 'turn-2',
|
||||
phase: 'witness_exam',
|
||||
}),
|
||||
),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('assertEventPayload: analytics_event missing pollType', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -53,6 +53,21 @@ export interface VoteClosedPayload {
|
||||
nextPhase: CourtPhase;
|
||||
}
|
||||
|
||||
export interface WitnessResponseCappedPayload {
|
||||
turnId: string;
|
||||
speaker: string;
|
||||
phase: CourtPhase;
|
||||
originalLength: number;
|
||||
truncatedLength: number;
|
||||
reason: 'tokens' | 'seconds';
|
||||
}
|
||||
|
||||
export interface JudgeRecapEmittedPayload {
|
||||
turnId: string;
|
||||
phase: CourtPhase;
|
||||
cycleNumber: number;
|
||||
}
|
||||
|
||||
export type AnalyticsEventName =
|
||||
| 'poll_started'
|
||||
| 'vote_completed'
|
||||
@@ -174,6 +189,34 @@ export function assertEventPayload(event: CourtEvent): void {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'witness_response_capped':
|
||||
if (
|
||||
!hasStringKeys(payload, [
|
||||
'turnId',
|
||||
'speaker',
|
||||
'phase',
|
||||
'reason',
|
||||
]) ||
|
||||
typeof payload['originalLength'] !== 'number' ||
|
||||
typeof payload['truncatedLength'] !== 'number'
|
||||
) {
|
||||
throw new TypeError(
|
||||
`witness_response_capped payload missing required fields: turnId, speaker, phase, originalLength, truncatedLength, reason`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'judge_recap_emitted':
|
||||
if (
|
||||
!hasStringKeys(payload, ['turnId', 'phase']) ||
|
||||
typeof payload['cycleNumber'] !== 'number'
|
||||
) {
|
||||
throw new TypeError(
|
||||
`judge_recap_emitted payload missing required fields: turnId, phase, cycleNumber`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'analytics_event':
|
||||
if (!hasStringKeys(payload, ['name', 'pollType'])) {
|
||||
throw new TypeError(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { LLMGenerateOptions } from '../types.js';
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? '';
|
||||
const DEFAULT_MODEL =
|
||||
process.env.LLM_MODEL ?? 'deepseek/deepseek-chat-v3-0324:free';
|
||||
const FORCE_MOCK = process.env.LLM_MOCK === 'true';
|
||||
|
||||
function extractFromXml(text: string): string {
|
||||
const contentMatch = text.match(
|
||||
@@ -60,7 +61,7 @@ export async function llmGenerate(
|
||||
.reverse()
|
||||
.find(message => message.role === 'user')?.content;
|
||||
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
if (!OPENROUTER_API_KEY || FORCE_MOCK) {
|
||||
return mockReply(latestUserMessage ?? '');
|
||||
}
|
||||
|
||||
|
||||
96
src/logger.test.ts
Normal file
96
src/logger.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Logger, createLogger } from './logger.js';
|
||||
|
||||
describe('Logger', () => {
|
||||
it('should create logger with default info level', () => {
|
||||
const logger = new (Logger as any)();
|
||||
assert.equal(logger.minLevel, 'info');
|
||||
});
|
||||
|
||||
it('should respect log level hierarchy', () => {
|
||||
const logger = new (Logger as any)('warn');
|
||||
|
||||
// Mock console methods
|
||||
const logs: string[] = [];
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
console.log = (msg: string) => logs.push(msg);
|
||||
console.error = (msg: string) => logs.push(msg);
|
||||
|
||||
logger.debug('debug message');
|
||||
logger.info('info message');
|
||||
logger.warn('warn message');
|
||||
logger.error('error message');
|
||||
|
||||
// Restore console
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
|
||||
// Only warn and error should be logged
|
||||
assert.equal(logs.length, 2);
|
||||
assert.ok(logs[0].includes('warn message'));
|
||||
assert.ok(logs[1].includes('error message'));
|
||||
});
|
||||
|
||||
it('should include context in log entries', () => {
|
||||
const logger = new (Logger as any)('debug');
|
||||
|
||||
let capturedLog = '';
|
||||
const originalLog = console.log;
|
||||
console.log = (msg: string) => {
|
||||
capturedLog = msg;
|
||||
};
|
||||
|
||||
logger.info('test message', {
|
||||
sessionId: 'sess123',
|
||||
phase: 'WITNESS_1',
|
||||
});
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
const parsed = JSON.parse(capturedLog);
|
||||
assert.equal(parsed.message, 'test message');
|
||||
assert.equal(parsed.context.sessionId, 'sess123');
|
||||
assert.equal(parsed.context.phase, 'WITNESS_1');
|
||||
assert.ok(parsed.timestamp);
|
||||
});
|
||||
|
||||
it('should create child logger with base context', () => {
|
||||
const logger = new (Logger as any)('debug');
|
||||
const childLogger = logger.child({ sessionId: 'sess456' });
|
||||
|
||||
let capturedLog = '';
|
||||
const originalLog = console.log;
|
||||
console.log = (msg: string) => {
|
||||
capturedLog = msg;
|
||||
};
|
||||
|
||||
childLogger.info('child message', { eventType: 'PHASE_START' });
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
const parsed = JSON.parse(capturedLog);
|
||||
assert.equal(parsed.context.sessionId, 'sess456');
|
||||
assert.equal(parsed.context.eventType, 'PHASE_START');
|
||||
});
|
||||
|
||||
it('should write errors to stderr', () => {
|
||||
const logger = new (Logger as any)('error');
|
||||
|
||||
let capturedError = '';
|
||||
const originalError = console.error;
|
||||
console.error = (msg: string) => {
|
||||
capturedError = msg;
|
||||
};
|
||||
|
||||
logger.error('error message');
|
||||
|
||||
console.error = originalError;
|
||||
|
||||
assert.ok(capturedError.includes('error message'));
|
||||
const parsed = JSON.parse(capturedError);
|
||||
assert.equal(parsed.level, 'error');
|
||||
});
|
||||
});
|
||||
113
src/logger.ts
Normal file
113
src/logger.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Structured logging service for Improv Court
|
||||
* Provides JSON-formatted logs with session/phase/event correlation
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type LogContext = {
|
||||
sessionId?: string;
|
||||
phase?: string;
|
||||
eventType?: string;
|
||||
userId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: LogContext;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
export class Logger {
|
||||
private minLevel: LogLevel;
|
||||
|
||||
constructor(minLevel: LogLevel = 'info') {
|
||||
this.minLevel = minLevel;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
|
||||
}
|
||||
|
||||
private write(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
context,
|
||||
};
|
||||
|
||||
const output = JSON.stringify(entry);
|
||||
|
||||
// Write to appropriate stream based on level
|
||||
if (level === 'error' || level === 'warn') {
|
||||
console.error(output);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.write('debug', message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.write('info', message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.write('warn', message, context);
|
||||
}
|
||||
|
||||
error(message: string, context?: LogContext): void {
|
||||
this.write('error', message, context);
|
||||
}
|
||||
|
||||
// Convenience method to create a child logger with base context
|
||||
child(baseContext: LogContext): Logger {
|
||||
const parent = this;
|
||||
const childLogger = Object.create(Logger.prototype);
|
||||
|
||||
childLogger.minLevel = this.minLevel;
|
||||
childLogger.shouldLog = this.shouldLog.bind(this);
|
||||
|
||||
// Override write to merge contexts
|
||||
childLogger.write = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
) => {
|
||||
const mergedContext = { ...baseContext, ...context };
|
||||
parent.write(level, message, mergedContext);
|
||||
};
|
||||
|
||||
return childLogger;
|
||||
}
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.minLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton logger instance
|
||||
const logLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
|
||||
export const logger = new Logger(logLevel);
|
||||
|
||||
// Export convenience function for creating child loggers
|
||||
export function createLogger(context: LogContext): Logger {
|
||||
return logger.child(context);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export async function createServerApp(
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const publicDir = path.resolve(__dirname, '../public');
|
||||
const dashboardDir = path.resolve(__dirname, '../dist/dashboard');
|
||||
|
||||
const verdictWindowMs = Number.parseInt(
|
||||
process.env.VERDICT_VOTE_WINDOW_MS ?? '20000',
|
||||
@@ -72,6 +73,11 @@ export async function createServerApp(
|
||||
pruneTimer.unref();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Serve operator dashboard
|
||||
app.use('/operator', express.static(dashboardDir));
|
||||
|
||||
// Serve main public app
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
@@ -309,6 +315,7 @@ export async function createServerApp(
|
||||
turns: session.turns,
|
||||
verdictVotes: session.metadata.verdictVotes,
|
||||
sentenceVotes: session.metadata.sentenceVotes,
|
||||
recapTurnIds: session.metadata.recapTurnIds ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -324,6 +331,12 @@ export async function createServerApp(
|
||||
},
|
||||
);
|
||||
|
||||
// Catch-all for operator dashboard (SPA routing)
|
||||
app.get('/operator/*', (_req, res) => {
|
||||
res.sendFile(path.join(dashboardDir, 'index.html'));
|
||||
});
|
||||
|
||||
// Catch-all for main app (SPA routing)
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
@@ -347,10 +360,12 @@ export async function createServerApp(
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const { app } = await createServerApp();
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '3001', 10);
|
||||
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Improv Court POC running on http://localhost:${port}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Operator Dashboard: http://localhost:${port}/operator`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,45 @@ test('emits vote_closed and persists vote snapshots when vote phases end', async
|
||||
});
|
||||
});
|
||||
|
||||
test('recordRecap stores recap turn ids and emits judge_recap_emitted', async () => {
|
||||
const { store, sessionId } = await createRunningSession();
|
||||
const recapEvents: CourtEvent[] = [];
|
||||
|
||||
const unsubscribe = store.subscribe(sessionId, event => {
|
||||
if (event.type === 'judge_recap_emitted') {
|
||||
recapEvents.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await store.setPhase(sessionId, 'openings');
|
||||
const recapTurn = await store.addTurn({
|
||||
sessionId,
|
||||
speaker: 'chora',
|
||||
role: 'judge',
|
||||
phase: 'openings',
|
||||
dialogue: 'Recap: The witness spilled the soup.',
|
||||
});
|
||||
|
||||
await store.recordRecap({
|
||||
sessionId,
|
||||
turnId: recapTurn.id,
|
||||
phase: 'openings',
|
||||
cycleNumber: 2,
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
assert.equal(recapEvents.length, 1);
|
||||
const session = await store.getSession(sessionId);
|
||||
assert.deepEqual(session?.metadata.recapTurnIds, [
|
||||
recapEvents[0]?.payload.turnId,
|
||||
]);
|
||||
assert.equal(recapEvents[0]?.payload.phase, 'openings');
|
||||
assert.equal(recapEvents[0]?.payload.cycleNumber, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'postgres store persists final ruling when TEST_DATABASE_URL is provided',
|
||||
{ skip: !process.env.TEST_DATABASE_URL },
|
||||
|
||||
@@ -118,6 +118,12 @@ export interface CourtSessionStore {
|
||||
verdict: string;
|
||||
sentence: string;
|
||||
}): Promise<CourtSession>;
|
||||
recordRecap(input: {
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
phase: CourtPhase;
|
||||
cycleNumber: number;
|
||||
}): Promise<void>;
|
||||
completeSession(sessionId: string): Promise<CourtSession>;
|
||||
failSession(sessionId: string, reason: string): Promise<CourtSession>;
|
||||
recoverInterruptedSessions(): Promise<string[]>;
|
||||
@@ -402,6 +408,29 @@ class InMemoryCourtSessionStore implements CourtSessionStore {
|
||||
return deepCopy(session);
|
||||
}
|
||||
|
||||
async recordRecap(input: {
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
phase: CourtPhase;
|
||||
cycleNumber: number;
|
||||
}): Promise<void> {
|
||||
const session = this.mustGet(input.sessionId);
|
||||
session.metadata.recapTurnIds ??= [];
|
||||
if (!session.metadata.recapTurnIds.includes(input.turnId)) {
|
||||
session.metadata.recapTurnIds.push(input.turnId);
|
||||
}
|
||||
|
||||
this.publish({
|
||||
sessionId: input.sessionId,
|
||||
type: 'judge_recap_emitted',
|
||||
payload: {
|
||||
turnId: input.turnId,
|
||||
phase: input.phase,
|
||||
cycleNumber: input.cycleNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async completeSession(sessionId: string): Promise<CourtSession> {
|
||||
const session = this.mustGet(sessionId);
|
||||
session.status = 'completed';
|
||||
@@ -960,6 +989,55 @@ class PostgresCourtSessionStore implements CourtSessionStore {
|
||||
return this.mapSession(row, turns);
|
||||
}
|
||||
|
||||
async recordRecap(input: {
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
phase: CourtPhase;
|
||||
cycleNumber: number;
|
||||
}): Promise<void> {
|
||||
const row = await this.db.begin(async (tx: any) => {
|
||||
const [current] = await tx<SessionRow[]>`
|
||||
SELECT *
|
||||
FROM court_sessions
|
||||
WHERE id = ${input.sessionId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (!current) {
|
||||
throw new CourtNotFoundError(
|
||||
`Session not found: ${input.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
...(current.metadata ?? {}),
|
||||
} as CourtSessionMetadata;
|
||||
metadata.recapTurnIds ??= [];
|
||||
if (!metadata.recapTurnIds.includes(input.turnId)) {
|
||||
metadata.recapTurnIds.push(input.turnId);
|
||||
}
|
||||
|
||||
const [updated] = await tx<SessionRow[]>`
|
||||
UPDATE court_sessions
|
||||
SET metadata = ${tx.json(metadata as unknown as Record<string, unknown>)}
|
||||
WHERE id = ${input.sessionId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
this.publish({
|
||||
sessionId: input.sessionId,
|
||||
type: 'judge_recap_emitted',
|
||||
payload: {
|
||||
turnId: input.turnId,
|
||||
phase: input.phase,
|
||||
cycleNumber: input.cycleNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async completeSession(sessionId: string): Promise<CourtSession> {
|
||||
const [row] = await this.db<SessionRow[]>`
|
||||
UPDATE court_sessions
|
||||
|
||||
36
src/stream-state.test.ts
Normal file
36
src/stream-state.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStreamState,
|
||||
isRecapTurn,
|
||||
markRecap,
|
||||
resetStreamState,
|
||||
shouldAppendTurn,
|
||||
} from '../public/stream-state.js';
|
||||
|
||||
test('resetStreamState seeds seen turns and recap ids', () => {
|
||||
const state = createStreamState();
|
||||
resetStreamState(state, {
|
||||
turns: [{ id: 'turn-1' }, { id: 'turn-2' }],
|
||||
recapTurnIds: ['turn-2'],
|
||||
});
|
||||
|
||||
assert.equal(shouldAppendTurn(state, { id: 'turn-1' }), false);
|
||||
assert.equal(isRecapTurn(state, 'turn-2'), true);
|
||||
});
|
||||
|
||||
test('shouldAppendTurn prevents duplicates and accepts new turns', () => {
|
||||
const state = createStreamState();
|
||||
resetStreamState(state, { turns: [], recapTurnIds: [] });
|
||||
|
||||
assert.equal(shouldAppendTurn(state, { id: 'turn-3' }), true);
|
||||
assert.equal(shouldAppendTurn(state, { id: 'turn-3' }), false);
|
||||
});
|
||||
|
||||
test('markRecap flags recap turn ids', () => {
|
||||
const state = createStreamState();
|
||||
resetStreamState(state, { turns: [], recapTurnIds: [] });
|
||||
|
||||
markRecap(state, 'turn-9');
|
||||
assert.equal(isRecapTurn(state, 'turn-9'), true);
|
||||
});
|
||||
@@ -77,6 +77,7 @@ export interface CourtSessionMetadata {
|
||||
votes: Record<string, number>;
|
||||
};
|
||||
};
|
||||
recapTurnIds?: string[];
|
||||
finalRuling?: {
|
||||
verdict: string;
|
||||
sentence: string;
|
||||
@@ -121,6 +122,8 @@ export type CourtEventType =
|
||||
| 'turn'
|
||||
| 'vote_updated'
|
||||
| 'vote_closed'
|
||||
| 'witness_response_capped'
|
||||
| 'judge_recap_emitted'
|
||||
| 'analytics_event'
|
||||
| 'moderation_action'
|
||||
| 'vote_spam_blocked'
|
||||
|
||||
23
tailwind.config.js
Normal file
23
tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./dashboard/index.html', './dashboard/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fdf2f8',
|
||||
100: '#fce7f3',
|
||||
200: '#fbcfe8',
|
||||
300: '#f9a8d4',
|
||||
400: '#f472b6',
|
||||
500: '#ec4899',
|
||||
600: '#db2777',
|
||||
700: '#be185d',
|
||||
800: '#9d174d',
|
||||
900: '#831843',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -11,5 +11,5 @@
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
|
||||
21
vite.config.ts
Normal file
21
vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'dashboard',
|
||||
build: {
|
||||
outDir: '../dist/dashboard',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user