@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41

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 (118) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +16 -8
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +189 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +90 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/relevance-decisions.ts +6 -2
  106. package/lib/review-client.ts +71 -5
  107. package/lib/review-drafts.ts +277 -0
  108. package/lib/review-fixture.ts +56 -2
  109. package/lib/review-requests.ts +89 -24
  110. package/lib/site-brand.ts +2 -0
  111. package/lib/source-frameable.ts +77 -0
  112. package/lib/source-icon.ts +104 -0
  113. package/lib/staff-groups.ts +23 -0
  114. package/lib/staging-gate.ts +2 -2
  115. package/lib/story-reporting-trail.ts +33 -2
  116. package/package.json +45 -2
  117. package/routes.manifest.json +23 -1
  118. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
6
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
7
7
  import { CheckIcon, UserIcon } from "lucide-react";
8
8
  import { Md5 } from "@smithy/md5-js";
9
+ import { hasEditorRights } from "../lib/staff-groups";
9
10
  import {
10
11
  createConsoleMessage,
11
12
  createConsoleThread,
@@ -154,7 +155,7 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
154
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
156
  const internalPathname = browserPathToInternalNewsroomPath(pathname);
156
157
  const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
157
- const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
158
+ const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
158
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
159
160
  const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
160
161
 
@@ -2,21 +2,20 @@
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import amplifyOutputs from "papyrus-amplify-outputs";
5
- import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { ProposalsView } from "./proposals-view";
7
7
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
8
8
  import {
9
- EDIT_PROPOSAL,
10
9
  LIST_PROPOSALS,
11
- TRANSITION_PROPOSAL,
10
+ filterProposals,
12
11
  proposalReceiptMessage,
13
12
  type ProposalFilter,
14
13
  type ProposalMove,
15
- type ProposalReceipt,
16
14
  type ProposalRow,
17
15
  } from "../lib/proposals-admin";
16
+ import { performProposalMove } from "../lib/proposals-client";
18
17
 
19
- type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | ProposalReceipt | null>; errors?: { message?: string }[] };
18
+ type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | null>; errors?: { message?: string }[] };
20
19
  const MAXIMUM_PAGES = 20;
21
20
 
22
21
  async function loadProposals(): Promise<ProposalRow[]> {
@@ -34,24 +33,6 @@ async function loadProposals(): Promise<ProposalRow[]> {
34
33
  return rows;
35
34
  }
36
35
 
37
- async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
38
- const client = generateClient({ authMode: "userPool" });
39
- const result = (await client.graphql({ query, variables })) as GraphResult;
40
- if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
41
- return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
42
- }
43
-
44
- /** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
45
- async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
46
- if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
47
- let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
48
- for (const state of move.path ?? []) {
49
- receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
50
- if (receipt.status !== "ok") break;
51
- }
52
- return receipt;
53
- }
54
-
55
36
  /** Core to every site: it does not depend on the reader-steering plugin. */
