war_gaming / frontend /src /api.ts
misack's picture
Claude Opus 4.7 (1M context)
Trial-mode UX: live progress visibility + cosmetic fixes + tighter caps
b9537b4
Raw History Blame Contribute Delete
16.3 kB
// Minimal typed client for the AI War Gaming FastAPI backend.
const BASE = (import.meta as any).env?.VITE_API_BASE ?? '/api';
// Trial password gate: backend may require an X-Trial-Password header.
// We cache the password in localStorage and re-prompt on a 401.
// Local dev has no gate so this is a no-op there.
const PASSWORD_KEY = 'wargame_trial_password';
function getPassword(): string | null {
try { return localStorage.getItem(PASSWORD_KEY); } catch { return null; }
}
function setPassword(value: string | null): void {
try {
if (value === null) localStorage.removeItem(PASSWORD_KEY);
else localStorage.setItem(PASSWORD_KEY, value);
} catch { /* private mode etc. — swallow */ }
}
function promptForPassword(): string | null {
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return null;
const value = window.prompt('Enter trial access password');
return value && value.length > 0 ? value : null;
}
async function doFetch(path: string, init: RequestInit | undefined, password: string | null): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((init?.headers as Record<string, string> | undefined) || {}),
};
if (password) headers['X-Trial-Password'] = password;
return fetch(`${BASE}${path}`, { ...init, headers });
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res = await doFetch(path, init, getPassword());
if (res.status === 401) {
setPassword(null);
const next = promptForPassword();
if (next) {
setPassword(next);
res = await doFetch(path, init, next);
}
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw Object.assign(new Error(`${res.status} ${res.statusText}: ${body}`), { status: res.status });
}
return res.status === 204 ? (undefined as any) : res.json();
}
export const api = {
health: () => req<{ status: string }>('/healthz'),
listScenarios: () => req<{ scenarios: Scenario[] }>('/scenarios'),
listGames: () => req<{ games: GameSummary[] }>('/games'),
listModels: () => req<ModelsResponse>('/models'),
listAgents: (scenario_id: string) =>
req<AgentsResponse>(`/scenarios/${scenario_id}/agents`),
createGame: (
scenario_id: string,
model?: string,
role_model_overrides?: Record<string, string>,
language?: 'en' | 'de',
) => {
const body: Record<string, unknown> = { scenario_id };
if (model) body.model = model;
if (role_model_overrides && Object.keys(role_model_overrides).length > 0) {
body.role_model_overrides = role_model_overrides;
}
// ---- i18n (round 6) ----
if (language) body.language = language;
return req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, {
method: 'POST',
body: JSON.stringify(body),
});
},
getState: (id: string) => req<StateResponse>(`/games/${id}/state`),
getBriefing: (id: string) => req<BriefingResponse>(`/games/${id}/briefing`),
recon: (id: string, agent_id: string, question: string) =>
req<ReconResponseEnvelope>(`/games/${id}/recon`, {
method: 'POST', body: JSON.stringify({ agent_id, question }),
}),
submitAction: (id: string, action: ActionRequest) =>
req<ActionAcceptedResponse>(`/games/${id}/actions`, {
method: 'POST', body: JSON.stringify(action),
}),
advance: (id: string) => req<AdvanceResponse>(`/games/${id}/advance`, { method: 'POST' }),
debrief: (id: string) => req<DebriefResponse>(`/games/${id}/debrief`),
metrics: (id: string) => req<MetricsTimelineResponse>(`/games/${id}/metrics/timeline`),
setupStart: (scenario_id: string, initial_message: string) =>
req<SetupSession>(`/scenarios/setup`, {
method: 'POST', body: JSON.stringify({ scenario_id, initial_message }),
}),
setupGet: (session_id: string) => req<SetupSession>(`/scenarios/setup/${session_id}`),
setupAnswer: (session_id: string, message: string) =>
req<SetupSession>(`/scenarios/setup/${session_id}/answer`, {
method: 'POST', body: JSON.stringify({ message }),
}),
setupCommit: (session_id: string, force = false) =>
req<ScaffoldResponse>(`/scenarios/setup/${session_id}/commit`, {
method: 'POST', body: JSON.stringify({ force }),
}),
fork: (game_id: string, from_turn: number) =>
req<ForkResponse>(`/games/${game_id}/fork`, {
method: 'POST', body: JSON.stringify({ from_turn }),
}),
orchestrate: (game_id: string, opts?: { branches?: number; turns?: number }) =>
req<OrchestrateAccepted>(`/games/${game_id}/orchestrate`, {
method: 'POST',
body: JSON.stringify({ branches: opts?.branches ?? 3, turns: opts?.turns ?? 1 }),
}),
orchestrateStatus: (game_id: string) =>
req<OrchestrateStatus>(`/games/${game_id}/orchestrate/status`),
orchestrateResult: (game_id: string) =>
req<ScenarioMap>(`/games/${game_id}/orchestrate/result`),
// Action Coach — conversational action input that drafts a 3-anchor
// ActionSubmission and submits it through the same engine path.
actionCoachStart: (game_id: string, initial_prose: string) =>
req<ActionCoachSession>(`/games/${game_id}/actions/coach`, {
method: 'POST',
body: JSON.stringify({ initial_prose }),
}),
actionCoachLoad: (game_id: string, session_id: string) =>
req<ActionCoachSession>(`/games/${game_id}/actions/coach/${session_id}`),
actionCoachAnswer: (game_id: string, session_id: string, message: string) =>
req<ActionCoachSession>(`/games/${game_id}/actions/coach/${session_id}/answer`, {
method: 'POST',
body: JSON.stringify({ message }),
}),
actionCoachCommit: (game_id: string, session_id: string) =>
req<ActionAcceptedResponse>(`/games/${game_id}/actions/coach/${session_id}/commit`, {
method: 'POST',
}),
// ---- forms (5b) ----
addConstraint: (game_id: string, body: HardConstraintInput) =>
req<HardConstraintListResponse>(`/games/${game_id}/constraints`, {
method: 'POST',
body: JSON.stringify(body),
}),
checkAction: (game_id: string, action: ActionRequest) =>
req<ConstraintCheckResponse>(`/games/${game_id}/check_action`, {
method: 'POST',
body: JSON.stringify(action),
}),
deleteConstraint: (game_id: string, constraint_id: string, force = false) =>
req<HardConstraintListResponse>(
`/games/${game_id}/constraints/${encodeURIComponent(constraint_id)}${force ? '?force=true' : ''}`,
{ method: 'DELETE' },
),
deleteDocument: (session_id: string, filename: string) =>
req<void>(
`/scenarios/setup/${session_id}/documents/${encodeURIComponent(filename)}`,
{ method: 'DELETE' },
),
listConstraints: (game_id: string) =>
req<HardConstraintListResponse>(`/games/${game_id}/constraints`),
listDocuments: (session_id: string) =>
req<DocumentListResponse>(`/scenarios/setup/${session_id}/documents`),
uploadDocument: (session_id: string, file: File) => {
const fd = new FormData();
fd.append('file', file);
// NOTE: don't set Content-Type — the browser fills in the multipart
// boundary. `req()` defaults to JSON, so we call fetch directly here.
return fetch(`${BASE}/scenarios/setup/${session_id}/documents`, {
method: 'POST',
body: fd,
}).then(async (res) => {
if (!res.ok) {
const body = await res.text().catch(() => '');
throw Object.assign(
new Error(`${res.status} ${res.statusText}: ${body}`),
{ status: res.status },
);
}
return res.json() as Promise<UploadedDocument>;
});
},
// ---- live (5b) ----
pauseGame: (id: string) =>
req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
resumeGame: (id: string) =>
req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
// EventSource can't attach custom headers, so the trial password rides in
// a `?token=` query param instead. The backend middleware accepts either.
streamUrl: (id: string) => {
const pw = getPassword();
const qs = pw ? `?token=${encodeURIComponent(pw)}` : '';
return `${BASE}/games/${id}/stream${qs}`;
},
};
export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
export interface Anchor { kind: AnchorKind; detail: string }
export interface ActionRequest { intent: string; justification: string; anchors: Anchor[] }
export interface ActionAcceptedResponse { game_id: string; turn: number; action: any }
export interface Scenario { scenario_id: string; company_name: string; rival_name: string; industry: string; player_role: string; agent_count: number }
export interface GameSummary { id: string; scenario_id: string; turn: number; max_turns: number; status: 'active' | 'complete'; updated_at: string; llm_model?: string | null }
export interface ModelInfo { id: string; display_name: string; provider: string }
export interface ModelsResponse { models: ModelInfo[]; default: string; roles?: string[] }
export interface AgentInfo { id: string; label: string; kind: string; posture: string }
export interface AgentsResponse { scenario_id: string; agents: AgentInfo[] }
export interface BriefingSection { heading: string; body: string; fog_of_war?: string | null }
export interface DecisionPoint { title: string; context: string; example_actions: string[] }
export interface Briefing { turn: number; headline: string; exec_summary: string; sections: BriefingSection[]; decision_points: DecisionPoint[] }
export interface BriefingResponse { game_id: string; turn: number; briefing: Briefing; recon_budget_remaining: number }
export interface ReconResponseEnvelope { game_id: string; recon_budget_remaining: number; response: { answered_by: string; question: string; answer: string; reveals_to_rival: boolean } }
export interface AdvanceResponse { game_id: string; status: string; turn_resolved: number; next_turn?: number; briefing?: Briefing }
export interface DebriefResponse { game_id: string; status: string; markdown: string }
export interface MetricsTimelineResponse { game_id: string; points: { turn: number; metrics: { key: string; label: string; unit: string; value: number; delta: number }[] }[] }
export interface StateResponse { id: string; scenario_id: string; status: string; world: any; recon_budget_remaining: number; current_phase: string; turns: any[]; language?: 'en' | 'de' }
export interface SetupMessage { role: 'user' | 'setup_agent'; content: string; at: string }
export interface SetupSession {
session_id: string;
scenario_id: string;
status: 'gathering' | 'ready' | 'committed';
last_agent_message: string;
synthesized_brief: string | null;
committed_scenario_id: string | null;
messages: SetupMessage[];
doc_filenames?: string[];
}
export interface ScaffoldResponse {
scenario_id: string;
target_dir: string;
mode: 'blank' | 'generated';
summary: Scenario | null;
}
export interface ForkResponse {
game_id: string;
forked_from: string;
forked_at_turn: number;
turn: number;
briefing: Briefing;
}
export type OrchestratorStatus = 'pending' | 'running' | 'complete' | 'failed';
export interface StrategyVector {
id: string;
label: string;
description: string;
play_style_note: string;
likely_stumbles: string[];
priority_metrics: string[];
}
export interface OrchestrateAccepted { map_id: string; status: string }
export interface OrchestrateStatus {
map_id: string;
status: OrchestratorStatus;
branches_complete: number;
branches_total: number;
error: string | null;
}
export interface ScenarioMap {
id: string;
source_game_id: string;
scenario_id: string;
created_at: string;
updated_at: string;
vectors: StrategyVector[];
branches: Array<{
vector_id: string;
branch_game_id: string;
proxy_temperature: number;
narrative_after?: string;
skipped?: boolean;
skip_reason?: string | null;
turns_played?: number;
}>;
synthesis_markdown: string;
branches_requested: number;
turns_requested: number;
status: OrchestratorStatus;
branches_complete: number;
error: string | null;
}
// Action Coach — conversational action input.
export interface ActionDraft {
intent: string;
justification: string;
anchors: Anchor[];
}
export interface CoachMessage {
role: 'user' | 'coach';
content: string;
at: string;
}
export type ActionCoachStatus = 'gathering' | 'ready' | 'committed';
export interface ActionCoachSession {
session_id: string;
game_id: string;
turn: number;
status: ActionCoachStatus;
rounds_used: number;
last_coach_message: string;
synthesized_draft: ActionDraft | null;
committed: boolean;
messages: CoachMessage[];
}
// ---- forms (5b) ----
// Hard constraints + uploaded documents + check-action shapes (Round 4 backend).
export type ConstraintSeverity = 'hard' | 'soft';
export type ConstraintSource = 'scenario' | 'document' | 'manual';
export interface HardConstraint {
id: string;
description: string;
rationale: string;
test_question: string;
severity: ConstraintSeverity;
source: ConstraintSource;
source_ref?: string | null;
}
export interface HardConstraintInput {
description: string;
rationale: string;
test_question: string;
severity?: ConstraintSeverity;
source_ref?: string | null;
}
export interface HardConstraintListResponse {
game_id: string;
constraints: HardConstraint[];
}
export interface ConstraintViolation {
constraint_id: string;
constraint_description: string;
reason: string;
}
export interface ConstraintCheckResult {
passes: boolean;
violations: ConstraintViolation[];
}
export interface ConstraintCheckResponse {
game_id: string;
result: ConstraintCheckResult;
}
export interface UploadedDocument {
filename: string;
bytes_stored: number;
}
export interface DocumentListResponse {
session_id: string;
documents: UploadedDocument[];
}
// ---- live (5b) ----
// PhaseEvent — server-sent events that fire on the engine bus during an
// advance. Discriminated by `kind`. Wire format documented in CONTRACT.md.
export type PhaseEventKind =
| 'phase_started'
| 'phase_completed'
| 'deliberation_complete'
| 'critic_complete'
| 'premortem_complete'
| 'verdict_complete'
| 'evaluator_complete'
| 'world_updated'
| 'briefing_ready'
| 'usage_tick'
| 'paused'
| 'resumed'
| 'error';
export type VerdictOutcome =
| 'effect_major'
| 'effect_minor'
| 'no_effect'
| 'backfires';
interface PhaseEventBase {
game_id: string;
turn: number;
label: string;
at: string;
payload: Record<string, unknown> & { dropped?: number };
}
export type PhaseEvent =
| (PhaseEventBase & { kind: 'phase_started'; payload: { dropped?: number } })
| (PhaseEventBase & { kind: 'phase_completed'; payload: { dropped?: number } })
| (PhaseEventBase & {
kind: 'deliberation_complete';
payload: { agent_id: string; expects: string; fears: string; dropped?: number };
})
| (PhaseEventBase & {
kind: 'critic_complete';
payload: { count: number; issue_count: number; dropped?: number };
})
| (PhaseEventBase & {
kind: 'premortem_complete';
payload: { count: number; dropped?: number };
})
| (PhaseEventBase & {
kind: 'verdict_complete';
payload: {
actor: string;
plausibility: number;
outcome: VerdictOutcome;
dropped?: number;
};
})
| (PhaseEventBase & {
kind: 'evaluator_complete';
payload: { scored: number; dropped?: number };
})
| (PhaseEventBase & {
kind: 'world_updated';
payload: { media_tone: string; public_narrative: string; dropped?: number };
})
| (PhaseEventBase & {
kind: 'briefing_ready';
payload: { turn: number; dropped?: number };
})
| (PhaseEventBase & {
kind: 'usage_tick';
payload: {
cost_usd_so_far: number;
input_tokens_so_far: number;
output_tokens_so_far: number;
dropped?: number;
};
})
| (PhaseEventBase & {
kind: 'paused';
payload: { turn: number; dropped?: number };
})
| (PhaseEventBase & {
kind: 'resumed';
payload: { dropped?: number };
})
| (PhaseEventBase & {
kind: 'error';
payload: Record<string, unknown> & { dropped?: number };
});