@@ -0,0 +1,61 @@
|
||||
// What a document's page says about the document above its lines: its status, how many lines wait and
|
||||
// how many are approved, and which pages the extraction left out. The page keeps the document live, so
|
||||
// these are built wherever it renders -- on the server for the first paint and in the browser on each
|
||||
// change -- from messages that keep their placeholders (`@conusai-kit/i18n/format`: the language's plural
|
||||
// rules, and no catalogue).
|
||||
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import type { PAGE_CLASSES } from "../contract/constants.js"
|
||||
import type { DocumentView } from "../contract/procedures.js"
|
||||
import type { DocumentListLabels } from "./document-list.js"
|
||||
|
||||
export interface DocumentHeaderLabels {
|
||||
/** "Status", the word before the status. */
|
||||
readonly status: string
|
||||
readonly statuses: DocumentListLabels["statuses"]
|
||||
readonly failures: DocumentListLabels["failures"]
|
||||
/** `{count, plural, ...}`, with its placeholder left in. */
|
||||
readonly pendingCount: string
|
||||
/** `{count, plural, ...}`, with its placeholder left in. */
|
||||
readonly approvedCount: string
|
||||
/** What a document nobody has read yet says. */
|
||||
readonly waiting: string
|
||||
readonly skippedTitle: string
|
||||
/** `Page {page}: {kind}. ...`, with both placeholders left in. */
|
||||
readonly skippedPage: string
|
||||
readonly pageClasses: Readonly<Record<(typeof PAGE_CLASSES)[number], string>>
|
||||
}
|
||||
|
||||
export interface DocumentHeader {
|
||||
readonly status: string
|
||||
readonly counts: string
|
||||
readonly waiting?: string
|
||||
readonly skipped?: {
|
||||
readonly title: string
|
||||
readonly pages: readonly { readonly page: number; readonly text: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
export function documentHeader(document: DocumentView, labels: DocumentHeaderLabels, locale: string): DocumentHeader {
|
||||
const failure = document.failureCode ? ` ${labels.failures[document.failureCode]}` : ""
|
||||
return {
|
||||
status: `${labels.status}: ${labels.statuses[document.status]}${failure}`,
|
||||
counts: `${formatMessage(labels.pendingCount, { count: document.pending }, locale)}. ${formatMessage(labels.approvedCount, { count: document.approved }, locale)}.`,
|
||||
...(document.status === "queued" || document.status === "processing" ? { waiting: labels.waiting } : {}),
|
||||
...(document.skippedPages.length > 0
|
||||
? {
|
||||
skipped: {
|
||||
title: labels.skippedTitle,
|
||||
pages: document.skippedPages.map((skipped) => ({
|
||||
page: skipped.page,
|
||||
text: formatMessage(
|
||||
labels.skippedPage,
|
||||
{ page: skipped.page, kind: labels.pageClasses[skipped.pageClass] },
|
||||
locale,
|
||||
),
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// The project's documents, one row each, with where they are in the pipeline and how many lines wait
|
||||
// for a person. Part of a page's view (F10): it holds no state, and every string is passed in.
|
||||
|
||||
import { type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import type { DocumentView } from "../contract/index.js"
|
||||
|
||||
export interface DocumentListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly file: string
|
||||
readonly status: string
|
||||
readonly pages: string
|
||||
readonly pending: string
|
||||
readonly approved: string
|
||||
readonly uploaded: string
|
||||
readonly actions: string
|
||||
/** `Download {filename}`: a same-origin, project-scoped file stream. */
|
||||
readonly download: string
|
||||
/** `Review {filename}`: the link's accessible name, so a list of links is not a list of "Review". */
|
||||
readonly review: string
|
||||
/** The status and failure texts, by code. */
|
||||
readonly statuses: Readonly<Record<DocumentView["status"], string>>
|
||||
readonly failures: Readonly<Record<NonNullable<DocumentView["failureCode"]>, string>>
|
||||
}
|
||||
|
||||
export function DocumentList({
|
||||
documents,
|
||||
hrefFor,
|
||||
downloadHrefFor,
|
||||
labels,
|
||||
formatDate,
|
||||
}: {
|
||||
documents: readonly DocumentView[]
|
||||
hrefFor: (fileId: string) => string
|
||||
downloadHrefFor: (fileId: string) => string
|
||||
labels: DocumentListLabels
|
||||
formatDate: (iso: string) => string
|
||||
}): ReactElement {
|
||||
const columns: ResourceColumn<DocumentView>[] = [
|
||||
{ id: "filename", header: labels.file, wrap: true, minWidth: "10rem" },
|
||||
{
|
||||
id: "status",
|
||||
header: labels.status,
|
||||
wrap: true,
|
||||
cell: (document) =>
|
||||
`${labels.statuses[document.status]}${document.failureCode ? ` ${labels.failures[document.failureCode]}` : ""}`,
|
||||
},
|
||||
{ id: "pageCount", header: labels.pages, align: "end", cell: (document) => document.pageCount ?? "" },
|
||||
{ id: "pending", header: labels.pending, align: "end" },
|
||||
{ id: "approved", header: labels.approved, align: "end" },
|
||||
{ id: "uploadedAt", header: labels.uploaded, cell: (document) => formatDate(document.uploadedAt) },
|
||||
{
|
||||
id: "actions",
|
||||
header: labels.actions,
|
||||
cell: (document) => (
|
||||
<a href={downloadHrefFor(document.fileId)}>{labels.download.replace("{filename}", document.filename)}</a>
|
||||
),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ResourceList
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
data={documents}
|
||||
rowId={(document) => document.fileId}
|
||||
rowHref={(document) => hrefFor(document.fileId)}
|
||||
rowLinkLabel={(document) => labels.review.replace("{filename}", document.filename)}
|
||||
rowLabel={(document) => document.filename}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Download the approved lines as an Excel workbook. The procedure answers with the file as base64
|
||||
// (a procedure's answer is JSON on every transport); the browser turns it into a download. With
|
||||
// nothing approved the button is disabled and says why, in text a screen reader reads with it.
|
||||
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { type ReactElement, useEffect, useId, useState } from "react"
|
||||
import type { ProjectSlugs } from "./slugs.js"
|
||||
|
||||
export interface ExportLabels {
|
||||
readonly export: string
|
||||
readonly hint: string
|
||||
readonly nothing: string
|
||||
/** Said after the workbook was handed to the browser. */
|
||||
readonly exported: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function ExportButton({
|
||||
slugs,
|
||||
fileId,
|
||||
approved,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
fileId: string
|
||||
/** How many lines are approved: with none, there is nothing to export. */
|
||||
approved: number
|
||||
labels: ExportLabels
|
||||
}): ReactElement {
|
||||
const createExport = useWrite<
|
||||
ProjectSlugs & { fileId: string },
|
||||
{ filename: string; contentType: string; body: string }
|
||||
>("documents.export.create")
|
||||
const hintId = useId()
|
||||
const [interactive, setInteractive] = useState(false)
|
||||
useEffect(() => setInteractive(true), [])
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setMessage(undefined)
|
||||
try {
|
||||
const made = await createExport.mutate({ ...slugs, fileId })
|
||||
const bytes = Uint8Array.from(atob(made.body), (char) => char.charCodeAt(0))
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: made.contentType }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = made.filename
|
||||
// Attached while it is clicked, and the object URL kept for a while after: a browser starts the
|
||||
// download after the click returns, and a URL revoked first is a download that never starts.
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
setMessage({ ok: true, text: labels.exported })
|
||||
} catch (error) {
|
||||
setMessage({ ok: false, text: labels.failed.replace("{detail}", describeError(error)) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={!interactive || busy || approved === 0}
|
||||
aria-describedby={hintId}
|
||||
onClick={run}
|
||||
>
|
||||
{labels.export}
|
||||
</Button>
|
||||
<p id={hintId}>{approved === 0 ? labels.nothing : labels.hint}</p>
|
||||
<p role={message?.ok === false ? "alert" : "status"}>{message?.text ?? ""}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The documents module's pages and components. Server-safe ones (`DocumentList`) and client ones
|
||||
// (the rest, each marked "use client") are exported side by side; a page of apps/web composes them
|
||||
// inside the app's data provider (`DataProvider` of `@conusai-kit/ui/data`).
|
||||
|
||||
export { type DocumentHeader, type DocumentHeaderLabels, documentHeader } from "./document-header.js"
|
||||
export { DocumentList, type DocumentListLabels } from "./document-list.js"
|
||||
export { ExportButton, type ExportLabels } from "./export-button.js"
|
||||
export { exportLabels, headerLabels, listLabels, reviewLabels, type Translate, uploadLabels } from "./labels.js"
|
||||
export { LineReview, type ReviewLabels } from "./line-review.js"
|
||||
export { type DocumentWithLines, useLiveDocument, useLiveDocuments } from "./live-documents.js"
|
||||
export type { ProjectSlugs } from "./slugs.js"
|
||||
export { UploadForm, type UploadLabels } from "./upload-form.js"
|
||||
@@ -0,0 +1,116 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks -- the page itself holds no string.
|
||||
//
|
||||
// `t` takes a key relative to the module (`status.queued`); the page binds it to
|
||||
// `i18n.message("documents." + key)`. The two messages that are not this module's own -- the app's
|
||||
// `app.failed`, and the kit's `ui.aiDisclosure` and `ui.page` -- are passed in already looked up.
|
||||
|
||||
import { DOCUMENT_STATUSES, FAILURE_CODES, LINE_STATUSES, PAGE_CLASSES } from "../contract/constants.js"
|
||||
import type { DocumentHeaderLabels } from "./document-header.js"
|
||||
import type { DocumentListLabels } from "./document-list.js"
|
||||
import type { ExportLabels } from "./export-button.js"
|
||||
import type { ReviewLabels } from "./line-review.js"
|
||||
import type { UploadLabels } from "./upload-form.js"
|
||||
|
||||
/** `{ a: f("a"), b: f("b") }` for a list of keys, typed by the keys. */
|
||||
function byKey<K extends string>(keys: readonly K[], f: (key: K) => string): Record<K, string> {
|
||||
return Object.fromEntries(keys.map((key) => [key, f(key)])) as Record<K, string>
|
||||
}
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The statuses a document shows: the stored ones, and `queued` for an upload nobody has read yet. */
|
||||
const LISTED_STATUSES = ["queued", ...DOCUMENT_STATUSES] as const
|
||||
|
||||
export function uploadLabels(t: Translate): UploadLabels {
|
||||
return {
|
||||
heading: t("upload.heading"),
|
||||
label: t("upload.label"),
|
||||
hint: t("upload.hint"),
|
||||
submit: t("upload.submit"),
|
||||
busy: t("upload.busy"),
|
||||
progress: t("upload.progress"),
|
||||
}
|
||||
}
|
||||
|
||||
export function listLabels(t: Translate): DocumentListLabels {
|
||||
return {
|
||||
caption: t("title"),
|
||||
empty: t("list.empty"),
|
||||
file: t("list.file"),
|
||||
status: t("list.status"),
|
||||
pages: t("list.pages"),
|
||||
pending: t("list.pending"),
|
||||
approved: t("list.approved"),
|
||||
uploaded: t("list.uploaded"),
|
||||
actions: t("list.actions"),
|
||||
// The `{filename}` placeholder is the component's to fill, so it is passed through untouched.
|
||||
review: t("list.review", { filename: "{filename}" }),
|
||||
download: t("list.download", { filename: "{filename}" }),
|
||||
statuses: byKey(LISTED_STATUSES, (status) => t(`status.${status}`)),
|
||||
failures: byKey(FAILURE_CODES, (code) => t(`failure.${code}`)),
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewLabels(
|
||||
t: Translate,
|
||||
shared: { failed: string; aiDisclosure: string; pageWord: string },
|
||||
): ReviewLabels {
|
||||
// A message with a `{label}` or `{page}` or `{model}` placeholder is filled per line by the
|
||||
// component; it is looked up with the placeholder kept in place.
|
||||
const keep = (key: string, name: string) => t(key, { [name]: `{${name}}` })
|
||||
return {
|
||||
caption: t("review.tableLabel"),
|
||||
empty: t("review.noLines"),
|
||||
label: t("review.label"),
|
||||
value: t("review.value"),
|
||||
page: t("review.page"),
|
||||
source: t("review.source"),
|
||||
confidence: t("review.confidence"),
|
||||
decision: t("review.decision"),
|
||||
actions: t("review.actions"),
|
||||
approve: keep("review.approve", "label"),
|
||||
reject: keep("review.reject", "label"),
|
||||
edit: keep("review.edit", "label"),
|
||||
save: t("review.save"),
|
||||
cancel: t("review.cancel"),
|
||||
approveAll: t("review.approveAll"),
|
||||
needsReview: t("review.needsReview"),
|
||||
scannedQuote: t("review.scannedQuote"),
|
||||
block: keep("review.block", "id"),
|
||||
edited: t("review.edited"),
|
||||
sourceOnPage: keep("review.sourceOnPage", "page"),
|
||||
proposedBy: keep("review.proposedBy", "model"),
|
||||
aiDisclosure: shared.aiDisclosure,
|
||||
pageWord: shared.pageWord,
|
||||
states: byKey(LINE_STATUSES, (status) => t(`review.state.${status}`)),
|
||||
failed: shared.failed,
|
||||
}
|
||||
}
|
||||
|
||||
/** The page's header: the counts and the skipped page keep their placeholders, for the live document. */
|
||||
export function headerLabels(t: Translate): DocumentHeaderLabels {
|
||||
const list = listLabels(t)
|
||||
return {
|
||||
status: list.status,
|
||||
statuses: list.statuses,
|
||||
failures: list.failures,
|
||||
pendingCount: t("review.pendingCount"),
|
||||
approvedCount: t("review.approvedCount"),
|
||||
waiting: t("review.waiting"),
|
||||
skippedTitle: t("review.skippedTitle"),
|
||||
skippedPage: t("review.skippedPage"),
|
||||
pageClasses: byKey(PAGE_CLASSES, (pageClass) => t(`review.pageClass.${pageClass}`)),
|
||||
}
|
||||
}
|
||||
|
||||
export function exportLabels(t: Translate, failed: string): ExportLabels {
|
||||
return {
|
||||
export: t("review.export"),
|
||||
hint: t("review.exportHint"),
|
||||
nothing: t("review.exportNothing"),
|
||||
exported: t("review.exported"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@conusai-kit/ui/primitives"
|
||||
|
||||
// The review table: every extracted line with where it was read, and a person's decision on each --
|
||||
// approve, edit or reject -- and a bulk approve for what is still pending. A decision is a call to
|
||||
// the proposals module's own procedures (or `documents.line.edit`), so the permission, the
|
||||
// transaction and the audit event are the kernel's, and this component adds none of its own.
|
||||
//
|
||||
// What is machine-proposed says so: the disclosure badge (EU AI Act Art. 50) heads the table, and
|
||||
// each row names the model and extractor version that proposed it.
|
||||
|
||||
import { AiDisclosureBadge, EvidencePanel } from "@conusai-kit/ui"
|
||||
import { describeError, useDataClient } from "@conusai-kit/ui/data"
|
||||
import { type FormEvent, type ReactElement, useEffect, useState } from "react"
|
||||
import type { LineView } from "../contract/index.js"
|
||||
import { REVIEW_PROCEDURES } from "../contract/review.js"
|
||||
import type { ProjectSlugs } from "./slugs.js"
|
||||
|
||||
export interface ReviewLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly page: string
|
||||
readonly source: string
|
||||
readonly confidence: string
|
||||
readonly decision: string
|
||||
readonly actions: string
|
||||
/** Contain `{label}`, replaced with the line's label: the button's accessible name. */
|
||||
readonly approve: string
|
||||
readonly reject: string
|
||||
readonly edit: string
|
||||
readonly save: string
|
||||
readonly cancel: string
|
||||
readonly approveAll: string
|
||||
/** Shown beside a line whose quote was not found on the page it cites. */
|
||||
readonly needsReview: string
|
||||
/** Shown instead of `needsReview` for a quote the model read off the image of a scanned page. */
|
||||
readonly scannedQuote: string
|
||||
/** Contains `{id}`, replaced with the reader's block id of the quote. */
|
||||
readonly block: string
|
||||
readonly edited: string
|
||||
readonly sourceOnPage: string
|
||||
readonly proposedBy: string
|
||||
readonly aiDisclosure: string
|
||||
/** The word before a page number ("Page"): the evidence panel prints it beside the number. */
|
||||
readonly pageWord: string
|
||||
readonly states: Readonly<Record<LineView["status"], string>>
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** `approveMany` takes at most the proposals policy's `maxBatch`; its default is 100. */
|
||||
const BATCH = 100
|
||||
|
||||
export function LineReview({
|
||||
slugs,
|
||||
lines,
|
||||
labels,
|
||||
locale,
|
||||
fileId,
|
||||
onDecided,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
lines: readonly LineView[]
|
||||
labels: ReviewLabels
|
||||
/** 0.97 as "97 %": the locale's own number format. */
|
||||
locale: string
|
||||
fileId: string
|
||||
/** Reads the live document again: its lines, its counts and what the export holds. */
|
||||
onDecided: () => Promise<void>
|
||||
}): ReactElement {
|
||||
const client = useDataClient()
|
||||
// Keep the actions disabled until hydration attaches handlers, so an early click cannot disappear.
|
||||
const [interactive, setInteractive] = useState(false)
|
||||
useEffect(() => setInteractive(true), [])
|
||||
const [editing, setEditing] = useState<string | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState<string | undefined>(undefined)
|
||||
const pending = lines.filter((line) => line.status === "pending")
|
||||
// A line whose quote nobody could find on the page is never approved in bulk: a person looks at it.
|
||||
const approvable = pending.filter((line) => line.verification === "verified")
|
||||
|
||||
/** Runs one decision, then reads the document again: the lines, the counts and the export follow it. */
|
||||
const run = async (work: () => Promise<unknown>): Promise<void> => {
|
||||
setBusy(true)
|
||||
setProblem(undefined)
|
||||
try {
|
||||
await work()
|
||||
await onDecided()
|
||||
setEditing(undefined)
|
||||
setBusy(false)
|
||||
} catch (error) {
|
||||
setProblem(labels.failed.replace("{detail}", describeError(error)))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = (line: LineView) =>
|
||||
run(() =>
|
||||
client.call(REVIEW_PROCEDURES.approve, {
|
||||
...slugs,
|
||||
proposalId: line.lineId,
|
||||
expectedVersion: line.expectedVersion,
|
||||
}),
|
||||
)
|
||||
const reject = (line: LineView) =>
|
||||
run(() => client.call(REVIEW_PROCEDURES.reject, { ...slugs, proposalId: line.lineId }))
|
||||
const approveAll = () =>
|
||||
run(async () => {
|
||||
for (let at = 0; at < approvable.length; at += BATCH) {
|
||||
await client.call(REVIEW_PROCEDURES.approveMany, {
|
||||
...slugs,
|
||||
proposalIds: approvable.slice(at, at + BATCH).map((line) => line.lineId),
|
||||
expectations: Object.fromEntries(
|
||||
approvable.slice(at, at + BATCH).map((line) => [line.lineId, { expectedVersion: line.expectedVersion }]),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
const save = (line: LineView, event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
return run(() =>
|
||||
client.call("documents.line.edit", {
|
||||
...slugs,
|
||||
lineId: line.lineId,
|
||||
expectedVersion: line.expectedVersion,
|
||||
label: String(form.get("label") ?? ""),
|
||||
value: String(form.get("value") ?? ""),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <p>{labels.empty}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-busy={busy}>
|
||||
<p>
|
||||
<AiDisclosureBadge label={labels.aiDisclosure} />
|
||||
</p>
|
||||
{approvable.length > 0 ? (
|
||||
<Button type="button" disabled={!interactive || busy} onClick={approveAll}>
|
||||
{labels.approveAll}
|
||||
</Button>
|
||||
) : null}
|
||||
{problem ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
{problem}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.label}</TableHead>
|
||||
<TableHead scope="col">{labels.value}</TableHead>
|
||||
<TableHead scope="col">{labels.page}</TableHead>
|
||||
<TableHead scope="col">{labels.source}</TableHead>
|
||||
<TableHead scope="col">{labels.confidence}</TableHead>
|
||||
<TableHead scope="col">{labels.decision}</TableHead>
|
||||
<TableHead scope="col">{labels.actions}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.lineId}>
|
||||
{editing === line.lineId ? (
|
||||
<TableCell colSpan={7}>
|
||||
<form onSubmit={(event) => save(line, event)}>
|
||||
<Field>
|
||||
<Label htmlFor={`label-${line.lineId}`}>{labels.label}</Label>
|
||||
<Input
|
||||
id={`label-${line.lineId}`}
|
||||
name="label"
|
||||
defaultValue={line.label}
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label htmlFor={`value-${line.lineId}`}>{labels.value}</Label>
|
||||
<Input
|
||||
id={`value-${line.lineId}`}
|
||||
name="value"
|
||||
defaultValue={line.value}
|
||||
required
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={!interactive || busy}>
|
||||
{labels.save}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => setEditing(undefined)}
|
||||
>
|
||||
{labels.cancel}
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
) : (
|
||||
<>
|
||||
<TableHead scope="row">{line.label}</TableHead>
|
||||
<TableCell>
|
||||
{line.value}
|
||||
{line.edited ? ` (${labels.edited})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{line.page}</TableCell>
|
||||
<TableCell>
|
||||
<EvidencePanel
|
||||
evidence={{
|
||||
fileId,
|
||||
page: line.page,
|
||||
quote: line.quote,
|
||||
...(line.box ? { box: line.box } : {}),
|
||||
}}
|
||||
label={labels.sourceOnPage.replace("{page}", String(line.page))}
|
||||
pageLabel={labels.pageWord}
|
||||
/>
|
||||
<small>{labels.proposedBy.replace("{model}", `${line.model} ${line.promptVersion}`)}</small>
|
||||
{line.blockId ? <small>{` ${labels.block.replace("{id}", line.blockId)}`}</small> : null}
|
||||
</TableCell>
|
||||
<TableCell>{new Intl.NumberFormat(locale, { style: "percent" }).format(line.confidence)}</TableCell>
|
||||
<TableCell>
|
||||
{labels.states[line.status]}
|
||||
{line.verification === "needs_review" ? (
|
||||
<strong>{` ${line.note === "scanned_quote" ? labels.scannedQuote : labels.needsReview}`}</strong>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{line.status === "pending" ? (
|
||||
<>
|
||||
<Button type="button" disabled={!interactive || busy} onClick={() => approve(line)}>
|
||||
{labels.approve.replace("{label}", line.label)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => setEditing(line.lineId)}
|
||||
>
|
||||
{labels.edit.replace("{label}", line.label)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => reject(line)}
|
||||
>
|
||||
{labels.reject.replace("{label}", line.label)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
// The project's documents and one document, live (umbrella TS6.4 and TS6.6): the page's loader reads them
|
||||
// and its view hands them over as the seed, so the page arrives complete; these keep them current through
|
||||
// the organization's live stream. The job's change of a document -- taken, read, failed -- is a change of
|
||||
// `documents.document` (contract/collections.ts), and each read again answers the files, the rows and the
|
||||
// line counts together, under the list's permission. An upload or a decision on a line is this person's
|
||||
// own write: the component that made it reads again (`refetch`) once it has answered, so nothing reloads.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useRead } from "@conusai-kit/ui/data"
|
||||
import { DOCUMENT_COLLECTION, type DocumentView, type LineView } from "../contract/index.js"
|
||||
|
||||
const liveness = (slugs: ProjectSlugs) => ({
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entities: [DOCUMENT_COLLECTION.entity],
|
||||
})
|
||||
|
||||
export function useLiveDocuments(
|
||||
slugs: ProjectSlugs,
|
||||
seed: readonly DocumentView[],
|
||||
): { documents: readonly DocumentView[]; refetch(): Promise<void> } {
|
||||
const { data, refetch } = useRead<{ documents: readonly DocumentView[] }>(
|
||||
DOCUMENT_COLLECTION.list,
|
||||
{ ...slugs },
|
||||
{ live: liveness(slugs), initial: { documents: seed } },
|
||||
)
|
||||
return { documents: data?.documents ?? seed, refetch }
|
||||
}
|
||||
|
||||
export interface DocumentWithLines {
|
||||
readonly document: DocumentView
|
||||
readonly lines: readonly LineView[]
|
||||
}
|
||||
|
||||
export function useLiveDocument(
|
||||
slugs: ProjectSlugs,
|
||||
fileId: string,
|
||||
seed: DocumentWithLines,
|
||||
): DocumentWithLines & { refetch(): Promise<void> } {
|
||||
const { data, refetch } = useRead<DocumentWithLines>(
|
||||
"documents.document.get",
|
||||
{ ...slugs, fileId },
|
||||
{ live: liveness(slugs), initial: seed },
|
||||
)
|
||||
return { ...(data ?? seed), refetch }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** The three slugs every project-scoped procedure's input carries (a URL's own segments). */
|
||||
export interface ProjectSlugs {
|
||||
readonly org: string
|
||||
readonly workspace: string
|
||||
readonly project: string
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
// Upload one document through the files module. A PDF of 10 to 100 MB cannot be a JSON document, so
|
||||
// the browser sends it the way the kit's `uploadFile` does: it asks `files.file.beginUpload` how the
|
||||
// file is to be cut, sends each piece as the body of `files.file.putPart` (so the bar below moves
|
||||
// as the bytes leave), and ends with `files.file.completeUpload`. The answer comes back before any
|
||||
// OCR has run, because reading the document is the worker's job (AGENTS.md: a request enqueues and
|
||||
// answers). Once it has, the page's live list reads again (`onUploaded`), so the upload shows as queued at
|
||||
// once; the job's progress then reaches the list through the live stream.
|
||||
|
||||
import { TransferProgress } from "@conusai-kit/ui"
|
||||
import { useUpload } from "@conusai-kit/ui/data"
|
||||
import { FormLayout, PageSection, TextField, useInSidePanel } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { ProjectSlugs } from "./slugs.js"
|
||||
|
||||
export interface UploadLabels {
|
||||
readonly heading: string
|
||||
readonly label: string
|
||||
readonly hint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
/** `Uploading {filename}: {percent}%`, with both placeholders still in it. */
|
||||
readonly progress: string
|
||||
}
|
||||
|
||||
export function UploadForm({
|
||||
slugs,
|
||||
accept,
|
||||
labels,
|
||||
onUploaded,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
/** The content types the module reads, for the file picker; the server is what enforces them. */
|
||||
accept: readonly string[]
|
||||
labels: UploadLabels
|
||||
/** Reads the page's live list again: an upload is not a change of a document row until the job takes it. */
|
||||
onUploaded: () => Promise<void>
|
||||
}): ReactElement {
|
||||
const inPanel = useInSidePanel()
|
||||
const upload = useUpload()
|
||||
const [progress, setProgress] = useState<{ filename: string; sent: number; total: number } | undefined>(undefined)
|
||||
|
||||
const submit = async (_: unknown, data: FormData): Promise<void> => {
|
||||
const file = data.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setProgress({ filename: file.name, sent: 0, total: file.size })
|
||||
try {
|
||||
await upload(slugs, file, { onProgress: ({ sent, total }) => setProgress({ filename: file.name, sent, total }) })
|
||||
await onUploaded()
|
||||
} finally {
|
||||
setProgress(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={inPanel ? undefined : labels.heading} variant={inPanel ? "plain" : "card"}>
|
||||
<FormLayout submitLabel={labels.submit} busyLabel={labels.busy} onSubmit={submit}>
|
||||
<TextField
|
||||
name="file"
|
||||
type="file"
|
||||
label={labels.label}
|
||||
description={labels.hint}
|
||||
accept={accept.join(",")}
|
||||
required
|
||||
/>
|
||||
{progress ? (
|
||||
<TransferProgress
|
||||
label={labels.progress
|
||||
.replace("{filename}", progress.filename)
|
||||
.replace("{percent}", String(Math.floor((100 * progress.sent) / progress.total)))}
|
||||
value={progress.sent}
|
||||
max={progress.total}
|
||||
/>
|
||||
) : null}
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user