56
37
  export function ProposalsPanel() {
57
38
  const [rows, setRows] = useState<ProposalRow[]>([]);
@@ -59,6 +40,8 @@ export function ProposalsPanel() {
59
40
  const [status, setStatus] = useState("Loading...");
60
41
  const [busy, setBusy] = useState(false);
61
42
  const [reason, setReason] = useState("");
43
+ const [focusId, setFocusId] = useState<string | null>(null);
44
+ const focused = useRef(false);
62
45
  const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
63
46
 
64
47
  const refresh = useCallback(async (quiet = false) => {
@@ -77,12 +60,25 @@ export function ProposalsPanel() {
77
60
  void refresh();
78
61
  }, [refresh]);
79
62
 
63
+ // Sent here by a link such as `?proposal=<id>`: show the view that holds it and scroll to it.
64
+ useEffect(() => {
65
+ setFocusId(new URLSearchParams(window.location.search).get("proposal"));
66
+ }, []);
67
+ useEffect(() => {
68
+ if (!focusId || focused.current) return;
69
+ const row = rows.find((candidate) => candidate.id === focusId);
70
+ if (!row) return;
71
+ focused.current = true;
72
+ setFilter(filterProposals([row], "open").length ? "open" : "decided");
73
+ window.requestAnimationFrame(() => document.getElementById(`proposal-${focusId}`)?.scrollIntoView({ block: "start" }));
74
+ }, [focusId, rows]);
75
+
80
76
  const onMove = useCallback(
81
77
  async (proposalId: string, move: ProposalMove) => {
82
78
  setBusy(true);
83
79
  setStatus("Working...");
84
80
  try {
85
- const receipt = await perform(proposalId, move, reason.trim());
81
+ const receipt = await performProposalMove(proposalId, move, reason.trim());
86
82
  setStatus(proposalReceiptMessage(receipt));
87
83
  if (receipt.status === "ok") setReason("");
88
84
  } catch (error) {
@@ -105,6 +101,7 @@ export function ProposalsPanel() {
105
101
  onReason={setReason}
106
102
  onMove={(proposalId, move) => void onMove(proposalId, move)}
107
103
  onRefresh={() => void refresh()}
104
+ focusId={focusId}
108
105
  />
109
106
  );
110
107
  }
@@ -32,6 +32,8 @@ export type ProposalsViewProps = {
32
32
  onReason: (reason: string) => void;
33
33
  onMove: (proposalId: string, move: ProposalMove) => void;
34
34
  onRefresh: () => void;
35
+ /** A proposal the editor was sent to (from the Notification Center): its draft opens and it is marked. */
36
+ focusId?: string | null;
35
37
  };
36
38
 
37
39
  export function ProposalsView(props: ProposalsViewProps) {
@@ -62,7 +64,7 @@ export function ProposalsView(props: ProposalsViewProps) {
62
64
  ) : (
63
65
  <ul>
64
66
  {shown.map((row) => (
65
- <li key={row.id} data-proposal-row={row.id}>
67
+ <li key={row.id} id={`proposal-${row.id}`} data-proposal-row={row.id} data-proposal-focused={props.focusId === row.id || undefined}>
66
68
  <article aria-label={row.title}>
67
69
  <p>
68
70
  <strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
@@ -70,7 +72,7 @@ export function ProposalsView(props: ProposalsViewProps) {
70
72
  </p>
71
73
  {row.summary ? <p>{row.summary}</p> : null}
72
74
  {row.body ? (
73
- <details>
75
+ <details open={props.focusId === row.id || undefined}>
74
76
  <summary>Read the draft</summary>
75
77
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
76
78
  </details>
@@ -36,7 +36,9 @@ export function ReaderAuthControl({
36
36
  if (!nextPath) return;
37
37
  if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
38
38
  if (storedPath) window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
39
- window.history.replaceState(window.history.state, "", nextPath);
39
+ if (nextPath === "/") return;
40
+ // Sign-in comes back to "/"; the page asked for (a deep link such as /references/<id>) may be another route, so load it.
41
+ window.location.replace(nextPath);
40
42
  }, [postAuthPath]);
41
43
 
42
44
  const refreshAuthState = useCallback(async () => {
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import {
7
+ captureStatus, countWords, curationCardOf, grobidRows, primaryTextAttachment, structuredRows,
8
+ type CaptureAssessment as Assessment, type CaptureStatus, type CurationCard,
9
+ } from "../lib/reference-capture";
10
+ import { loadModelPayloadsForOwner, loadStoragePathText, uploadModelPayloadForOwner } from "./news-desk-taxonomy-client";
11
+
12
+ export type CaptureAssessment = Assessment;
13
+
14
+ export type CaptureView = {
15
+ status: CaptureStatus;
16
+ /** The stored text, once read. Null while loading, or when there is none. */
17
+ text: string | null;
18
+ loading: boolean;
19
+ error: string | null;
20
+ summary: string | null;
21
+ /** True when an editor replaced the text and the summary awaits the next curation run. */
22
+ summaryStale: boolean;
23
+ rows: Array<{ label: string; value: string }>;
24
+ /** Saves an editor's pasted or corrected text and their verdict on the capture (the `correctReferenceCapture` action). */
25
+ correct?: (input: { text?: string; assessment: CaptureAssessment; note?: string }) => Promise<void>;
26
+ /** True when the current text was read from a PDF the editor uploaded. */
27
+ fromPdf: boolean;
28
+ /** Stores a PDF the editor downloaded as an attachment of the Reference, then has the server read its text (`correctReferenceCapture` with `pdfAttachmentId`). */
29
+ uploadPdf?: (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => Promise<void>;
30
+ };
31
+
32
+ /** The server reads PDFs up to this size (ideas.source_text.MAXIMUM_PDF_BYTES); say so before a slow upload that would be refused. */
33
+ export const MAXIMUM_PDF_UPLOAD_BYTES = 8_000_000;
34
+ export const PDF_UPLOAD_ROLE = "curation-upload-pdf";
35
+
36
+ function previewText(attachment: ReferenceAttachmentRecord): string | null {
37
+ // Local preview only: a fixture can carry the captured text inline instead of in storage.
38
+ if (!isLocalPreviewEnabled()) return null;
39
+ const raw = attachment.metadata;
40
+ try {
41
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
42
+ const text = (parsed as { previewText?: unknown } | null)?.previewText;
43
+ return typeof text === "string" ? text : null;
44
+ } catch { return null; }
45
+ }
46
+
47
+ type Payloads = { key: string; text: string | null; grobid: unknown; summary: string | null; loaded: boolean };
48
+
49
+ /**
50
+ * Reads what the newsroom stored for one Reference. The curation card comes first (status, word count, signals, summary, GROBID fields,
51
+ * and the `curation-text` body its `current` points at); the older `extracted_text` attachment is the fallback.
52
+ */
53
+ export type CorrectCaptureAction = (input: { referenceId: string; text?: string; pdfAttachmentId?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
54
+
55
+ export function useReferenceCapture(reference: ReferenceRecord, attachments: readonly ReferenceAttachmentRecord[], teaserLine: number, correctAction?: CorrectCaptureAction): CaptureView {
56
+ const card: CurationCard | null = useMemo(() => curationCardOf(attachments), [attachments]);
57
+ const primary = useMemo(() => primaryTextAttachment(attachments), [attachments]);
58
+ const textSortKey = card?.current?.textSortKey ?? card?.capture.textSortKey ?? null;
59
+ const grobidSortKey = card?.current?.grobidSortKey ?? card?.grobid?.fieldsSortKey ?? null;
60
+ const [payloads, setPayloads] = useState<Payloads>({ key: "", text: null, grobid: null, summary: null, loaded: false });
61
+ const [fallback, setFallback] = useState<{ key: string; text: string | null; error: string | null; loading: boolean }>({ key: "", text: null, error: null, loading: false });
62
+ const [corrected, setCorrected] = useState<{ key: string; text: string; assessment: CaptureAssessment; fromPdf?: boolean } | null>(null);
63
+ const referenceKey = `${reference.id}:${textSortKey ?? ""}:${grobidSortKey ?? ""}`;
64
+
65
+ // The card's bodies and the Reference's metadata.json, in one read of the Reference's ModelAttachments.
66
+ useEffect(() => {
67
+ if (isLocalPreviewEnabled()) { setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); return; }
68
+ let active = true;
69
+ void loadModelPayloadsForOwner("reference", reference.id, ["metadata"])
70
+ .then((rows) => {
71
+ if (!active) return;
72
+ const bySortKey = (key: string | null) => (key ? rows.find((row) => row.attachment.sortKey === key) : undefined);
73
+ const metadata = rows.find((row) => row.attachment.sortKey === "metadata")?.json as { summary?: unknown } | null | undefined;
74
+ setPayloads({
75
+ key: referenceKey,
76
+ text: bySortKey(textSortKey)?.text ?? null,
77
+ grobid: bySortKey(grobidSortKey)?.json ?? null,
78
+ summary: typeof metadata?.summary === "string" && metadata.summary.trim() ? metadata.summary.trim() : null,
79
+ loaded: true,
80
+ });
81
+ })
82
+ .catch(() => { if (active) setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); });
83
+ return () => { active = false; };
84
+ }, [reference.id, referenceKey, textSortKey, grobidSortKey]);
85
+
86
+ // Fallback: the older extracted_text attachment, when the card has no text of its own.
87
+ const fallbackKey = primary ? `${primary.id}:${primary.storagePath ?? ""}` : "";
88
+ const useFallback = Boolean(primary) && !textSortKey;
89
+ useEffect(() => {
90
+ if (!useFallback || !primary) { setFallback({ key: "", text: null, error: null, loading: false }); return; }
91
+ const inline = previewText(primary);
92
+ if (inline !== null) { setFallback({ key: fallbackKey, text: inline, error: null, loading: false }); return; }
93
+ let active = true;
94
+ setFallback({ key: fallbackKey, text: null, error: null, loading: true });
95
+ void loadStoragePathText(primary.storagePath)
96
+ .then((result) => { if (active) setFallback({ key: fallbackKey, text: result.text, error: result.error, loading: false }); })
97
+ .catch((error) => { if (active) setFallback({ key: fallbackKey, text: null, error: error instanceof Error ? error.message : "Could not read the stored text.", loading: false }); });
98
+ return () => { active = false; };
99
+ }, [primary, fallbackKey, useFallback]);
100
+
101
+ const current = payloads.key === referenceKey ? payloads : null;
102
+ const mine = corrected && corrected.key === reference.id ? corrected : null;
103
+ const fromPdf = mine ? mine.fromPdf === true : card?.current?.textVia === "editor_pdf";
104
+ const text = mine?.text ?? (textSortKey ? current?.text ?? null : fallback.key === fallbackKey ? fallback.text : null);
105
+ const loading = mine ? false : textSortKey ? !(current?.loaded) : useFallback && fallback.key === fallbackKey ? fallback.loading : useFallback;
106
+ const error = textSortKey || mine ? null : fallback.key === fallbackKey ? fallback.error : null;
107
+ const loadedWords = text !== null ? countWords(text) : null;
108
+ const base = captureStatus(attachments, teaserLine, loadedWords);
109
+ const status: CaptureStatus = mine ? { ...base, state: base.state === "none" || base.state === "running" ? (loadedWords !== null && loadedWords < teaserLine ? "thin" : "full") : base.state, words: loadedWords, estimated: false, assessment: mine.assessment, summaryStale: true, ...(loadedWords !== null && loadedWords < teaserLine ? { thinBy: "length" as const } : {}) } : base;
110
+ const summary = card?.summary?.status === "completed" && card.summary.text ? card.summary.text : current?.summary ?? null;
111
+ const rows = card ? grobidRows(current?.grobid, card) : structuredRows(attachments);
112
+
113
+ const correct = useCallback(async (input: { text?: string; assessment: CaptureAssessment; note?: string }) => {
114
+ if (correctAction) await correctAction({ referenceId: reference.id, ...(input.text !== undefined ? { text: input.text } : {}), assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
115
+ setCorrected({ key: reference.id, text: input.text ?? text ?? "", assessment: input.assessment, fromPdf: input.text === undefined && fromPdf });
116
+ }, [reference.id, text, fromPdf, correctAction]);
117
+
118
+ const uploadPdf = useCallback(async (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => {
119
+ if (!correctAction) throw new Error("Uploading is not available here.");
120
+ if (input.file.size > MAXIMUM_PDF_UPLOAD_BYTES) throw new Error(`That PDF is larger than we read (${MAXIMUM_PDF_UPLOAD_BYTES / 1_000_000} MB). Paste the text instead.`);
121
+ onStep?.("uploading");
122
+ const attachment = await uploadModelPayloadForOwner({
123
+ ownerKind: "reference", ownerId: reference.id, ownerLineageId: reference.lineageId ?? reference.id, ownerVersionNumber: reference.versionNumber ?? null,
124
+ ownerVersionKey: `reference#${reference.id}`, role: PDF_UPLOAD_ROLE, sortKey: `${PDF_UPLOAD_ROLE}-${Date.now().toString(36)}`,
125
+ filename: input.file.name || "upload.pdf", mediaType: "application/pdf", content: input.file,
126
+ });
127
+ onStep?.("reading");
128
+ const result = await correctAction({ referenceId: reference.id, pdfAttachmentId: attachment.id, assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
129
+ // The server read the text; fetch the new body it stored so the panel shows it.
130
+ const sortKey = (result as { edits?: Array<{ sortKey?: string; kind?: string }> } | null | undefined)?.edits?.find((edit) => edit.kind === "text")?.sortKey;
131
+ const rows = sortKey ? await loadModelPayloadsForOwner("reference", reference.id, ["metadata"]) : [];
132
+ const stored = rows.find((row) => row.attachment.sortKey === sortKey)?.text;
133
+ if (typeof stored !== "string") throw new Error("The PDF was read, but its text could not be loaded. Reload to see it.");
134
+ setCorrected({ key: reference.id, text: stored, assessment: input.assessment, fromPdf: true });
135
+ }, [reference.id, reference.lineageId, reference.versionNumber, correctAction]);
136
+
137
+ return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, fromPdf, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined, uploadPdf: correctAction ? uploadPdf : undefined };
138
+ }
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import { FileCheckIcon, FileClockIcon, FileUpIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
6
+ import { cn } from "../lib/utils";
7
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
8
+ import type { CaptureView } from "./reference-capture-client";
9
+ import { MarkdownContext } from "./markdown-context";
10
+
11
+ const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
12
+ /** Colour never stands alone: each state has its own icon and words. */
13
+ const TONE: Record<CaptureState, string> = {
14
+ full: "bg-emerald-600/15 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100",
15
+ thin: "bg-amber-500/20 text-amber-900 dark:bg-amber-300/15 dark:text-amber-100",
16
+ none: "bg-red-600/15 text-red-900 dark:bg-red-400/15 dark:text-red-100",
17
+ running: "bg-[var(--feed-control)] text-foreground",
18
+ };
19
+
20
+ /** A small badge for a grid card, so a thin or missing capture shows before the item is opened. It follows the curation status when there is one. */
21
+ export function CaptureBadge({ status }: { status: CaptureStatus }) {
22
+ const Icon = ICON[status.state];
23
+ const text = status.state === "none" ? "No text" : status.state === "running" ? "Capturing" : status.state === "thin" ? (status.words === null || status.thinBy === "signals" ? "Maybe cut off" : `${status.words.toLocaleString("en-US")} words`) : "Full text";
24
+ return (
25
+ <span
26
+ className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.7rem] font-medium", TONE[status.state])}
27
+ data-capture-badge={status.state}
28
+ data-capture-curation={status.curation ?? undefined}
29
+ data-capture-source={status.source}
30
+ title={captureLine(status).label}
31
+ >
32
+ <Icon aria-hidden="true" className="size-3.5" />
33
+ {text}
34
+ </span>
35
+ );
36
+ }
37
+
38
+ const PREVIEW_CHARACTERS = 1600;
39
+ const ASSESSMENTS: Array<{ key: CaptureAssessment; label: string; hint: string }> = [
40
+ { key: "complete", label: "Complete", hint: "This is the whole item." },
41
+ { key: "incomplete", label: "Incomplete", hint: "Something is missing or cut off." },
42
+ { key: "incorrect", label: "Incorrect", hint: "This is not the right text." },
43
+ ];
44
+
45
+ /** The editor's verdict on the machine capture; asked the same way whether they paste text or upload a PDF. */
46
+ function AssessmentChoice({ value, onChange }: { value: CaptureAssessment | null; onChange: (value: CaptureAssessment) => void }) {
47
+ return (
48
+ <div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
49
+ {ASSESSMENTS.map((option) => (
50
+ <button
51
+ aria-pressed={value === option.key}
52
+ className={cn("inline-flex min-h-9 items-center rounded-full px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", value === option.key ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-card)]")}
53
+ data-capture-assessment={option.key}
54
+ key={option.key}
55
+ onClick={() => onChange(option.key)}
56
+ title={option.hint}
57
+ type="button"
58
+ >
59
+ {option.label}
60
+ </button>
61
+ ))}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /** "Upload a PDF": the editor's verdict, then the PDF they downloaded themselves; the server reads its text. */
67
+ function PdfUploadForm({ capture, onDone, onCancel }: { capture: CaptureView; onDone: () => void; onCancel: () => void }) {
68
+ const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
69
+ const [note, setNote] = useState("");
70
+ const [phase, setPhase] = useState<"idle" | "uploading" | "reading" | "error">("idle");
71
+ const [message, setMessage] = useState<string | null>(null);
72
+ const input = useRef<HTMLInputElement>(null);
73
+ const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect, then choose the PDF." : null;
74
+ const busy = phase === "uploading" || phase === "reading";
75
+ async function upload(file: File) {
76
+ if (!assessment || !capture.uploadPdf) return;
77
+ setMessage(null);
78
+ try {
79
+ await capture.uploadPdf({ file, assessment, ...(note.trim() ? { note: note.trim() } : {}) }, (step) => setPhase(step));
80
+ onDone();
81
+ } catch (failure) {
82
+ setPhase("error");
83
+ setMessage(failure instanceof Error ? failure.message : "The PDF could not be uploaded.");
84
+ }
85
+ }
86
+ return (
87
+ <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-upload-form>
88
+ <p className="text-sm font-medium">Upload the PDF you downloaded. Its text replaces the capture, and the capture is kept.</p>
89
+ <AssessmentChoice onChange={setAssessment} value={assessment} />
90
+ <label className="block text-sm font-medium">
91
+ Note (optional)
92
+ <AutoGrowTextarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
93
+ </label>
94
+ <input
95
+ accept="application/pdf"
96
+ aria-label="PDF file"
97
+ className="hidden"
98
+ data-capture-upload-input
99
+ onChange={(event) => {
100
+ const file = event.target.files?.[0];
101
+ event.target.value = "";
102
+ if (file) void upload(file);
103
+ }}
104
+ ref={input}
105
+ type="file"
106
+ />
107
+ {reason && !busy ? <p className="text-xs text-muted-foreground" data-capture-upload-reason>{reason}</p> : null}
108
+ {busy ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" data-capture-upload-status role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />{phase === "uploading" ? "Uploading the PDF…" : "Reading the text from the PDF…"}</p> : null}
109
+ {phase === "error" ? <p className="text-xs text-destructive" data-capture-upload-error role="alert">{message}</p> : null}
110
+ <div className="flex gap-2">
111
+ <button
112
+ aria-disabled={reason || busy ? true : undefined}
113
+ className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--primary)] px-4 text-sm font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-2 focus-visible:ring-ring", (reason || busy) && "opacity-60")}
114
+ data-capture-upload-choose
115
+ onClick={() => { if (!reason && !busy) input.current?.click(); }}
116
+ type="button"
117
+ >
118
+ <FileUpIcon aria-hidden="true" className="size-4" />Choose the PDF
119
+ </button>
120
+ <button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-card)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" disabled={busy} onClick={onCancel} type="button">Cancel</button>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /** "Paste or correct the text": an editor's own text and verdict on the capture, saved with `correctReferenceCapture`. */
127
+ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: () => void }) {
128
+ const [text, setText] = useState(capture.text ?? "");
129
+ const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
130
+ const [note, setNote] = useState("");
131
+ const [phase, setPhase] = useState<"idle" | "saving" | "error">("idle");
132
+ const [message, setMessage] = useState<string | null>(null);
133
+ useEffect(() => { setText((current) => current || capture.text || ""); }, [capture.text]);
134
+ const changedText = text.trim() !== (capture.text ?? "").trim() && text.trim().length > 0;
135
+ const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect to save." : null;
136
+ async function save() {
137
+ if (!assessment || !capture.correct) return;
138
+ setPhase("saving");
139
+ setMessage(null);
140
+ try {
141
+ await capture.correct({ ...(changedText ? { text: text.trim() } : {}), assessment, ...(note.trim() ? { note: note.trim() } : {}) });
142
+ onDone();
143
+ } catch (failure) {
144
+ setPhase("error");
145
+ setMessage(failure instanceof Error ? failure.message : "The correction could not be saved.");
146
+ }
147
+ }
148
+ return (
149
+ <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
150
+ <label className="block text-sm font-medium">
151
+ The text
152
+ <AutoGrowTextarea
153
+ aria-label="Captured text"
154
+ className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
155
+ data-capture-correction-text
156
+ onChange={(event) => setText(event.target.value)}
157
+ placeholder="Paste the whole article here, or fix what was captured."
158
+ value={text}
159
+ />
160
+ </label>
161
+ <AssessmentChoice onChange={setAssessment} value={assessment} />
162
+ <label className="block text-sm font-medium">
163
+ Note (optional)
164
+ <AutoGrowTextarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
165
+ </label>
166
+ {reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
167
+ {phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
168
+ <div className="flex gap-2">
169
+ <button
170
+ aria-disabled={reason ? true : undefined}
171
+ className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--primary)] px-4 text-sm font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-2 focus-visible:ring-ring", reason && "opacity-60")}
172
+ data-capture-correction-save
173
+ onClick={() => { if (!reason && phase !== "saving") void save(); }}
174
+ type="button"
175
+ >
176
+ {phase === "saving" ? <><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Saving…</> : changedText ? "Save the text and my verdict" : "Save my verdict"}
177
+ </button>
178
+ <button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-card)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onDone} type="button">Cancel</button>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
185
+ export function ReferenceCapturePanel({ capture, showSummary = true, startEditing = false }: {
186
+ capture: CaptureView;
187
+ /** False where the summary is already shown above the rating, with the title. */
188
+ showSummary?: boolean;
189
+ startEditing?: boolean;
190
+ }) {
191
+ const [expanded, setExpanded] = useState(false);
192
+ const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
193
+ const [uploading, setUploading] = useState(false);
194
+ const [savedNote, setSavedNote] = useState(false);
195
+ const line = captureLine(capture.status);
196
+ const Icon = ICON[line.state];
197
+ const text = capture.text ?? "";
198
+ const long = text.length > PREVIEW_CHARACTERS;
199
+ return (
200
+ <section aria-label="Our captured record" className="space-y-3" data-capture-panel={line.state}>
201
+ <h3 className="text-sm font-semibold text-foreground">Our captured record</h3>
202
+ <div className={cn("flex items-start gap-3 rounded-2xl p-3", TONE[line.state])} data-capture-status={line.state} data-capture-source={capture.status.source}>
203
+ <Icon aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
204
+ <div className="min-w-0">
205
+ <p className="text-sm font-semibold" data-capture-label>{line.label}</p>
206
+ {line.state === "full" ? null : <p className="text-xs opacity-90" data-capture-detail>{line.detail}</p>}
207
+ {capture.status.words !== null ? <p className="text-xs font-medium" data-capture-words>{capture.status.estimated ? "about " : ""}{capture.status.words.toLocaleString("en-US")} words</p> : null}
208
+ {line.state === "full" && capture.status.flags?.length ? <p className="text-xs opacity-90" data-capture-flags>{capture.status.flags.join(" ")}</p> : null}
209
+ {capture.status.curation === "degraded" ? <p className="text-xs opacity-90" data-capture-curation-note>Curation finished all but one stage (usually the summary).</p> : null}
210
+ {capture.status.assessment ? <p className="text-xs font-medium" data-capture-assessment-note>An editor marked this capture {capture.status.assessment}.</p> : null}
211
+ </div>
212
+ </div>
213
+ {capture.loading ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Reading the stored text…</p> : null}
214
+ {capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
215
+ {/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
216
+ {text && capture.fromPdf ? <p className="text-xs font-medium" data-capture-from-pdf>Text from a PDF you uploaded</p> : null}
217
+ {text && !editing ? (
218
+ <div className="space-y-2">
219
+ {/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
220
+ {/* Captured pages are stored as Markdown: render it (as React elements, never HTML) so headings and lists read as such. */}
221
+ <div className="rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
222
+ <MarkdownContext className="reference-capture-markdown" text={expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`} />
223
+ </div>
224
+ {long ? (
225
+ <button className="rounded-full bg-[var(--feed-control)] px-3 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-toggle onClick={() => setExpanded((value) => !value)} type="button">
226
+ {expanded ? "Show less" : "Show all of the captured text"}
227
+ </button>
228
+ ) : null}
229
+ </div>
230
+ ) : null}
231
+ {showSummary && capture.summary ? (
232
+ <div className="space-y-1" data-capture-summary>
233
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
234
+ <p className="text-sm">{capture.summary}</p>
235
+ </div>
236
+ ) : capture.summaryStale ? <p className="text-xs text-muted-foreground" data-capture-summary-stale>The summary will be rewritten from the new text on the next curation run.</p> : null}
237
+ {capture.rows.length ? (
238
+ <dl className="grid gap-2 rounded-2xl bg-[var(--feed-control)] p-3 text-sm" data-capture-structured>
239
+ {capture.rows.map((row) => (
240
+ <div key={`${row.label}:${row.value}`}>
241
+ <dt className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.label}</dt>
242
+ <dd className="mt-0.5">{row.value}</dd>
243
+ </div>
244
+ ))}
245
+ </dl>
246
+ ) : null}
247
+ {capture.correct || capture.uploadPdf ? (
248
+ editing && capture.correct ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} />
249
+ : uploading && capture.uploadPdf ? <PdfUploadForm capture={capture} onCancel={() => setUploading(false)} onDone={() => { setUploading(false); setSavedNote(true); }} />
250
+ : (
251
+ <div className="flex flex-wrap items-center gap-2">
252
+ {capture.correct ? (
253
+ <button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-correct onClick={() => { setSavedNote(false); setEditing(true); }} type="button">
254
+ Paste or correct the text
255
+ </button>
256
+ ) : null}
257
+ {capture.uploadPdf ? (
258
+ <button className="inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-upload onClick={() => { setSavedNote(false); setUploading(true); }} type="button">
259
+ <FileUpIcon aria-hidden="true" className="size-4" />Upload a PDF
260
+ </button>
261
+ ) : null}
262
+ {savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
263
+ </div>
264
+ )
265
+ ) : null}
266
+ </section>
267
+ );
268
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ConversationThread } from "./conversation-thread";
5
+ import { useConversation } from "./use-conversation";
6
+
7
+ /**
8
+ * The Messages about one Reference, under its review card. Shows nothing where no conversation store is provided.
9
+ * `onCount` tells the feed how many Messages there are, so its card count follows what is written here.
10
+ */
11
+ export function ReferenceConversation({ disabledReason = null, lineageId, onCount, referenceId, title }: {
12
+ disabledReason?: string | null;
13
+ lineageId: string;
14
+ onCount?: (lineageId: string, count: number) => void;
15
+ /** The Reference version's id, when it differs from the lineage id. */
16
+ referenceId?: string;
17
+ title?: string;
18
+ }) {
19
+ const conversation = useConversation({ kind: "reference", lineageId, title, ...(referenceId ? { id: referenceId } : {}) });
20
+ const [now, setNow] = useState(() => Date.now());
21
+ useEffect(() => {
22
+ const timer = window.setInterval(() => setNow(Date.now()), 60000);
23
+ return () => window.clearInterval(timer);
24
+ }, []);
25
+ const settled = conversation.messages.filter((message) => !message.pending).length;
26
+ useEffect(() => {
27
+ if (conversation.state === "ready") onCount?.(lineageId, settled);
28
+ }, [conversation.state, lineageId, onCount, settled]);
29
+ if (!conversation.available) return null;
30
+ return (
31
+ <div data-reference-conversation={lineageId}>
32
+ <ConversationThread
33
+ disabledReason={disabledReason}
34
+ error={conversation.error}
35
+ messages={conversation.messages}
36
+ now={now}
37
+ onPost={conversation.post}
38
+ onRetry={conversation.retry}
39
+ state={conversation.state}
40
+ />
41
+ </div>
42
+ );
43
+ }