@crowandraven/evalkeeper 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/bin/evalkeeper.mjs +9 -0
  4. package/dist/attention.d.ts +49 -0
  5. package/dist/attention.js +49 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +57 -0
  8. package/dist/board.js +2 -0
  9. package/dist/board.js.map +1 -0
  10. package/dist/cli/addPages.d.ts +79 -0
  11. package/dist/cli/addPages.js +181 -0
  12. package/dist/cli/addPages.js.map +1 -0
  13. package/dist/cli/main.d.ts +18 -0
  14. package/dist/cli/main.js +115 -0
  15. package/dist/cli/main.js.map +1 -0
  16. package/dist/common.d.ts +72 -0
  17. package/dist/common.js +276 -0
  18. package/dist/common.js.map +1 -0
  19. package/dist/compare.d.ts +85 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/decisions.d.ts +84 -0
  23. package/dist/decisions.js +146 -0
  24. package/dist/decisions.js.map +1 -0
  25. package/dist/feedback.d.ts +114 -0
  26. package/dist/feedback.js +116 -0
  27. package/dist/feedback.js.map +1 -0
  28. package/dist/format.d.ts +6 -0
  29. package/dist/format.js +22 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/glossary.d.ts +46 -0
  32. package/dist/glossary.js +45 -0
  33. package/dist/glossary.js.map +1 -0
  34. package/dist/health.d.ts +40 -0
  35. package/dist/health.js +101 -0
  36. package/dist/health.js.map +1 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +34 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/labels.d.ts +8 -0
  41. package/dist/labels.js +15 -0
  42. package/dist/labels.js.map +1 -0
  43. package/dist/legs.d.ts +23 -0
  44. package/dist/legs.js +22 -0
  45. package/dist/legs.js.map +1 -0
  46. package/dist/memoryStore.d.ts +21 -0
  47. package/dist/memoryStore.js +228 -0
  48. package/dist/memoryStore.js.map +1 -0
  49. package/dist/models.d.ts +76 -0
  50. package/dist/models.js +56 -0
  51. package/dist/models.js.map +1 -0
  52. package/dist/prune.d.ts +48 -0
  53. package/dist/prune.js +77 -0
  54. package/dist/prune.js.map +1 -0
  55. package/dist/quickSet.d.ts +26 -0
  56. package/dist/quickSet.js +44 -0
  57. package/dist/quickSet.js.map +1 -0
  58. package/dist/runner.d.ts +202 -0
  59. package/dist/runner.js +734 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +196 -0
  62. package/dist/settings.js +272 -0
  63. package/dist/settings.js.map +1 -0
  64. package/dist/stableJson.d.ts +11 -0
  65. package/dist/stableJson.js +15 -0
  66. package/dist/stableJson.js.map +1 -0
  67. package/dist/stats.d.ts +98 -0
  68. package/dist/stats.js +119 -0
  69. package/dist/stats.js.map +1 -0
  70. package/dist/store.d.ts +151 -0
  71. package/dist/store.js +3 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/suggest.d.ts +75 -0
  74. package/dist/suggest.js +152 -0
  75. package/dist/suggest.js.map +1 -0
  76. package/dist/summary.d.ts +51 -0
  77. package/dist/summary.js +61 -0
  78. package/dist/summary.js.map +1 -0
  79. package/dist/testing.d.ts +26 -0
  80. package/dist/testing.js +362 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/timeout.d.ts +2 -0
  83. package/dist/timeout.js +15 -0
  84. package/dist/timeout.js.map +1 -0
  85. package/dist/types.d.ts +225 -0
  86. package/dist/types.js +46 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/upkeepRules.d.ts +28 -0
  89. package/dist/upkeepRules.js +40 -0
  90. package/dist/upkeepRules.js.map +1 -0
  91. package/dist/util.d.ts +8 -0
  92. package/dist/util.js +18 -0
  93. package/dist/util.js.map +1 -0
  94. package/dist/weekly.d.ts +80 -0
  95. package/dist/weekly.js +113 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +89 -0
  99. package/src/board.ts +58 -0
  100. package/src/cli/addPages.ts +256 -0
  101. package/src/cli/main.ts +125 -0
  102. package/src/common.ts +338 -0
  103. package/src/compare.ts +261 -0
  104. package/src/decisions.ts +230 -0
  105. package/src/feedback.ts +225 -0
  106. package/src/format.ts +19 -0
  107. package/src/glossary.ts +75 -0
  108. package/src/health.ts +149 -0
  109. package/src/index.ts +33 -0
  110. package/src/labels.ts +18 -0
  111. package/src/legs.ts +38 -0
  112. package/src/memoryStore.ts +268 -0
  113. package/src/models.ts +127 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +1010 -0
  117. package/src/settings.ts +441 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +172 -0
  121. package/src/suggest.ts +247 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +415 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +258 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +169 -0
  129. package/starter/index.ts +86 -0
  130. package/templates/app/api/cron/evals-weekly/route.ts +24 -0
  131. package/templates/app/evals/[suite]/page.tsx +449 -0
  132. package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
  133. package/templates/app/evals/feedback/page.tsx +219 -0
  134. package/templates/app/evals/page.tsx +313 -0
  135. package/templates/components/evalkeeper/AddCase.tsx +62 -0
  136. package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
  137. package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
  138. package/templates/components/evalkeeper/Fields.tsx +110 -0
  139. package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
  140. package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
  141. package/templates/components/evalkeeper/Submit.tsx +58 -0
  142. package/templates/components/evalkeeper/Thumbs.tsx +117 -0
  143. package/templates/components/evalkeeper/Tip.tsx +98 -0
  144. package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
  145. package/templates/components/evalkeeper/evalkeeper.css +71 -0
  146. package/templates/components/evalkeeper/ui.tsx +103 -0
  147. package/templates/lib/evalkeeper/actions.ts +263 -0
  148. package/templates/lib/evalkeeper/cronAuth.ts +18 -0
  149. package/templates/lib/evalkeeper/paths.ts +11 -0
  150. package/templates/lib/evalkeeper/time.ts +4 -0
  151. package/templates/lib/evalkeeper/weekly.ts +51 -0
