Files
server-26/drb-frontend/app/systems/page.tsx
T
Logan CusanoandClaude Opus 5 58efdbd6eb
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Successful in 2m28s
Build & Deploy / Report a failed deploy (push) Skipped
Correct the transcript before anything reads it
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.

internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.

Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.

Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.

Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.

Two things found on the way:

PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.

Closes server-26#36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:23:41 -04:00

1572 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { AreaContext, SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ───────────────────────────────────────────────
interface TalkgroupEntry {
id: string;
name: string;
tag: string;
// Local knowledge for the transcript corrector (server-26#36). Optional on
// every talkgroup: unset means "inherit the system's", which is the whole
// point of the scope rule — talkgroup narrows, it does not replace.
vocabulary?: string[];
area_context?: AreaContext;
}
/** Comma-separated text field <-> string[], the shape the API stores. */
const listToText = (v?: string[]) => (v ?? []).join(", ");
const textToList = (v: string) =>
v.split(",").map((x) => x.trim()).filter(Boolean);
/** Drop an area_context whose every field is blank, so we don't store noise. */
function cleanArea(a?: AreaContext): AreaContext | undefined {
if (!a) return undefined;
const out: AreaContext = {};
if (a.municipality?.trim()) out.municipality = a.municipality.trim();
if (a.county?.trim()) out.county = a.county.trim();
if (a.roads?.length) out.roads = a.roads;
if (a.landmarks?.length) out.landmarks = a.landmarks;
return Object.keys(out).length ? out : undefined;
}
interface P25Config {
nac: string;
system_id: string;
wacn: string;
control_channels: string;
voice_channels: string;
talkgroups: TalkgroupEntry[];
}
const DEFAULT_P25: P25Config = {
nac: "",
system_id: "",
wacn: "",
control_channels: "",
voice_channels: "",
talkgroups: [],
};
const TG_TAGS = ["fire", "police", "ems", "transit", "public works", "other"];
function recordToP25Config(c: Record<string, unknown>): P25Config {
return {
nac: String(c.nac ?? ""),
system_id: String(c.system_id ?? ""),
wacn: String(c.wacn ?? ""),
control_channels: Array.isArray(c.control_channels)
? (c.control_channels as number[]).join(", ")
: "",
voice_channels: Array.isArray(c.voice_channels)
? (c.voice_channels as number[]).join(", ")
: "",
talkgroups: Array.isArray(c.talkgroups)
? (c.talkgroups as Array<{
id: number; name: string; tag: string;
vocabulary?: string[]; area_context?: AreaContext;
}>).map((tg) => ({
id: String(tg.id),
name: tg.name,
tag: tg.tag ?? "other",
vocabulary: tg.vocabulary ?? [],
area_context: tg.area_context ?? {},
}))
: [],
};
}
function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
const parseFreqs = (s: string) =>
s
.split(",")
.map((f) => parseFloat(f.trim()))
.filter((f) => !isNaN(f));
return {
nac: p.nac,
system_id: p.system_id ? parseInt(p.system_id, 10) : undefined,
wacn: p.wacn,
control_channels: parseFreqs(p.control_channels),
voice_channels: parseFreqs(p.voice_channels),
talkgroups: p.talkgroups
.filter((tg) => tg.id && tg.name)
.map((tg) => {
const area = cleanArea(tg.area_context);
const vocab = (tg.vocabulary ?? []).filter(Boolean);
return {
id: parseInt(tg.id, 10),
name: tg.name,
tag: tg.tag,
// Omitted rather than written empty: an absent key is what
// resolve_context() reads as "inherit from the system".
...(vocab.length ? { vocabulary: vocab } : {}),
...(area ? { area_context: area } : {}),
};
}),
};
}
// ── RadioReference parser types ───────────────────────────────────────────────
interface RRTalkgroup {
dec: number;
alphaTag: string;
description: string;
tag: string;
}
interface RRCategory {
name: string;
talkgroups: RRTalkgroup[];
}
interface RRSystem {
name: string;
location: string;
sysIds: string;
systemType: string;
categories: RRCategory[];
}
function mapRRTag(rrTag: string): string {
const t = rrTag.toLowerCase();
if (t.includes("fire")) return "fire";
if (t.includes("law") || t.includes("police")) return "police";
if (t.includes("ems") || t.includes("emergency medical")) return "ems";
if (t.includes("transport") || t.includes("transit")) return "transit";
if (t.includes("public works")) return "public works";
return "other";
}
function parseRadioReference(html: string): RRSystem | null {
const doc = new DOMParser().parseFromString(html, "text/html");
// Validate: RadioReference system pages have rrlblue header cells
if (!doc.querySelector(".rrlblue")) return null;
// System info table (first table with rrlblue headers)
const infoMap: Record<string, string> = {};
const infoTable = doc.querySelector("table.table-sm.table-bordered");
if (infoTable) {
infoTable.querySelectorAll("tr").forEach((row) => {
const th = row.querySelector("th.rrlblue");
const td = row.querySelector("td");
if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? "";
});
}
const name = infoMap["System Name"] ?? doc.title ?? "Unknown System";
const location = infoMap["Location"] ?? "";
const sysIds = infoMap["System IDs"] ?? "";
const systemType = infoMap["System Type"] ?? "";
// Talkgroup tables — find all with class rrdbTable or datatable-lite
// For each, find the nearest preceding h5 to use as category name
const tgTables = Array.from(
doc.querySelectorAll("table.rrdbTable, table.datatable-lite")
) as HTMLTableElement[];
const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[];
function categoryForTable(table: HTMLTableElement): string {
// Find the last h5 that appears before this table in document order
let best: HTMLElement | null = null;
for (const h5 of allH5s) {
const pos = h5.compareDocumentPosition(table);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5;
}
if (!best) return "Uncategorized";
const clone = best.cloneNode(true) as HTMLElement;
clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove());
return clone.textContent?.trim() || "Uncategorized";
}
const categories: RRCategory[] = [];
for (const table of tgTables) {
// Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …)
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
th.textContent?.trim().toLowerCase()
);
if (!headers.includes("dec") && !headers.includes("hex")) continue;
const catName = categoryForTable(table);
const talkgroups: RRTalkgroup[] = [];
table.querySelectorAll("tbody tr").forEach((row) => {
const cells = Array.from(row.querySelectorAll("td"));
if (cells.length < 6) return;
// DEC cell may wrap in a Broadcastify link
const decText =
cells[0].querySelector("a")?.textContent?.trim() ??
cells[0].textContent?.trim() ??
"";
const dec = parseInt(decText.replace(/\D/g, ""), 10);
if (isNaN(dec)) return;
const alphaTag = cells[3].textContent?.trim() ?? "";
const description = cells[4].textContent?.trim() ?? "";
const tag = cells[5].textContent?.trim() ?? "";
talkgroups.push({ dec, alphaTag, description, tag });
});
if (talkgroups.length > 0) {
// Merge into an existing category with same name if present
const existing = categories.find((c) => c.name === catName);
if (existing) {
existing.talkgroups.push(...talkgroups);
} else {
categories.push({ name: catName, talkgroups });
}
}
}
if (categories.length === 0) return null;
return { name, location, sysIds, systemType, categories };
}
// ── RadioReference import modal ───────────────────────────────────────────────
function RRImportModal({
system,
onImport,
onCancel,
}: {
system: RRSystem;
onImport: (tgs: TalkgroupEntry[]) => void;
onCancel: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(
() => new Set(system.categories.map((c) => c.name))
);
function toggle(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name); else next.add(name);
return next;
});
}
function handleImport() {
const tgs: TalkgroupEntry[] = [];
for (const cat of system.categories) {
if (!selected.has(cat.name)) continue;
for (const tg of cat.talkgroups) {
tgs.push({
id: String(tg.dec),
name: `${cat.name.split(" - ")[0]} - ${tg.description || tg.alphaTag}`,
tag: mapRRTag(tg.tag),
});
}
}
onImport(tgs);
}
const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0);
const selectedCount = system.categories
.filter((c) => selected.has(c.name))
.reduce((s, c) => s + c.talkgroups.length, 0);
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
<h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-gray-500 mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""}
</p>
{system.sysIds && (
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
)}
<p className="text-xs text-gray-500 mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p>
</div>
{/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400">
<button
type="button"
onClick={() => setSelected(new Set(system.categories.map((c) => c.name)))}
className="hover:text-indigo-300 transition-colors"
>
All
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
className="hover:text-indigo-300 transition-colors"
>
None
</button>
</div>
</div>
{system.categories.map((cat) => (
<label key={cat.name} className="flex items-center gap-3 cursor-pointer group py-0.5">
<input
type="checkbox"
checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)}
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
/>
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
{cat.name}
</span>
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
</label>
))}
</div>
{/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
<button
type="button"
onClick={handleImport}
disabled={selectedCount === 0}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
Import {selectedCount} talkgroup{selectedCount !== 1 ? "s" : ""}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}
/** True when this talkgroup overrides anything, so the row can say so. */
function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
}
/** One labelled text input in the local-knowledge grid. */
function LocalKnowledgeField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500"
/>
</label>
);
}
// ── Talkgroup table editor ────────────────────────────────────────────────────
function TalkgroupEditor({
talkgroups,
onChange,
}: {
talkgroups: TalkgroupEntry[];
onChange: (tgs: TalkgroupEntry[]) => void;
}) {
const [showPaste, setShowPaste] = useState(false);
const [pasteText, setPasteText] = useState("");
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
const [rrError, setRrError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const rrInputRef = useRef<HTMLInputElement>(null);
function addRow() {
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
}
function updateArea(i: number, field: "municipality" | "county", value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], area_context: { ...updated[i].area_context, [field]: value } };
onChange(updated);
}
function updateAreaList(i: number, field: "roads" | "landmarks", value: string) {
const updated = [...talkgroups];
updated[i] = {
...updated[i],
area_context: { ...updated[i].area_context, [field]: textToList(value) },
};
onChange(updated);
}
function updateRowList(i: number, field: "vocabulary", value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], [field]: textToList(value) };
onChange(updated);
}
function removeRow(i: number) {
onChange(talkgroups.filter((_, idx) => idx !== i));
}
function updateRow(i: number, field: keyof TalkgroupEntry, value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], [field]: value };
onChange(updated);
}
function handlePasteImport() {
const lines = pasteText.trim().split("\n");
const parsed: TalkgroupEntry[] = lines
.map((line) => {
const parts = line.includes("\t") ? line.split("\t") : line.split(",");
const id = parts[0]?.trim() ?? "";
const name = parts[1]?.trim() ?? "";
const rawTag = parts[2]?.trim()?.toLowerCase() ?? "other";
const tag = TG_TAGS.includes(rawTag) ? rawTag : "other";
return { id, name, tag };
})
.filter((e) => e.id && e.name);
onChange([...talkgroups, ...parsed]);
setPasteText("");
setShowPaste(false);
}
async function handleRRFile(file: File) {
setRrError(null);
const html = await file.text();
const parsed = parseRadioReference(html);
if (!parsed) {
setRrError(
"This doesn't look like a RadioReference trunked system page. " +
"Download the HTML from a system page on radioreference.com and try again."
);
return;
}
setRrSystem(parsed);
}
function handleRRImport(newTgs: TalkgroupEntry[]) {
onChange([...talkgroups, ...newTgs]);
setRrSystem(null);
setRrError(null);
}
return (
<div className="space-y-2">
{rrSystem && (
<RRImportModal
system={rrSystem}
onImport={handleRRImport}
onCancel={() => setRrSystem(null)}
/>
)}
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
</label>
<div className="flex gap-3">
{/* RadioReference file import */}
<button
type="button"
onClick={() => rrInputRef.current?.click()}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
RadioReference import
</button>
<input
ref={rrInputRef}
type="file"
accept=".html,.htm"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleRRFile(f);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => setShowPaste(!showPaste)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
{showPaste ? "Cancel paste" : "Paste import"}
</button>
<button
type="button"
onClick={addRow}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add row
</button>
</div>
</div>
{rrError && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/50 rounded px-3 py-2">
{rrError}
</p>
)}
{showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
<p className="text-xs text-gray-500">
Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-gray-400">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other
</p>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={handlePasteImport}
disabled={!pasteText.trim()}
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
>
Import rows
</button>
</div>
)}
{talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-800 text-gray-400">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th>
<th className="px-3 py-1.5 w-8"></th>
</tr>
</thead>
<tbody>
{talkgroups.map((tg, i) => (
<Fragment key={i}>
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
<td className="px-2 py-1">
<input
value={tg.id}
onChange={(e) => updateRow(i, "id", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="1234"
/>
</td>
<td className="px-2 py-1">
<input
value={tg.name}
onChange={(e) => updateRow(i, "name", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="Fire Dispatch"
/>
</td>
<td className="px-2 py-1">
<select
value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1"
>
{TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</td>
<td className="px-2 py-1 text-center">
<button
type="button"
title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
}`}
>
{expanded === i ? "▾" : "▸"}
</button>
<button
type="button"
onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
>
×
</button>
</td>
</tr>
{/* Local knowledge, collapsed by default: a system can carry 125
talkgroups and most inherit the system's context rather than
override it. */}
{expanded === i && (
<tr className="border-t border-gray-800 bg-gray-900/60">
<td colSpan={4} className="px-3 py-3">
<p className="text-xs text-gray-500 mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked
<span className="text-gray-300"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<LocalKnowledgeField
label="Municipality"
value={tg.area_context?.municipality ?? ""}
onChange={(v) => updateArea(i, "municipality", v)}
placeholder="Ossining"
/>
<LocalKnowledgeField
label="County"
value={tg.area_context?.county ?? ""}
onChange={(v) => updateArea(i, "county", v)}
placeholder="Westchester"
/>
<LocalKnowledgeField
label="Roads"
value={listToText(tg.area_context?.roads)}
onChange={(v) => updateAreaList(i, "roads", v)}
placeholder="Route 9, Croton Ave"
/>
<LocalKnowledgeField
label="Landmarks & businesses"
value={listToText(tg.area_context?.landmarks)}
onChange={(v) => updateAreaList(i, "landmarks", v)}
placeholder="Sing Sing, Phelps Hospital"
/>
<div className="md:col-span-2">
<LocalKnowledgeField
label="Unit call signs & local terms"
value={listToText(tg.vocabulary)}
onChange={(v) => updateRowList(i, "vocabulary", v)}
placeholder="Post 4, 11-X-ray, Car 7"
/>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
) : (
<p className="text-xs text-gray-600 italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p>
)}
</div>
);
}
// ── P25 structured form ───────────────────────────────────────────────────────
function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label>
<input
value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">System ID</label>
<input
value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label>
<input
value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
</label>
<input
value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label>
<input
value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional"
/>
</div>
<TalkgroupEditor
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
</div>
);
}
// ── Main system form ──────────────────────────────────────────────────────────
function SystemForm({
initial,
createOnly,
onSave,
onCancel,
}: {
initial?: SystemRecord;
createOnly?: boolean;
onSave: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState(initial?.type ?? "P25");
const [p25, setP25] = useState<P25Config>(
initial?.type === "P25" && initial.config
? recordToP25Config(initial.config)
: DEFAULT_P25
);
const [rawJson, setRawJson] = useState(
initial?.type !== "P25" && initial?.config
? JSON.stringify(initial.config, null, 2)
: "{}"
);
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(t: string) {
setType(t);
setShowRaw(t !== "P25");
}
function toggleRaw() {
if (!showRaw) {
setRawJson(JSON.stringify(p25ConfigToRecord(p25), null, 2));
} else {
try {
setP25(recordToP25Config(JSON.parse(rawJson)));
} catch {
// keep current p25 state if parse fails
}
}
setShowRaw(!showRaw);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
let config: Record<string, unknown>;
if (type === "P25" && !showRaw) {
config = p25ConfigToRecord(p25);
} else {
config = JSON.parse(rawJson);
}
if (initial && !createOnly) {
await c2api.updateSystem(initial.system_id, { name, type, config });
} else {
await c2api.createSystem({ name, type, config });
}
onSave();
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid config or save failed.");
} finally {
setSaving(false);
}
}
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Westchester County P25"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Type</label>
<select
value={type}
onChange={(e) => handleTypeChange(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option>P25</option>
<option>DMR</option>
<option>NBFM</option>
</select>
</div>
</div>
{type === "P25" && !showRaw ? (
<P25Form value={p25} onChange={setP25} />
) : (
<div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
<textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
)}
{type === "P25" && (
<button
type="button"
onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors"
>
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
// ── Preferred bot token panel ─────────────────────────────────────────────────
interface TokenOption {
token_id: string;
name: string;
in_use: boolean;
}
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (tokens !== null) return;
try {
const data = await c2api.getTokens();
setTokens(data as TokenOption[]);
} catch {
setTokens([]);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleSet(tokenId: string) {
setSaving(true);
try {
await c2api.setPreferredToken(tokenId, systemId);
setPreferredId(tokenId);
} finally {
setSaving(false);
}
}
async function handleClear() {
if (!preferredId) return;
setSaving(true);
try {
await c2api.setPreferredToken(preferredId, "_none");
setPreferredId(null);
} finally {
setSaving(false);
}
}
const currentToken = tokens?.find((t) => t.token_id === preferredId);
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Preferred Bot Token
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
</span>
</button>
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p>
) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p>
) : (
<>
<p className="text-gray-600">
When a node on this system joins a voice channel, this token is tried first.
</p>
<div className="space-y-1.5">
{tokens.map((t) => (
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name={`preferred-token-${systemId}`}
checked={preferredId === t.token_id}
onChange={() => handleSet(t.token_id)}
disabled={saving}
className="accent-indigo-500"
/>
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
{t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span>
</label>
))}
</div>
{preferredId && (
<button
onClick={handleClear}
disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
>
Clear preference (use any free token)
</button>
)}
{!preferredId && (
<p className="text-gray-700">No preference — any free token will be used.</p>
)}
{currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Per-system AI flags panel ─────────────────────────────────────────────────
interface SystemAiFlags {
stt_enabled?: boolean;
correlation_enabled?: boolean;
}
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
const [flags, setFlags] = useState<SystemAiFlags>(initial);
const [saving, setSaving] = useState<string | null>(null);
const [open, setOpen] = useState(false);
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
setSaving(key);
try {
const result = await c2api.setSystemAiFlags(systemId, { [key]: value });
setFlags(result.ai_flags as SystemAiFlags);
} finally {
setSaving(null);
}
}
async function handleClear(key: keyof SystemAiFlags) {
setSaving(key);
try {
const result = await c2api.setSystemAiFlags(systemId, { [key]: null });
setFlags(result.ai_flags as SystemAiFlags);
} finally {
setSaving(null);
}
}
const rows: { key: keyof SystemAiFlags; label: string }[] = [
{ key: "stt_enabled", label: "Speech-to-Text" },
{ key: "correlation_enabled", label: "Incident Correlation" },
];
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={() => setOpen((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span>
{(flags.stt_enabled === false || flags.correlation_enabled === false) && (
<span className="ml-1.5 text-yellow-600 font-bold">!</span>
)}
</button>
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{rows.map(({ key, label }) => {
const override = flags[key];
const isSet = override !== undefined;
const isOn = override !== false;
return (
<div key={key} className="flex items-center gap-3">
<button
onClick={() => handleToggle(key, !isOn)}
disabled={saving === key}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
isOn ? "bg-indigo-600" : "bg-gray-700"
}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
<span className="text-gray-300 flex-1">{label}</span>
{isSet ? (
<button
onClick={() => handleClear(key)}
disabled={saving === key}
className="text-gray-600 hover:text-gray-400 transition-colors"
title="Clear override (inherit global)"
>
reset
</button>
) : (
<span className="text-gray-700">inherits global</span>
)}
</div>
);
})}
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div>
)}
</div>
);
}
// ── Source call audio player ──────────────────────────────────────────────────
function SourceCallPlayer({ callId }: { callId: string }) {
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function toggle() {
if (!open && !call) {
setLoading(true);
try {
const c = await c2api.getCall(callId);
setCall(c as unknown as typeof call);
} finally {
setLoading(false);
}
}
setOpen((v) => !v);
}
const transcript = call?.transcript_corrected || call?.transcript;
return (
<div className="text-xs">
<button
onClick={toggle}
disabled={loading}
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
title={callId}
>
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button>
{open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
{call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : (
<p className="text-gray-600 italic">No audio</p>
)}
{transcript && (
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
)}
</div>
)}
</div>
);
}
// ── Area context panel ────────────────────────────────────────────────────────
/**
* System-wide ground truth for the transcript corrector (server-26#36).
*
* This is the fallback every talkgroup inherits. A talkgroup that covers one
* municipality inside a multi-county system overrides it from the talkgroup
* table in the edit form, and its entries rank above these.
*/
function AreaContextPanel({ systemId }: { systemId: string }) {
const [open, setOpen] = useState(false);
const [area, setArea] = useState<AreaContext | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function toggle() {
const next = !open;
setOpen(next);
if (!next || area !== null) return;
setLoading(true);
try {
const data = await c2api.getAreaContext(systemId);
setArea(data.area_context ?? {});
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
async function save() {
if (!area) return;
setSaving(true);
setError(null);
try {
await c2api.updateAreaContext(systemId, area);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
const filled = area
? [area.municipality, area.county, area.roads?.length, area.landmarks?.length].filter(Boolean).length
: 0;
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Local Area
{area !== null && (
<span className="text-gray-600 ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
{area && (
<>
<p className="text-gray-500">
Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets
them be put back. Individual talkgroups can narrow this in the edit form.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<LocalKnowledgeField
label="Municipality"
value={area.municipality ?? ""}
onChange={(v) => setArea({ ...area, municipality: v })}
placeholder="Ossining"
/>
<LocalKnowledgeField
label="County"
value={area.county ?? ""}
onChange={(v) => setArea({ ...area, county: v })}
placeholder="Westchester"
/>
<LocalKnowledgeField
label="Roads"
value={listToText(area.roads)}
onChange={(v) => setArea({ ...area, roads: textToList(v) })}
placeholder="Route 9, Saw Mill Parkway"
/>
<LocalKnowledgeField
label="Landmarks & businesses"
value={listToText(area.landmarks)}
onChange={(v) => setArea({ ...area, landmarks: textToList(v) })}
placeholder="Phelps Hospital, Metro-North station"
/>
</div>
<button
type="button"
onClick={save}
disabled={saving}
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
>
{saving ? "Saving…" : "Save area"}
</button>
</>
)}
{error && <p className="text-red-400 font-mono">{error}</p>}
</div>
)}
</div>
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [bootstrapped, setBootstrapped] = useState(false);
const [loading, setLoading] = useState(false);
const [bootstrapping, setBootstrapping] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (vocab !== null) return;
setLoading(true);
try {
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(data.vocabulary_bootstrapped);
} finally {
setLoading(false);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleBootstrap() {
setBootstrapping(true);
try {
const result = await c2api.bootstrapVocabulary(systemId);
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(true);
alert(`Bootstrap added ${result.added} term(s).`);
} finally {
setBootstrapping(false);
}
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const term = newTerm.trim();
if (!term) return;
setAdding(true);
try {
await c2api.addVocabularyTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setNewTerm("");
} finally {
setAdding(false);
}
}
async function handleRemove(term: string) {
await c2api.removeVocabularyTerm(systemId, term);
setVocab((v) => (v ?? []).filter((t) => t !== term));
}
async function handleApprove(term: string) {
await c2api.approvePendingTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setPending((p) => p.filter((t) => t.term !== term));
}
async function handleDismiss(term: string) {
await c2api.dismissPendingTerm(systemId, term);
setPending((p) => p.filter((t) => t.term !== term));
}
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Vocabulary
{vocab !== null && (
<span className="text-gray-600 ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading…</p>}
{!loading && vocab !== null && (
<>
<div className="flex items-center gap-3">
<button
onClick={handleBootstrap}
disabled={bootstrapping}
className="bg-indigo-800 hover:bg-indigo-700 disabled:opacity-50 text-indigo-200 px-3 py-1.5 rounded-lg text-xs transition-colors"
>
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
{term}
<button
onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
</div>
) : (
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p>
)}
</div>
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
>
Add
</button>
</form>
{pending.length > 0 && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-2">
{pending.map((p) => (
<div key={p.term} className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
</div>
{p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1">
{p.source_call_ids.map((id: string) => (
<Fragment key={id}>
<SourceCallPlayer callId={id} />
</Fragment>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { systems, loading } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
}, [authLoading, isAdmin, isOperator, router]);
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
// Every hook must run before this guard. React tracks hooks by call order,
// so returning early on the first render and then reaching a useState on the
// next one is error #310 ("rendered more hooks than during the previous
// render") -- which crashed this whole page to a blank client-exception
// screen the moment auth resolved.
if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id);
}
function openEdit(s: SystemRecord) {
setEditing(s);
setEditIsDuplicate(false);
}
function openDuplicate(s: SystemRecord) {
setEditing({ ...s, name: `Copy of ${s.name}` });
setEditIsDuplicate(true);
}
function closeEdit() {
setEditing(null);
setEditIsDuplicate(false);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
</div>
{editing && (
<SystemForm
initial={editing === "new" ? undefined : editing}
createOnly={editIsDuplicate}
onSave={closeEdit}
onCancel={closeEdit}
/>
)}
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p>
) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p>
) : (
<div className="space-y-3">
{systems.map((s) => {
const tgCount = Array.isArray(s.config.talkgroups)
? (s.config.talkgroups as unknown[]).length
: null;
const ccCount = Array.isArray(s.config.control_channels)
? (s.config.control_channels as unknown[]).length
: null;
return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p>
{(s.config.nac || ccCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>}
{tgCount !== null && <span>· {tgCount} TGs</span>}
</p>
)}
</div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
{s.type}
</span>
</div>
<div className="mt-3 flex gap-3">
<button
onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
<button
onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Duplicate
</button>
<button
onClick={() => handleDelete(s.system_id)}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Delete
</button>
</div>
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
</div>
);
})}
</div>
)}
</div>
);
}