@anthusai/papyrus 5.57.0 → 5.59.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.
- package/amplify/data/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +28 -3
- package/amplify/data/resource.ts +28 -3
- package/amplify/data/schema.js +34 -5
- package/amplify/data/schema.ts +34 -5
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +154 -25
- package/components/conversations-memory-source.ts +83 -0
- package/components/conversations-network-source.ts +52 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +36 -0
- package/components/conversations-view.tsx +680 -0
- package/components/decision-control.tsx +307 -0
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +18 -17
- package/components/needs-me-feed.tsx +16 -32
- package/components/news-desk-taxonomy-client.ts +216 -62
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +34 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +9 -8
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +129 -127
- package/components/reference-review-card.tsx +9 -135
- package/components/references-page-loading.tsx +12 -6
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +44 -786
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +14 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +73 -2
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +12 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import type
|
|
4
|
+
import { NO_VIEWER, type ConversationAnchor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
|
|
5
5
|
import { useConversationStore } from "./conversation-context";
|
|
6
6
|
|
|
7
7
|
export type ConversationState = "loading" | "ready" | "error";
|
|
@@ -64,7 +64,41 @@ export function useConversation(anchor: ConversationAnchor) {
|
|
|
64
64
|
}
|
|
65
65
|
}, [store]);
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
const [viewer, setViewer] = useState<ConversationViewer>(NO_VIEWER);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!store) return;
|
|
70
|
+
let active = true;
|
|
71
|
+
store.viewer().then((loaded) => { if (active) setViewer(loaded); }).catch(() => undefined);
|
|
72
|
+
return () => { active = false; };
|
|
73
|
+
}, [store]);
|
|
74
|
+
|
|
75
|
+
// Display names come from the authors' profiles: one request for every author not yet asked about.
|
|
76
|
+
const [names, setNames] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
77
|
+
const asked = useRef(new Set<string>());
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!store) return;
|
|
80
|
+
const missing = [...new Set(messages.map((message) => message.authorUserProfileId).filter((id): id is string => Boolean(id) && !asked.current.has(id as string)))];
|
|
81
|
+
if (!missing.length) return;
|
|
82
|
+
missing.forEach((id) => asked.current.add(id));
|
|
83
|
+
let active = true;
|
|
84
|
+
store.authors(missing).then((loaded) => { if (active && loaded.size) setNames((current) => new Map([...current, ...loaded])); }).catch(() => undefined);
|
|
85
|
+
return () => { active = false; };
|
|
86
|
+
}, [messages, store]);
|
|
87
|
+
|
|
88
|
+
const edit = useCallback(async (messageId: string, body: string) => {
|
|
89
|
+
if (!store) throw new Error("Messages are not available here.");
|
|
90
|
+
const saved = await store.edit(messageId, body);
|
|
91
|
+
setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
|
|
92
|
+
}, [store]);
|
|
93
|
+
const remove = useCallback(async (messageId: string) => {
|
|
94
|
+
if (!store) throw new Error("Messages are not available here.");
|
|
95
|
+
const target = messages.find((message) => message.id === messageId);
|
|
96
|
+
const moderate = viewer.isAdmin && target?.authorSub !== viewer.sub;
|
|
97
|
+
const saved = await store.remove(messageId, moderate);
|
|
98
|
+
setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
|
|
99
|
+
}, [store, messages, viewer]);
|
|
100
|
+
|
|
101
|
+
return { available: Boolean(store), messages, state, error, retry, post, edit, remove, viewer, names };
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
/** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { DEFAULT_FEED_SHOW, DEFAULT_FEED_SORT, type FeedShow, type FeedSort } from "../lib/reference-feed-ranking";
|
|
5
|
+
|
|
6
|
+
const SORT_KEY = "papyrus:reference-feed:sort";
|
|
7
|
+
const SHOW_KEY = "papyrus:reference-feed:show";
|
|
8
|
+
|
|
9
|
+
function read<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
|
|
10
|
+
try {
|
|
11
|
+
const stored = window.localStorage.getItem(key);
|
|
12
|
+
return allowed.includes(stored as T) ? (stored as T) : fallback;
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function write(key: string, value: string) {
|
|
19
|
+
try { window.localStorage.setItem(key, value); } catch { /* The choice still works for this visit without storage. */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The viewer's Sort and Show choices, remembered in this browser. The page renders with the defaults, then applies what was saved. */
|
|
23
|
+
export function useFeedPreferences() {
|
|
24
|
+
const [sort, setSortState] = useState<FeedSort>(DEFAULT_FEED_SORT);
|
|
25
|
+
const [show, setShowState] = useState<FeedShow>(DEFAULT_FEED_SHOW);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setSortState(read<FeedSort>(SORT_KEY, ["best", "newest"], DEFAULT_FEED_SORT));
|
|
28
|
+
setShowState(read<FeedShow>(SHOW_KEY, ["unrated", "all"], DEFAULT_FEED_SHOW));
|
|
29
|
+
}, []);
|
|
30
|
+
const setSort = useCallback((next: FeedSort) => { setSortState(next); write(SORT_KEY, next); }, []);
|
|
31
|
+
const setShow = useCallback((next: FeedShow) => { setShowState(next); write(SHOW_KEY, next); }, []);
|
|
32
|
+
return { sort, setSort, show, setShow };
|
|
33
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
5
|
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
-
import type { RatingFeedback } from "./
|
|
6
|
+
import type { RatingFeedback } from "./decision-control";
|
|
7
7
|
|
|
8
8
|
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
9
|
export const NOTE_AUTOSAVE_MS = 1200;
|
|
@@ -38,8 +38,8 @@ export type RatingController = {
|
|
|
38
38
|
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
39
|
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
40
|
* reason, then vote -> the vote carries the reason
|
|
41
|
-
* A Reject needs a reason
|
|
42
|
-
* kept in the store until it is saved and is never dropped by a failed save.
|
|
41
|
+
* A Reject needs a reason (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
|
|
42
|
+
* waits as `pending` until one arrives; a typed reason is kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
44
|
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
@@ -110,13 +110,17 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
110
110
|
|
|
111
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
112
|
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
-
const { note, reasonCode
|
|
114
|
-
// A typed reason is a reason
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const { note, reasonCode } = values(id);
|
|
114
|
+
// A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
|
|
115
|
+
// code the editor did not pick: the backend reads the words for the code they name, and files words
|
|
116
|
+
// that name none under "other". Only a Reject with neither words nor a code waits for one.
|
|
117
|
+
if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
117
118
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
118
119
|
inFlight.current.add(id);
|
|
119
120
|
dirty.current.delete(id);
|
|
121
|
+
// The note goes out with this save, so its own pending autosave has nothing left to send.
|
|
122
|
+
dirty.current.delete(`note:${id}`);
|
|
123
|
+
clearTimeout(timers.current[id]);
|
|
120
124
|
const started = itemsRef.current[id] ?? EMPTY;
|
|
121
125
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
122
126
|
try {
|
|
@@ -280,8 +280,19 @@ export type ReferenceRecord = {
|
|
|
280
280
|
curationStatusUpdatedAt?: string | null;
|
|
281
281
|
curationStatusUpdatedBy?: string | null;
|
|
282
282
|
curationStatusReason?: string | null;
|
|
283
|
+
/** The latest vote's details; a Reference keeps only its latest vote (earlier votes live in the Cyclotron's feedback). */
|
|
284
|
+
curationReasonCode?: string | null;
|
|
285
|
+
curationActorKind?: string | null;
|
|
286
|
+
curationSectionId?: string | null;
|
|
287
|
+
curationShareable?: boolean | null;
|
|
288
|
+
curationDelegation?: unknown;
|
|
289
|
+
curationDecisionRelationId?: string | null;
|
|
283
290
|
newsroomFeedKey?: string | null;
|
|
284
291
|
reviewedFeedKey?: string | null;
|
|
292
|
+
/** The monitor that delivered this Reference (set at intake; the Reference is the record, no Message). */
|
|
293
|
+
discoveredByMonitorId?: string | null;
|
|
294
|
+
/** What the monitor's source said about it (a Hub model's structured fields, say). */
|
|
295
|
+
discoveryDetails?: unknown;
|
|
285
296
|
metadata?: unknown;
|
|
286
297
|
updatedAt?: string | null;
|
|
287
298
|
};
|
|
@@ -412,6 +423,9 @@ export type MessageRecord = {
|
|
|
412
423
|
authorSub?: string | null;
|
|
413
424
|
authorUserProfileId?: string | null;
|
|
414
425
|
authorLabel?: string | null;
|
|
426
|
+
editedAt?: string | null;
|
|
427
|
+
deletedAt?: string | null;
|
|
428
|
+
deletedBySub?: string | null;
|
|
415
429
|
threadId?: string | null;
|
|
416
430
|
parentMessageId?: string | null;
|
|
417
431
|
sequenceNumber?: number | null;
|
|
@@ -1471,18 +1485,6 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1471
1485
|
},
|
|
1472
1486
|
],
|
|
1473
1487
|
messages: [
|
|
1474
|
-
{
|
|
1475
|
-
id: "message-demo-history-002-rationale",
|
|
1476
|
-
messageKind: "ingestion_rationale",
|
|
1477
|
-
messageDomain: "commentary",
|
|
1478
|
-
status: "active",
|
|
1479
|
-
body: "Imported as a useful holdout against modern scaling coverage.",
|
|
1480
|
-
summary: "Holdout ingestion rationale",
|
|
1481
|
-
source: "biblicus-import",
|
|
1482
|
-
importRunId: "knowledge-import-demo-projection",
|
|
1483
|
-
createdAt: importedAt,
|
|
1484
|
-
updatedAt: importedAt,
|
|
1485
|
-
},
|
|
1486
1488
|
{
|
|
1487
1489
|
id: reportingPacketMessageId,
|
|
1488
1490
|
messageKind: "reporting_context_packet",
|
|
@@ -1826,32 +1828,6 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1826
1828
|
importRunId: "knowledge-import-demo-projection",
|
|
1827
1829
|
importedAt,
|
|
1828
1830
|
},
|
|
1829
|
-
{
|
|
1830
|
-
id: "semantic-relation-demo-message-history-002-rationale",
|
|
1831
|
-
relationState: "current",
|
|
1832
|
-
predicate: "comment",
|
|
1833
|
-
relationTypeId: "semantic-relation-type-comment",
|
|
1834
|
-
relationTypeKey: "comment",
|
|
1835
|
-
relationDomain: "commentary",
|
|
1836
|
-
subjectKind: "message",
|
|
1837
|
-
subjectId: "message-demo-history-002-rationale",
|
|
1838
|
-
subjectLineageId: "message-demo-history-002-rationale",
|
|
1839
|
-
subjectVersionNumber: 1,
|
|
1840
|
-
objectKind: "reference",
|
|
1841
|
-
objectId: referenceHistoryTwoId,
|
|
1842
|
-
objectLineageId: referenceHistoryTwoLineageId,
|
|
1843
|
-
objectVersionNumber: 1,
|
|
1844
|
-
subjectStateKey: "message#message-demo-history-002-rationale#current",
|
|
1845
|
-
objectStateKey: `reference#${referenceHistoryTwoLineageId}#current`,
|
|
1846
|
-
objectSubjectStateKey: `reference#${referenceHistoryTwoLineageId}#current#message`,
|
|
1847
|
-
predicateObjectStateKey: `comment#reference#${referenceHistoryTwoLineageId}#current`,
|
|
1848
|
-
subjectVersionKey: "message#message-demo-history-002-rationale",
|
|
1849
|
-
objectVersionKey: `reference#${referenceHistoryTwoId}`,
|
|
1850
|
-
rank: 1,
|
|
1851
|
-
reviewRecommended: false,
|
|
1852
|
-
importRunId: "knowledge-import-demo-projection",
|
|
1853
|
-
importedAt,
|
|
1854
|
-
},
|
|
1855
1831
|
],
|
|
1856
1832
|
loadError: null,
|
|
1857
1833
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import type { Schema } from "../amplify/data/resource";
|
|
5
5
|
import { configureAmplifyClient } from "../components/amplify-client-provider";
|
|
6
|
+
import { postMessageArguments, postMessageMutation } from "./post-message";
|
|
6
7
|
import { loadModelPayloadsForOwner, uploadModelPayloadForOwner } from "../components/news-desk-taxonomy-client";
|
|
7
8
|
|
|
8
9
|
const USER_POOL_AUTH_MODE = "userPool";
|
|
@@ -190,14 +191,6 @@ const UPDATE_CONSOLE_THREAD = `
|
|
|
190
191
|
}
|
|
191
192
|
`;
|
|
192
193
|
|
|
193
|
-
const CREATE_CONSOLE_MESSAGE_LEGACY = `
|
|
194
|
-
mutation CreateMessage($input: CreateMessageInput!) {
|
|
195
|
-
createMessage(input: $input) {
|
|
196
|
-
${CONSOLE_MESSAGE_FIELDS_LEGACY}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
`;
|
|
200
|
-
|
|
201
194
|
export type ConsoleChatThread = {
|
|
202
195
|
id: string;
|
|
203
196
|
threadKind: string;
|
|
@@ -383,79 +376,6 @@ function chooseMessageSelectionSet(schemaFields: Set<string> | null): string {
|
|
|
383
376
|
return selected.join("\n ");
|
|
384
377
|
}
|
|
385
378
|
|
|
386
|
-
function fieldValuesForCreateMessageInput(input: CreateConsoleMessageInput): Record<string, unknown> {
|
|
387
|
-
return {
|
|
388
|
-
id: input.id,
|
|
389
|
-
messageKind: input.messageKind,
|
|
390
|
-
messageDomain: input.messageDomain,
|
|
391
|
-
status: input.status,
|
|
392
|
-
summary: input.summary ?? truncateSummary(input.content ?? null),
|
|
393
|
-
source: input.source,
|
|
394
|
-
importRunId: null,
|
|
395
|
-
authorSub: null,
|
|
396
|
-
authorUserProfileId: input.authorUserProfileId ?? null,
|
|
397
|
-
authorLabel: input.authorLabel,
|
|
398
|
-
threadId: input.threadId ?? null,
|
|
399
|
-
parentMessageId: input.parentMessageId ?? null,
|
|
400
|
-
sequenceNumber: input.sequenceNumber ?? null,
|
|
401
|
-
role: input.role ?? null,
|
|
402
|
-
messageType: input.messageType ?? null,
|
|
403
|
-
semanticLayer: input.semanticLayer,
|
|
404
|
-
searchVisibility: input.searchVisibility,
|
|
405
|
-
responseTarget: input.responseTarget,
|
|
406
|
-
responseStatus: input.responseStatus ?? null,
|
|
407
|
-
responseOwner: input.responseOwner ?? null,
|
|
408
|
-
responseStartedAt: input.responseStartedAt ?? null,
|
|
409
|
-
responseCompletedAt: input.responseCompletedAt ?? null,
|
|
410
|
-
responseError: input.responseError ?? null,
|
|
411
|
-
metadata: input.metadata,
|
|
412
|
-
createdAt: input.createdAt,
|
|
413
|
-
updatedAt: input.updatedAt,
|
|
414
|
-
newsroomFeedKey: input.newsroomFeedKey,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function buildCreateMessageInputCandidates(
|
|
419
|
-
input: CreateConsoleMessageInput,
|
|
420
|
-
availableFields: Set<string>,
|
|
421
|
-
): Record<string, unknown>[] {
|
|
422
|
-
const values = fieldValuesForCreateMessageInput(input);
|
|
423
|
-
const candidates: Record<string, unknown>[] = [];
|
|
424
|
-
const seen = new Set<string>();
|
|
425
|
-
|
|
426
|
-
const pushCandidate = (candidate: Record<string, unknown>) => {
|
|
427
|
-
const cleaned: Record<string, unknown> = {};
|
|
428
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
429
|
-
if (value !== undefined) cleaned[key] = value;
|
|
430
|
-
}
|
|
431
|
-
const signature = JSON.stringify(Object.keys(cleaned).sort().map((key) => [key, cleaned[key]]));
|
|
432
|
-
if (!seen.has(signature)) {
|
|
433
|
-
seen.add(signature);
|
|
434
|
-
candidates.push(cleaned);
|
|
435
|
-
}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
if (availableFields.size) {
|
|
439
|
-
pushCandidate(sanitizeCreateMessageInput(input, availableFields));
|
|
440
|
-
} else {
|
|
441
|
-
pushCandidate(values);
|
|
442
|
-
pushCandidate({
|
|
443
|
-
id: values.id,
|
|
444
|
-
messageKind: values.messageKind,
|
|
445
|
-
messageDomain: values.messageDomain,
|
|
446
|
-
status: values.status,
|
|
447
|
-
summary: values.summary,
|
|
448
|
-
source: values.source,
|
|
449
|
-
authorLabel: values.authorLabel,
|
|
450
|
-
createdAt: values.createdAt,
|
|
451
|
-
updatedAt: values.updatedAt,
|
|
452
|
-
newsroomFeedKey: values.newsroomFeedKey,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
return candidates;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
379
|
async function loadConsoleSchemaSnapshot(): Promise<ConsoleSchemaSnapshot> {
|
|
460
380
|
if (consoleSchemaSnapshot) return consoleSchemaSnapshot;
|
|
461
381
|
if (consoleSchemaSnapshotPromise) return consoleSchemaSnapshotPromise;
|
|
@@ -540,70 +460,6 @@ function buildListMessagesQuery(
|
|
|
540
460
|
`;
|
|
541
461
|
}
|
|
542
462
|
|
|
543
|
-
function sanitizeCreateMessageInput(
|
|
544
|
-
input: CreateConsoleMessageInput,
|
|
545
|
-
availableFields: Set<string>,
|
|
546
|
-
): Record<string, unknown> {
|
|
547
|
-
const fieldValues: Record<string, unknown> = {
|
|
548
|
-
id: input.id,
|
|
549
|
-
messageKind: input.messageKind,
|
|
550
|
-
messageDomain: input.messageDomain,
|
|
551
|
-
status: input.status,
|
|
552
|
-
summary: input.summary ?? truncateSummary(input.content ?? null),
|
|
553
|
-
source: input.source,
|
|
554
|
-
importRunId: null,
|
|
555
|
-
authorSub: null,
|
|
556
|
-
authorUserProfileId: input.authorUserProfileId ?? null,
|
|
557
|
-
authorLabel: input.authorLabel,
|
|
558
|
-
threadId: input.threadId ?? null,
|
|
559
|
-
parentMessageId: input.parentMessageId ?? null,
|
|
560
|
-
sequenceNumber: input.sequenceNumber ?? null,
|
|
561
|
-
role: input.role ?? null,
|
|
562
|
-
messageType: input.messageType ?? null,
|
|
563
|
-
semanticLayer: input.semanticLayer,
|
|
564
|
-
searchVisibility: input.searchVisibility,
|
|
565
|
-
responseTarget: input.responseTarget,
|
|
566
|
-
responseStatus: input.responseStatus ?? null,
|
|
567
|
-
responseOwner: input.responseOwner ?? null,
|
|
568
|
-
responseStartedAt: input.responseStartedAt ?? null,
|
|
569
|
-
responseCompletedAt: input.responseCompletedAt ?? null,
|
|
570
|
-
responseError: input.responseError ?? null,
|
|
571
|
-
metadata: input.metadata,
|
|
572
|
-
createdAt: input.createdAt,
|
|
573
|
-
updatedAt: input.updatedAt,
|
|
574
|
-
newsroomFeedKey: input.newsroomFeedKey,
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
if (!availableFields.size) {
|
|
578
|
-
const conservativeFields = [
|
|
579
|
-
"id",
|
|
580
|
-
"messageKind",
|
|
581
|
-
"messageDomain",
|
|
582
|
-
"status",
|
|
583
|
-
"summary",
|
|
584
|
-
"source",
|
|
585
|
-
"authorLabel",
|
|
586
|
-
"createdAt",
|
|
587
|
-
"updatedAt",
|
|
588
|
-
"newsroomFeedKey",
|
|
589
|
-
] as const;
|
|
590
|
-
const conservative: Record<string, unknown> = {};
|
|
591
|
-
for (const key of conservativeFields) {
|
|
592
|
-
const value = fieldValues[key];
|
|
593
|
-
if (value !== undefined) conservative[key] = value;
|
|
594
|
-
}
|
|
595
|
-
return conservative;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const sanitized: Record<string, unknown> = {};
|
|
599
|
-
for (const [key, value] of Object.entries(fieldValues)) {
|
|
600
|
-
if (!availableFields.has(key)) continue;
|
|
601
|
-
if (value === undefined) continue;
|
|
602
|
-
sanitized[key] = value;
|
|
603
|
-
}
|
|
604
|
-
return sanitized;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
463
|
function truncateSummary(value: string | null): string | null {
|
|
608
464
|
if (!value) return null;
|
|
609
465
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
@@ -733,53 +589,27 @@ export async function createConsoleMessage(input: CreateConsoleMessageInput): Pr
|
|
|
733
589
|
const selectionSets = snapshot.messageFields.size
|
|
734
590
|
? [chooseMessageSelectionSet(snapshot.messageFields)]
|
|
735
591
|
: [CONSOLE_MESSAGE_FIELDS, CONSOLE_MESSAGE_FIELDS_LEGACY];
|
|
736
|
-
|
|
592
|
+
// The backend stamps the author from the signed-in identity (postMessage): the browser names none and sets no timestamps.
|
|
593
|
+
const args = postMessageArguments({ ...input, summary: input.summary ?? truncateSummary(input.content ?? null) } as unknown as Record<string, unknown>);
|
|
737
594
|
let lastError: unknown = null;
|
|
738
595
|
|
|
739
596
|
for (const selectionSet of selectionSets) {
|
|
740
|
-
const createMutation = `
|
|
741
|
-
mutation CreateMessage($input: CreateMessageInput!) {
|
|
742
|
-
createMessage(input: $input) {
|
|
743
|
-
${selectionSet}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
`;
|
|
747
|
-
for (const candidateInput of inputCandidates) {
|
|
748
|
-
try {
|
|
749
|
-
const response = await getGraphQLClient().graphql({
|
|
750
|
-
query: createMutation,
|
|
751
|
-
variables: { input: candidateInput },
|
|
752
|
-
authMode: USER_POOL_AUTH_MODE,
|
|
753
|
-
});
|
|
754
|
-
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
|
|
755
|
-
await maybeUploadConsoleMessageBody(created.id, input.content);
|
|
756
|
-
return input.content?.trim() ? { ...created, content: input.content } : created;
|
|
757
|
-
} catch (error) {
|
|
758
|
-
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
|
|
759
|
-
lastError = error;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
if (lastError) {
|
|
765
597
|
try {
|
|
766
598
|
const response = await getGraphQLClient().graphql({
|
|
767
|
-
query:
|
|
768
|
-
variables:
|
|
769
|
-
input: inputCandidates[inputCandidates.length - 1] ?? {},
|
|
770
|
-
},
|
|
599
|
+
query: postMessageMutation(args, selectionSet),
|
|
600
|
+
variables: args,
|
|
771
601
|
authMode: USER_POOL_AUTH_MODE,
|
|
772
602
|
});
|
|
773
|
-
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "
|
|
603
|
+
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "postMessage"));
|
|
774
604
|
await maybeUploadConsoleMessageBody(created.id, input.content);
|
|
775
605
|
return input.content?.trim() ? { ...created, content: input.content } : created;
|
|
776
606
|
} catch (error) {
|
|
777
|
-
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "
|
|
607
|
+
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "postMessage");
|
|
778
608
|
lastError = error;
|
|
779
609
|
}
|
|
780
610
|
}
|
|
781
611
|
|
|
782
|
-
throw normalizeUnknownError(lastError, "
|
|
612
|
+
throw normalizeUnknownError(lastError, "postMessage");
|
|
783
613
|
}
|
|
784
614
|
|
|
785
615
|
export async function updateConsoleMessage(
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Messages page: which MessageThreads are conversations, which section each belongs to, and how several newest-first streams
|
|
3
|
+
* (one per thread kind) merge into one page of conversations ordered by last activity. Pure helpers, shared by the browser and the tests.
|
|
4
|
+
*/
|
|
5
|
+
import { GENERAL_CONVERSATION_KIND, REFERENCE_CONVERSATION_KIND, STORY_CONVERSATION_KIND } from "./conversation";
|
|
6
|
+
|
|
7
|
+
export type ConversationSection = "all" | "references" | "stories" | "chats" | "other";
|
|
8
|
+
export const CONVERSATION_SECTIONS: ReadonlyArray<{ key: ConversationSection; label: string }> = [
|
|
9
|
+
{ key: "all", label: "All" },
|
|
10
|
+
{ key: "references", label: "References" },
|
|
11
|
+
{ key: "stories", label: "Stories" },
|
|
12
|
+
{ key: "chats", label: "Chats" },
|
|
13
|
+
{ key: "other", label: "Other" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const CHAT_THREAD_KIND = "console";
|
|
17
|
+
export const FORUM_THREAD_KINDS = ["edition_forum", "section_forum"] as const;
|
|
18
|
+
|
|
19
|
+
/** The thread kinds that are conversations, by section. Machine records (provenance, curation, ingestion rationale) are in none of them. */
|
|
20
|
+
const KINDS_BY_SECTION: Record<Exclude<ConversationSection, "all">, ReadonlyArray<string>> = {
|
|
21
|
+
references: [REFERENCE_CONVERSATION_KIND],
|
|
22
|
+
stories: [STORY_CONVERSATION_KIND],
|
|
23
|
+
chats: [CHAT_THREAD_KIND],
|
|
24
|
+
other: [GENERAL_CONVERSATION_KIND, ...FORUM_THREAD_KINDS],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function threadKindsForSection(section: ConversationSection): string[] {
|
|
28
|
+
if (section === "all") return Object.values(KINDS_BY_SECTION).flat();
|
|
29
|
+
return [...KINDS_BY_SECTION[section]];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The section a thread kind is listed under, or null for a kind that is not a conversation. */
|
|
33
|
+
export function sectionOfThreadKind(kind: string): Exclude<ConversationSection, "all"> | null {
|
|
34
|
+
for (const [section, kinds] of Object.entries(KINDS_BY_SECTION)) if (kinds.includes(kind)) return section as Exclude<ConversationSection, "all">;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SECTION_EMPTY_COPY: Record<ConversationSection, string> = {
|
|
39
|
+
all: "No conversations yet. Write a message above, or on a Reference, to start one.",
|
|
40
|
+
references: "No Reference has a message yet. Open a Reference and write down what it made you think.",
|
|
41
|
+
stories: "No Story has a message yet.",
|
|
42
|
+
chats: "No chats yet.",
|
|
43
|
+
other: "No other conversations yet. Write a message above to start one.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type PagedThreads<Thread> = { items: Thread[]; nextToken: string | null };
|
|
47
|
+
|
|
48
|
+
type Streamable = { id: string; updatedAt: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One page at a time out of several newest-first streams, merged by `updatedAt` (the order of every thread index). A stream is only
|
|
52
|
+
* asked for another page when its buffered threads have all been shown, so the order across streams is exact, never approximate.
|
|
53
|
+
*/
|
|
54
|
+
export function createMergedThreadStream<Thread extends Streamable>(
|
|
55
|
+
kinds: ReadonlyArray<string>,
|
|
56
|
+
loadPage: (kind: string, nextToken: string | null) => Promise<PagedThreads<Thread>>,
|
|
57
|
+
) {
|
|
58
|
+
const lanes = kinds.map((kind) => ({ kind, buffer: [] as Thread[], nextToken: null as string | null, started: false, done: false }));
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
|
|
61
|
+
async function fill() {
|
|
62
|
+
await Promise.all(lanes.filter((lane) => !lane.done && lane.buffer.length === 0).map(async (lane) => {
|
|
63
|
+
const page = await loadPage(lane.kind, lane.started ? lane.nextToken : null);
|
|
64
|
+
lane.started = true;
|
|
65
|
+
lane.nextToken = page.nextToken;
|
|
66
|
+
lane.done = !page.nextToken;
|
|
67
|
+
lane.buffer = [...page.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/** The next `count` conversations, newest first, and whether more remain. */
|
|
73
|
+
async next(count: number): Promise<{ items: Thread[]; hasMore: boolean }> {
|
|
74
|
+
const items: Thread[] = [];
|
|
75
|
+
while (items.length < count) {
|
|
76
|
+
await fill();
|
|
77
|
+
const live = lanes.filter((lane) => lane.buffer.length > 0);
|
|
78
|
+
if (!live.length) break;
|
|
79
|
+
// A lane that is not finished and has an empty buffer after a fill returned an empty page with more behind it: ask again.
|
|
80
|
+
if (lanes.some((lane) => !lane.done && lane.buffer.length === 0)) continue;
|
|
81
|
+
const best = live.reduce((winner, lane) => (lane.buffer[0].updatedAt.localeCompare(winner.buffer[0].updatedAt) > 0 ? lane : winner));
|
|
82
|
+
const thread = best.buffer.shift()!;
|
|
83
|
+
if (seen.has(thread.id)) continue;
|
|
84
|
+
seen.add(thread.id);
|
|
85
|
+
items.push(thread);
|
|
86
|
+
}
|
|
87
|
+
return { items, hasMore: lanes.some((lane) => lane.buffer.length > 0 || !lane.done) };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The newest `limit` messages, oldest first, and how many are held back behind "Show all". */
|
|
93
|
+
export function latestMessages<Message>(messages: ReadonlyArray<Message>, limit: number, showAll: boolean): { shown: Message[]; hidden: number } {
|
|
94
|
+
if (showAll || messages.length <= limit) return { shown: [...messages], hidden: 0 };
|
|
95
|
+
return { shown: messages.slice(messages.length - limit), hidden: messages.length - limit };
|
|
96
|
+
}
|
package/lib/conversation.ts
CHANGED
|
@@ -21,10 +21,53 @@ export type ConversationMessage = {
|
|
|
21
21
|
content: string;
|
|
22
22
|
createdAt: string;
|
|
23
23
|
sequenceNumber: number;
|
|
24
|
+
/** The sub of the person who wrote it: only they can edit it. */
|
|
25
|
+
authorSub?: string | null;
|
|
26
|
+
/** Their profile, where their display name lives. */
|
|
27
|
+
authorUserProfileId?: string | null;
|
|
28
|
+
/** When the author last changed the text. */
|
|
29
|
+
editedAt?: string | null;
|
|
30
|
+
/** A deleted Message keeps its place (replies stay under it) but has no text. */
|
|
31
|
+
deleted?: boolean;
|
|
32
|
+
deletedAt?: string | null;
|
|
24
33
|
/** True while an optimistic Message waits for the save. */
|
|
25
34
|
pending?: boolean;
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
/** Who is looking: what they may change is decided from this (an author edits their own; an admin removes anyone's). */
|
|
38
|
+
export type ConversationViewer = { sub: string | null; isAdmin: boolean };
|
|
39
|
+
|
|
40
|
+
export const NO_VIEWER: ConversationViewer = { sub: null, isAdmin: false };
|
|
41
|
+
|
|
42
|
+
/** Only the author edits a Message, and not one that is deleted or still being saved. */
|
|
43
|
+
export function canEditMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
|
|
44
|
+
return Boolean(viewer?.sub && message.authorSub && message.authorSub === viewer.sub && !message.deleted && !message.pending);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The author deletes their own; an admin deletes anyone's. */
|
|
48
|
+
export function canDeleteMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
|
|
49
|
+
if (!viewer || message.deleted || message.pending) return false;
|
|
50
|
+
return viewer.isAdmin || Boolean(viewer.sub && message.authorSub && message.authorSub === viewer.sub);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Only the person who started a conversation, or an admin, renames it. */
|
|
54
|
+
export function canRenameThread(viewer: ConversationViewer | undefined, thread: { createdBySub?: string | null }): boolean {
|
|
55
|
+
if (!viewer) return false;
|
|
56
|
+
return viewer.isAdmin || Boolean(viewer.sub && thread.createdBySub && thread.createdBySub === viewer.sub);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The name to show. The label saved with the Message is the author's display name (it travels with the Message, because other
|
|
61
|
+
* editors cannot read profiles); an older Message saved an email there, so a profile name, when it can be read, comes before an
|
|
62
|
+
* email's first part.
|
|
63
|
+
*/
|
|
64
|
+
export function messageAuthorName(message: Pick<ConversationMessage, "authorLabel" | "authorUserProfileId">, names?: ReadonlyMap<string, string>): string {
|
|
65
|
+
const label = String(message.authorLabel ?? "").trim();
|
|
66
|
+
if (label && !label.includes("@")) return label;
|
|
67
|
+
const profileName = message.authorUserProfileId ? names?.get(message.authorUserProfileId)?.trim() : "";
|
|
68
|
+
return profileName || conversationAuthorName(label);
|
|
69
|
+
}
|
|
70
|
+
|
|
28
71
|
export type ConversationReply = { message: ConversationMessage; answers: string | null };
|
|
29
72
|
export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
|
|
30
73
|
|
|
@@ -75,7 +118,7 @@ export function conversationAuthorName(label: string | null | undefined): string
|
|
|
75
118
|
* Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
|
|
76
119
|
* A reply whose parent is missing is shown as a top-level Message rather than lost.
|
|
77
120
|
*/
|
|
78
|
-
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage
|
|
121
|
+
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>, nameOf: (message: ConversationMessage) => string = (message) => conversationAuthorName(message.authorLabel)): ConversationRoot[] {
|
|
79
122
|
const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
|
|
80
123
|
const byId = new Map(sorted.map((message) => [message.id, message]));
|
|
81
124
|
const rootOf = (message: ConversationMessage): string => {
|
|
@@ -95,7 +138,7 @@ export function buildConversationTree(messages: ReadonlyArray<ConversationMessag
|
|
|
95
138
|
const root = roots.get(rootOf(message));
|
|
96
139
|
if (!root || root.message.id === message.id) continue;
|
|
97
140
|
const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
|
|
98
|
-
root.replies.push({ message, answers: parent && parent.id !== root.message.id ?
|
|
141
|
+
root.replies.push({ message, answers: parent && parent.id !== root.message.id ? nameOf(parent) : null });
|
|
99
142
|
}
|
|
100
143
|
return [...roots.values()];
|
|
101
144
|
}
|
|
@@ -112,6 +155,34 @@ export function conversationAge(createdAt: string, now: number): string {
|
|
|
112
155
|
return `${Math.round(hours / 24)}d`;
|
|
113
156
|
}
|
|
114
157
|
|
|
158
|
+
/** The first line of a message, trimmed to fit a card: what a thread shows as its latest message. */
|
|
159
|
+
export function conversationPreviewLine(content: string | null | undefined, max = 140): string {
|
|
160
|
+
const line = String(content ?? "").split("\n").map((part) => part.trim()).find(Boolean) ?? "";
|
|
161
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function threadMetadataObject(metadata: unknown): Record<string, unknown> {
|
|
165
|
+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) return metadata as Record<string, unknown>;
|
|
166
|
+
if (typeof metadata !== "string" || !metadata.trim()) return {};
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(metadata);
|
|
169
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
170
|
+
} catch {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The thread's metadata (a JSON string) with the author of its latest message recorded, so a list can show who spoke last without reading messages. */
|
|
176
|
+
export function threadMetadataWithLastAuthor(metadata: unknown, authorLabel: string): string {
|
|
177
|
+
return JSON.stringify({ ...threadMetadataObject(metadata), lastAuthorLabel: authorLabel });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Who wrote the latest message, as recorded on the thread; null for a thread that predates the record. */
|
|
181
|
+
export function threadLastAuthorLabel(metadata: unknown): string | null {
|
|
182
|
+
const label = threadMetadataObject(metadata).lastAuthorLabel;
|
|
183
|
+
return typeof label === "string" && label.trim() ? label : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
115
186
|
export function messageCountLabel(count: number): string {
|
|
116
187
|
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
117
188
|
}
|