@@ -0,0 +1,360 @@
1
+ import {
2
+ allPassed,
3
+ type CaseRecord,
4
+ type GlossaryTerm,
5
+ type OutputRecord,
6
+ type AnySuite,
7
+ type BoardRow,
8
+ type CheckResult,
9
+ } from "@crowandraven/evalkeeper";
10
+ import {
11
+ addCase,
12
+ confirmAnswer,
13
+ editAnswer,
14
+ retireCase,
15
+ } from "@/lib/evalkeeper/actions";
16
+ import { Check, ChevronRight, CircleAlert, X } from "lucide-react";
17
+ import { store } from "@/lib/evalkeeper";
18
+ import { FieldValues, Fields } from "./Fields";
19
+ import { AddCaseHeader } from "./AddCase";
20
+ import { CaseMenu } from "./CaseMenu";
21
+ import { Submit } from "./Submit";
22
+ import { Chip, type ChipTone } from "./ui";
23
+ import { InfoTip, Tip } from "./Tip";
24
+
25
+ /** A suite's cases in use, with each case's answers newest first. */
26
+ async function loadCases(suite: string): Promise<CaseRecord[]> {
27
+ return (await store.listCases(suite)).map((c) => ({
28
+ ...c,
29
+ labels: [...c.labels].sort((a, b) => +b.updatedAt - +a.updatedAt),
30
+ }));
31
+ }
32
+
33
+ const STATUS: Record<
34
+ string,
35
+ { text: string; tone: ChipTone; term: GlossaryTerm }
36
+ > = {
37
+ CONFIRMED: { text: "Confirmed", tone: "live", term: "confirmed" },
38
+ PROVISIONAL: { text: "Provisional", tone: "warn", term: "provisional" },
39
+ PENDING: { text: "Pending", tone: "warn", term: "pending" },
40
+ };
41
+
42
+ /**
43
+ * The answer key: every case in use, each opening to its input, its
44
+ * answer, and how each model's latest test did on it, with forms to add a
45
+ * case, edit an answer, or retire a case.
46
+ */
47
+ export async function AnswerKey({
48
+ suite,
49
+ rows,
50
+ heading,
51
+ tools,
52
+ }: {
53
+ suite: AnySuite;
54
+ rows: BoardRow[];
55
+ /** The section's heading, with "Add a case" across from it. */
56
+ heading: React.ReactNode;
57
+ /** More buttons beside "Add a case" (rescore). */
58
+ tools?: React.ReactNode;
59
+ }) {
60
+ const cases = await loadCases(suite.key);
61
+ // Each model's latest test (at the operation's setting), by case.
62
+ const modelByRun = new Map(
63
+ rows.flatMap((r) =>
64
+ r.test && !r.variant ? [[r.test.runId, r.label] as const] : [],
65
+ ),
66
+ );
67
+ const outputs = (
68
+ await Promise.all([...modelByRun.keys()].map((id) => store.listOutputs(id)))
69
+ ).flat();
70
+ const byCase = new Map<string, { model: string; output: OutputRecord }[]>();
71
+ for (const o of outputs) {
72
+ byCase.set(o.caseId, [
73
+ ...(byCase.get(o.caseId) ?? []),
74
+ { model: modelByRun.get(o.runId) ?? o.runId, output: o },
75
+ ]);
76
+ }
77
+ const fields = suite.fields;
78
+
79
+ return (
80
+ <div className="space-y-4">
81
+ <AddCaseHeader
82
+ heading={heading}
83
+ tools={tools}
84
+ form={
85
+ fields &&
86
+ suite.labelFromForm && (
87
+ <form action={addCase} className="space-y-4">
88
+ <input type="hidden" name="suite" value={suite.key} />
89
+ <Fields fields={fields.input} prefix="new-" />
90
+ <p className="readable text-sm text-muted">
91
+ What a good answer is. You're writing it, so it's confirmed.
92
+ </p>
93
+ <Fields fields={fields.label} prefix="new-" />
94
+ <div className="grid gap-4 sm:grid-cols-2">
95
+ <div className="space-y-1">
96
+ <label
97
+ htmlFor="new-tags"
98
+ className="block text-sm font-medium"
99
+ >
100
+ Tags
101
+ <InfoTip term="tags" id="new-tags" />
102
+ </label>
103
+ <input
104
+ id="new-tags"
105
+ name="tags"
106
+ placeholder="e.g. billing, money now"
107
+ className="w-full rounded-md border border-line bg-card px-3 py-2 text-sm focus:border-accent focus:outline-none"
108
+ />
109
+ <p className="text-xs text-muted">
110
+ What this case tests, comma-separated. The quick set picks
111
+ cases to cover the most tags.
112
+ </p>
113
+ </div>
114
+ <div className="space-y-1">
115
+ <label
116
+ htmlFor="new-split"
117
+ className="block text-sm font-medium"
118
+ >
119
+ Set
120
+ <InfoTip
121
+ text="DEV: cases you tune against. TEST: cases you only check, never tune against, so they show how a change does on cases it wasn't fitted to."
122
+ id="new-split"
123
+ />
124
+ </label>
125
+ <select
126
+ id="new-split"
127
+ name="split"
128
+ defaultValue="DEV"
129
+ className="w-full rounded-md border border-line bg-card px-3 py-2 text-sm"
130
+ >
131
+ <option value="DEV">DEV: tune against it</option>
132
+ <option value="TEST">
133
+ TEST: check only, never tune against
134
+ </option>
135
+ </select>
136
+ </div>
137
+ </div>
138
+ <div className="flex gap-2">
139
+ <Submit>Add case</Submit>
140
+ </div>
141
+ </form>
142
+ )
143
+ }
144
+ />
145
+
146
+ <div className="space-y-2">
147
+ {cases.map((c) => (
148
+ <CaseItem
149
+ key={c.id}
150
+ suite={suite}
151
+ c={c}
152
+ results={byCase.get(c.id) ?? []}
153
+ />
154
+ ))}
155
+ {!cases.length && <p className="text-sm text-muted">No cases yet.</p>}
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ function CaseItem({
162
+ suite,
163
+ c,
164
+ results,
165
+ }: {
166
+ suite: AnySuite;
167
+ c: CaseRecord;
168
+ results: {
169
+ model: string;
170
+ output: { scores: unknown; error: string | null };
171
+ }[];
172
+ }) {
173
+ const answer =
174
+ c.labels.find((l) => l.status === "CONFIRMED") ??
175
+ c.labels.find((l) => l.status === "PROVISIONAL") ??
176
+ c.labels.find((l) => l.status === "PENDING");
177
+ const status = (answer && STATUS[answer.status]) ?? {
178
+ text: "No answer",
179
+ tone: "warn" as const,
180
+ term: "needsAnswer" as const,
181
+ };
182
+ const input = suite.input.parse(c.input);
183
+ const right = results.filter(
184
+ (r) =>
185
+ !r.output.error && allPassed(r.output.scores as CheckResult[] | null),
186
+ ).length;
187
+ const label = (key: string) =>
188
+ suite.checks.find((x) => x.key === key)?.label ?? key;
189
+ const tags = Array.isArray(c.tags) ? c.tags : [];
190
+
191
+ return (
192
+ <details className="group rounded-md border border-line bg-card">
193
+ <summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 p-3 text-sm">
194
+ <span className="font-medium">
195
+ <ChevronRight
196
+ aria-hidden
197
+ className="mr-1.5 inline-block h-4 w-4 align-[-3px] text-muted transition-transform group-open:rotate-90"
198
+ />
199
+ {suite.title(input)}
200
+ </span>
201
+ <span className="flex items-center gap-1.5">
202
+ <Tip term={status.term} id={`status-${c.id}`}>
203
+ <Chip tone={status.tone}>{status.text}</Chip>
204
+ </Tip>
205
+ <Tip term={c.split === "TEST" ? "test" : "dev"} id={`split-${c.id}`}>
206
+ <Chip tone="neutral">{c.split === "TEST" ? "Test" : "Dev"}</Chip>
207
+ </Tip>
208
+ {results.length > 0 && (
209
+ <Tip
210
+ text={`Right on ${right} of ${results.length} models' latest tests.`}
211
+ id={`right-${c.id}`}
212
+ align="end"
213
+ >
214
+ <span
215
+ className={`ml-1 inline-flex items-center gap-1 text-xs tabular-nums ${right === results.length ? "text-good" : "text-muted"}`}
216
+ >
217
+ <Check aria-hidden className="h-3.5 w-3.5" />
218
+ {right}/{results.length}
219
+ </span>
220
+ </Tip>
221
+ )}
222
+ </span>
223
+ </summary>
224
+
225
+ <div className="relative space-y-5 px-3 pb-4 text-sm">
226
+ <div className="grid gap-5 md:grid-cols-2">
227
+ <section className="space-y-2">
228
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
229
+ Input
230
+ </h4>
231
+ {suite.fields ? (
232
+ <FieldValues
233
+ fields={suite.fields.input}
234
+ values={c.input}
235
+ prefix={`${c.id}-in-`}
236
+ />
237
+ ) : (
238
+ <pre className="whitespace-pre-wrap text-xs">
239
+ {suite.describe?.(input) ?? JSON.stringify(c.input, null, 2)}
240
+ </pre>
241
+ )}
242
+ </section>
243
+ <section className="space-y-2">
244
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
245
+ Answer
246
+ </h4>
247
+ {answer && suite.fields ? (
248
+ <FieldValues
249
+ fields={suite.fields.label}
250
+ values={answer.value}
251
+ prefix={`${c.id}-ans-`}
252
+ />
253
+ ) : (
254
+ <pre className="whitespace-pre-wrap text-xs">
255
+ {JSON.stringify(answer?.value ?? null, null, 2)}
256
+ </pre>
257
+ )}
258
+ {(tags.length > 0 || c.note) && (
259
+ <p className="readable text-xs text-muted">
260
+ {tags.length > 0 && `Tags: ${tags.join(", ")}. `}
261
+ {c.note}
262
+ </p>
263
+ )}
264
+ </section>
265
+ </div>
266
+
267
+ {results.length > 0 && (
268
+ <section className="space-y-2">
269
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
270
+ Each model's latest test
271
+ </h4>
272
+ <ul className="space-y-1">
273
+ {results.map(({ model, output }) => {
274
+ const checks = (output.scores as CheckResult[] | null) ?? null;
275
+ const failed = checks?.filter((x) => x.pass === false) ?? [];
276
+ return (
277
+ <li key={model} className="flex items-start gap-1.5">
278
+ {output.error ? (
279
+ <CircleAlert
280
+ aria-label="No answer"
281
+ className="mt-0.5 h-4 w-4 shrink-0 text-bad"
282
+ />
283
+ ) : failed.length ? (
284
+ <X
285
+ aria-label="Wrong"
286
+ className="mt-0.5 h-4 w-4 shrink-0 text-warn"
287
+ />
288
+ ) : (
289
+ <Check
290
+ aria-label="Right"
291
+ className="mt-0.5 h-4 w-4 shrink-0 text-good"
292
+ />
293
+ )}
294
+ <span>
295
+ <span className="font-medium">{model}</span>
296
+ {output.error && (
297
+ <span className="text-muted">: {output.error}</span>
298
+ )}
299
+ {failed.map((x) => (
300
+ <span key={x.key} className="text-muted">
301
+ {" "}
302
+ · {label(x.key)}
303
+ {x.note ? `: ${x.note}` : ""}
304
+ </span>
305
+ ))}
306
+ </span>
307
+ </li>
308
+ );
309
+ })}
310
+ </ul>
311
+ </section>
312
+ )}
313
+
314
+ {answer?.status === "PENDING" && (
315
+ <form action={confirmAnswer}>
316
+ <input type="hidden" name="suite" value={suite.key} />
317
+ <input type="hidden" name="label" value={answer.id} />
318
+ <Submit>Confirm this answer</Submit>
319
+ </form>
320
+ )}
321
+
322
+ <CaseMenu
323
+ suite={suite.key}
324
+ caseId={c.id}
325
+ retire={retireCase}
326
+ startEditing={!answer}
327
+ editLabel={
328
+ suite.fields && suite.labelFromForm
329
+ ? answer
330
+ ? "Edit answer"
331
+ : "Write the answer"
332
+ : null
333
+ }
334
+ edit={
335
+ suite.fields && (
336
+ <form
337
+ key={answer?.updatedAt.toISOString() ?? "none"}
338
+ action={editAnswer}
339
+ className="space-y-4"
340
+ >
341
+ <input type="hidden" name="suite" value={suite.key} />
342
+ <input type="hidden" name="case" value={c.id} />
343
+ <Fields
344
+ fields={suite.fields.label}
345
+ values={answer?.value}
346
+ prefix={`${c.id}-`}
347
+ />
348
+ <p className="readable text-xs text-muted">
349
+ Saves a new confirmed answer and keeps the old one in the
350
+ history. Tests are rescored for free.
351
+ </p>
352
+ <Submit>Save answer</Submit>
353
+ </form>
354
+ )
355
+ }
356
+ />
357
+ </div>
358
+ </details>
359
+ );
360
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { EllipsisVertical } from "lucide-react";
5
+
6
+ /**
7
+ * A case's menu, in the top right of the open case: edit its answer (the
8
+ * form opens below the menu's row) and retire it. Closes on a click
9
+ * outside or Escape.
10
+ */
11
+ export function CaseMenu({
12
+ suite,
13
+ caseId,
14
+ editLabel,
15
+ edit,
16
+ retire,
17
+ startEditing = false,
18
+ }: {
19
+ suite: string;
20
+ caseId: string;
21
+ /** "Edit answer" or "Write the answer"; null when there's no form. */
22
+ editLabel: string | null;
23
+ /** The answer form, shown when someone picks editLabel. */
24
+ edit: React.ReactNode;
25
+ /** Your server action that retires a case (gets suite and case). */
26
+ retire: (form: FormData) => Promise<void>;
27
+ /** Open the form from the start, for a case with no answer yet. */
28
+ startEditing?: boolean;
29
+ }) {
30
+ const [open, setOpen] = useState(false);
31
+ const [editing, setEditing] = useState(startEditing);
32
+ const ref = useRef<HTMLDivElement>(null);
33
+
34
+ useEffect(() => {
35
+ if (!open) return;
36
+ const close = (e: MouseEvent | KeyboardEvent) => {
37
+ if (
38
+ e instanceof KeyboardEvent
39
+ ? e.key === "Escape"
40
+ : !ref.current?.contains(e.target as Node)
41
+ )
42
+ setOpen(false);
43
+ };
44
+ document.addEventListener("mousedown", close);
45
+ document.addEventListener("keydown", close);
46
+ return () => {
47
+ document.removeEventListener("mousedown", close);
48
+ document.removeEventListener("keydown", close);
49
+ };
50
+ }, [open]);
51
+
52
+ const item =
53
+ "block w-full rounded px-3 py-1.5 text-left text-sm hover:bg-tint";
54
+
55
+ return (
56
+ <>
57
+ <div ref={ref} className="absolute right-3 top-0 z-10">
58
+ <button
59
+ type="button"
60
+ aria-label="Case actions"
61
+ title="Case actions"
62
+ aria-haspopup="menu"
63
+ aria-expanded={open}
64
+ onClick={() => {
65
+ setOpen(!open);
66
+ }}
67
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-tint hover:text-ink"
68
+ >
69
+ <EllipsisVertical aria-hidden className="h-4 w-4" />
70
+ </button>
71
+ {open && (
72
+ <div
73
+ role="menu"
74
+ className="absolute right-0 mt-1 w-44 rounded-md border border-line bg-card p-1 shadow-lg"
75
+ >
76
+ {editLabel && (
77
+ <button
78
+ type="button"
79
+ role="menuitem"
80
+ className={item}
81
+ onClick={() => {
82
+ setEditing(true);
83
+ setOpen(false);
84
+ }}
85
+ >
86
+ {editLabel}
87
+ </button>
88
+ )}
89
+ <form action={retire}>
90
+ <input type="hidden" name="suite" value={suite} />
91
+ <input type="hidden" name="case" value={caseId} />
92
+ <button
93
+ type="submit"
94
+ role="menuitem"
95
+ className={`${item} text-bad`}
96
+ >
97
+ Retire case
98
+ </button>
99
+ </form>
100
+ </div>
101
+ )}
102
+ </div>
103
+ {editing && (
104
+ <div className="space-y-3 rounded-md bg-tint p-3">
105
+ <div className="flex items-center justify-between gap-2">
106
+ <h4 className="text-sm font-medium">{editLabel}</h4>
107
+ <button
108
+ type="button"
109
+ onClick={() => {
110
+ setEditing(false);
111
+ }}
112
+ className="text-sm text-muted hover:text-ink"
113
+ >
114
+ Cancel
115
+ </button>
116
+ </div>
117
+ {edit}
118
+ </div>
119
+ )}
120
+ </>
121
+ );
122
+ }
@@ -0,0 +1,110 @@
1
+ import { asText, fieldValue, type FormField } from "@crowandraven/evalkeeper";
2
+ import { InfoTip } from "./Tip";
3
+
4
+ /**
5
+ * A suite's form fields, rendered from its field specs: label, input, and
6
+ * help in one wrapper. `values` fills them from a stored input or answer.
7
+ */
8
+ export function Fields({
9
+ fields,
10
+ values,
11
+ prefix = "",
12
+ }: {
13
+ fields: FormField[];
14
+ values?: unknown;
15
+ /** Keeps ids unique when a page has several forms. */
16
+ prefix?: string;
17
+ }) {
18
+ return (
19
+ <>
20
+ {fields.map((f) => {
21
+ const id = `${prefix}${f.name}`;
22
+ const value = values === undefined ? "" : fieldValue(f, values);
23
+ const box =
24
+ "w-full rounded-md border border-line bg-card px-3 py-2 text-sm focus:border-accent focus:outline-none";
25
+ return (
26
+ <div key={f.name} className="space-y-1">
27
+ <label htmlFor={id} className="block text-sm font-medium">
28
+ {f.label}
29
+ </label>
30
+ {f.kind === "select" ? (
31
+ <select
32
+ id={id}
33
+ name={f.name}
34
+ defaultValue={value || f.options?.[0]}
35
+ className={box}
36
+ >
37
+ {f.options?.map((o) => (
38
+ <option key={o} value={o}>
39
+ {o.replace(/_/g, " ")}
40
+ </option>
41
+ ))}
42
+ </select>
43
+ ) : f.kind === "text" ? (
44
+ <input
45
+ id={id}
46
+ name={f.name}
47
+ defaultValue={value}
48
+ required={f.required}
49
+ className={box}
50
+ />
51
+ ) : (
52
+ <textarea
53
+ id={id}
54
+ name={f.name}
55
+ defaultValue={value}
56
+ required={f.required}
57
+ rows={f.kind === "lines" ? 4 : 5}
58
+ className={box}
59
+ />
60
+ )}
61
+ <p className="readable text-xs text-muted">{f.help}</p>
62
+ </div>
63
+ );
64
+ })}
65
+ </>
66
+ );
67
+ }
68
+
69
+ /** A stored input or answer, read with the suite's field labels. */
70
+ export function FieldValues({
71
+ fields,
72
+ values,
73
+ prefix = "",
74
+ }: {
75
+ fields: FormField[];
76
+ values: unknown;
77
+ /** Keeps tooltip ids unique when a page shows several. */
78
+ prefix?: string;
79
+ }) {
80
+ return (
81
+ <dl className="space-y-2">
82
+ {fields.map((f) => {
83
+ const v = (values as Record<string, unknown> | null)?.[f.name];
84
+ return (
85
+ <div key={f.name}>
86
+ <dt className="text-xs text-muted">
87
+ {f.label}
88
+ <InfoTip text={f.help} id={`${prefix}${f.name}-read`} />
89
+ </dt>
90
+ <dd className="readable whitespace-pre-wrap">
91
+ {Array.isArray(v) ? (
92
+ v.length ? (
93
+ <ul className="list-disc pl-5">
94
+ {v.map((x, i) => (
95
+ <li key={i}>{asText(x)}</li>
96
+ ))}
97
+ </ul>
98
+ ) : (
99
+ <span className="text-muted">None</span>
100
+ )
101
+ ) : (
102
+ (asText(v) || "—").replace(/_/g, " ")
103
+ )}
104
+ </dd>
105
+ </div>
106
+ );
107
+ })}
108
+ </dl>
109
+ );
110
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useEffect } from "react";
5
+
6
+ /** Refreshes the page every few seconds while something runs. */
7
+ export function RefreshWhile({
8
+ active,
9
+ ms = 2000,
10
+ }: {
11
+ active: boolean;
12
+ ms?: number;
13
+ }) {
14
+ const router = useRouter();
15
+ useEffect(() => {
16
+ if (!active) return;
17
+ const t = setInterval(() => {
18
+ router.refresh();
19
+ }, ms);
20
+ return () => {
21
+ clearInterval(t);
22
+ };
23
+ }, [active, ms, router]);
24
+ return null;
25
+ }