@anthusai/papyrus 1.0.0-next.64 → 1.0.0-next.65

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.
@@ -815,7 +815,6 @@ const baseDefinitions = {
815
815
  ])
816
816
  .authorization((allow) => [
817
817
  allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
818
- allow.authenticated("identityPool").to(authoringOperations),
819
818
  ]),
820
819
  AssignmentEvent: modelWithoutSubscriptions({
821
820
  id: a.id().required(),
@@ -1395,7 +1394,6 @@ const baseDefinitions = {
1395
1394
  ])
1396
1395
  .authorization((allow) => [
1397
1396
  allow.groups(categoryWriteGroups).to(["read", "create", "update"]),
1398
- allow.authenticated("identityPool").to(authoringOperations),
1399
1397
  ]),
1400
1398
  ModelAttachment: modelWithoutSubscriptions({
1401
1399
  id: a.id().required(),
@@ -886,7 +886,6 @@ const baseDefinitions = {
886
886
  ])
887
887
  .authorization((allow) => [
888
888
  allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
889
- allow.authenticated("identityPool").to(authoringOperations),
890
889
  ]),
891
890
 
892
891
  AssignmentEvent: modelWithoutSubscriptions({
@@ -1486,7 +1485,6 @@ const baseDefinitions = {
1486
1485
  ])
1487
1486
  .authorization((allow) => [
1488
1487
  allow.groups(categoryWriteGroups).to(["read", "create", "update"]),
1489
- allow.authenticated("identityPool").to(authoringOperations),
1490
1488
  ]),
1491
1489
 
1492
1490
  ModelAttachment: modelWithoutSubscriptions({
@@ -30,8 +30,10 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
30
30
  event.get("corpusKey")
31
31
  or event.get("corpus_key")
32
32
  or os.environ.get("PAPYRUS_INBOUND_EMAIL_CORPUS_KEY")
33
- or "AI-ML-research"
33
+ or ""
34
34
  ).strip()
35
+ if not corpus_key:
36
+ raise ValueError("corpusKey or PAPYRUS_INBOUND_EMAIL_CORPUS_KEY is required.")
35
37
 
36
38
  from papyrus_content.graphql_authoring import PapyrusGraphQLAuthoringClient
37
39
  from papyrus_newsroom.email_submissions import process_email_submission_message
@@ -2,6 +2,13 @@ import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
2
2
  import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
3
3
  import {
4
4
  buildEmailSubmissionMessageInput,
5
+ isInboundSenderAuthenticated,
6
+ readInboundSenderAuthenticationFromRawMime,
7
+ readInboundScanVerdictsFromSesReceipt,
8
+ REJECTION_KIND_UNAUTHENTICATED_SENDER,
9
+ UNAUTHENTICATED_SENDER_RESPONSE_ERROR,
10
+ UNVERIFIED_SENDER_AUTHENTICATION,
11
+ type InboundSenderAuthentication,
5
12
  classifyInboundEmailIntake,
6
13
  countInboundAttachments,
7
14
  extractDirectCitations,
@@ -49,6 +56,7 @@ type InboundPayload = {
49
56
  attachmentCount: number;
50
57
  userComposedText: string;
51
58
  parentSubmissionMessageId: string | null;
59
+ senderAuthentication: InboundSenderAuthentication;
52
60
  };
53
61
 
54
62
  type ExistingMessage = {
@@ -123,7 +131,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
123
131
  const resolvedMessageId = messageId ?? `message-email-submission-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
124
132
  const dataClient = await getLambdaDataClient();
125
133
  const profileId = await lookupRegisteredUserProfileId(dataClient, inbound.senderEmail);
126
- const authorized = Boolean(profileId);
134
+ const senderAuthenticated = isInboundSenderAuthenticated(inbound.senderAuthentication, inbound.senderEmail);
135
+ const authorized = Boolean(profileId) && senderAuthenticated;
127
136
  const citations = authorized
128
137
  ? (inbound.citations.length > 0
129
138
  ? inbound.citations
@@ -143,7 +152,9 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
143
152
 
144
153
  let status = authorized ? "received" : "rejected";
145
154
  let responseStatus = authorized ? "PENDING" : "REJECTED";
146
- let responseError: string | null = authorized ? null : UNREGISTERED_SENDER_RESPONSE_ERROR;
155
+ let responseError: string | null = authorized
156
+ ? null
157
+ : (senderAuthenticated ? UNREGISTERED_SENDER_RESPONSE_ERROR : UNAUTHENTICATED_SENDER_RESPONSE_ERROR);
147
158
 
148
159
  if (authorized && looksLikeResearchAssignmentRequest(inbound.bodyText, citations.length)) {
149
160
  status = "rejected";
@@ -173,7 +184,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
173
184
  s3Bucket: inbound.s3Bucket,
174
185
  s3Key: inbound.s3Key,
175
186
  authorized,
176
- authorUserProfileId: profileId,
187
+ rejectionKind: senderAuthenticated ? undefined : REJECTION_KIND_UNAUTHENTICATED_SENDER,
188
+ authorUserProfileId: authorized ? profileId : null,
177
189
  authorLabel: inbound.senderEmail || "unknown-sender",
178
190
  citations,
179
191
  status,
@@ -205,7 +217,7 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
205
217
 
206
218
  if (authorized && responseStatus === "PENDING" && PROCESSOR_FUNCTION_NAME) {
207
219
  await invokeEmailProcessor(resolvedMessageId);
208
- } else if (responseStatus === "REJECTED" && PROCESSOR_FUNCTION_NAME) {
220
+ } else if (responseStatus === "REJECTED" && senderAuthenticated && PROCESSOR_FUNCTION_NAME) {
209
221
  await invokeEmailProcessor(resolvedMessageId, { sendFeedbackOnly: true });
210
222
  }
211
223
 
@@ -390,6 +402,7 @@ async function resolveFromSesMail(
390
402
  senderEmail: loaded.senderEmail || senderEmail,
391
403
  recipients: loaded.recipients.length > 0 ? loaded.recipients : recipients,
392
404
  subject: loaded.subject !== "(no subject)" ? loaded.subject : subject,
405
+ senderAuthentication: mergeScanVerdicts(loaded.senderAuthentication, readInboundScanVerdictsFromSesReceipt(receipt)),
393
406
  };
394
407
  }
395
408
 
@@ -407,6 +420,7 @@ async function resolveFromSesMail(
407
420
  attachmentCount: 0,
408
421
  userComposedText: "",
409
422
  parentSubmissionMessageId: null,
423
+ senderAuthentication: { ...UNVERIFIED_SENDER_AUTHENTICATION, ...readInboundScanVerdictsFromSesReceipt(receipt) },
410
424
  });
411
425
  }
412
426
 
@@ -433,6 +447,7 @@ async function loadInboundFromS3Object(
433
447
  attachmentCount: countInboundAttachments(rawBytes),
434
448
  userComposedText: extractUserComposedReplyText(intake.bodyText),
435
449
  parentSubmissionMessageId: null,
450
+ senderAuthentication: readInboundSenderAuthenticationFromRawMime(rawBytes),
436
451
  });
437
452
  }
438
453
 
@@ -446,6 +461,19 @@ function enrichInboundPayload(payload: InboundPayload): InboundPayload {
446
461
  };
447
462
  }
448
463
 
464
+ function mergeScanVerdicts(
465
+ authentication: InboundSenderAuthentication,
466
+ receiptVerdicts: Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict">,
467
+ ): InboundSenderAuthentication {
468
+ const worst = (fromMime: string, fromReceipt: string) =>
469
+ fromMime === "fail" || fromReceipt === "fail" ? "fail" : fromMime !== "unknown" ? fromMime : fromReceipt;
470
+ return {
471
+ ...authentication,
472
+ spamVerdict: worst(authentication.spamVerdict, receiptVerdicts.spamVerdict),
473
+ virusVerdict: worst(authentication.virusVerdict, receiptVerdicts.virusVerdict),
474
+ };
475
+ }
476
+
449
477
  function sesMessageIdFromObjectKey(key: string): string | null {
450
478
  const basename = key.split("/").pop() ?? "";
451
479
  const match = basename.match(/^[A-Za-z0-9_-]+$/);
@@ -11,6 +11,113 @@ export const REJECTION_KIND_UNREGISTERED_SENDER = "unregistered_sender";
11
11
  export const UNREGISTERED_SENDER_RESPONSE_ERROR =
12
12
  "This submission was not accepted because only registered Papyrus users may send reference submissions by email.";
13
13
 
14
+ export const UNAUTHENTICATED_SENDER_RESPONSE_ERROR =
15
+ "This submission was not accepted because the sender could not be authenticated (SPF or DKIM must pass).";
16
+ export const REJECTION_KIND_UNAUTHENTICATED_SENDER = "unauthenticated_sender";
17
+
18
+ export type InboundSenderAuthentication = {
19
+ spfPassDomains: string[];
20
+ dkimPassDomains: string[];
21
+ spamVerdict: string;
22
+ virusVerdict: string;
23
+ };
24
+
25
+ const TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID = "amazonses.com";
26
+
27
+ export const UNVERIFIED_SENDER_AUTHENTICATION: InboundSenderAuthentication = {
28
+ spfPassDomains: [],
29
+ dkimPassDomains: [],
30
+ spamVerdict: "unknown",
31
+ virusVerdict: "unknown",
32
+ };
33
+
34
+ function domainOfAddress(address: string): string {
35
+ return normalizeEmailAddress(address).split("@")[1] ?? "";
36
+ }
37
+
38
+ function domainsAreAligned(authenticatedDomain: string, fromDomain: string): boolean {
39
+ if (!authenticatedDomain || !fromDomain) return false;
40
+ return authenticatedDomain === fromDomain
41
+ || fromDomain.endsWith(`.${authenticatedDomain}`)
42
+ || authenticatedDomain.endsWith(`.${fromDomain}`);
43
+ }
44
+
45
+ export function isInboundSenderAuthenticated(
46
+ authentication: InboundSenderAuthentication,
47
+ fromAddress: string,
48
+ ): boolean {
49
+ if (authentication.spamVerdict === "fail" || authentication.virusVerdict === "fail") return false;
50
+ const fromDomain = domainOfAddress(fromAddress);
51
+ return [...authentication.spfPassDomains, ...authentication.dkimPassDomains]
52
+ .some((domain) => domainsAreAligned(domain, fromDomain));
53
+ }
54
+
55
+ export function readInboundScanVerdictsFromSesReceipt(
56
+ receipt: Record<string, unknown> | undefined,
57
+ ): Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict"> {
58
+ const verdictStatus = (key: string): string => {
59
+ const verdict = receipt?.[key];
60
+ const status = verdict && typeof verdict === "object" ? (verdict as { status?: unknown }).status : null;
61
+ return typeof status === "string" ? status.trim().toLowerCase() : "unknown";
62
+ };
63
+ return { spamVerdict: verdictStatus("spamVerdict"), virusVerdict: verdictStatus("virusVerdict") };
64
+ }
65
+
66
+ function readHeaderBlock(rawBytes: Uint8Array): string[] {
67
+ const raw = Buffer.from(rawBytes).toString("utf8");
68
+ const headerBlockEnd = raw.search(/\r?\n\r?\n/);
69
+ const headerBlock = headerBlockEnd === -1 ? raw : raw.slice(0, headerBlockEnd);
70
+ return headerBlock.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/);
71
+ }
72
+
73
+ function firstHeaderValue(headerLines: string[], name: string): string | null {
74
+ const prefix = new RegExp(`^${name}:\\s*`, "i");
75
+ const line = headerLines.find((entry) => prefix.test(entry));
76
+ return line ? line.replace(prefix, "") : null;
77
+ }
78
+
79
+ export function readInboundSenderAuthenticationFromRawMime(rawBytes: Uint8Array): InboundSenderAuthentication {
80
+ const headerLines = readHeaderBlock(rawBytes);
81
+ const verdictHeader = (name: string): string =>
82
+ (firstHeaderValue(headerLines, name) ?? "unknown").trim().toLowerCase();
83
+ const scanVerdicts = {
84
+ spamVerdict: verdictHeader("X-SES-Spam-Verdict"),
85
+ virusVerdict: verdictHeader("X-SES-Virus-Verdict"),
86
+ };
87
+ const topmostResults = firstHeaderValue(headerLines, "Authentication-Results");
88
+ if (!topmostResults) return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
89
+ const [serverId, ...parts] = topmostResults.replace(/\([^)]*\)/g, "").split(";").map((part) => part.trim());
90
+ if (serverId.toLowerCase() !== TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID) {
91
+ return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
92
+ }
93
+
94
+ const spfPassDomains: string[] = [];
95
+ const dkimPassDomains: string[] = [];
96
+ let spfPassed = false;
97
+ let envelopeFromDomain = "";
98
+ let heloDomain = "";
99
+ for (const part of parts) {
100
+ const result = /^(spf|dkim)\s*=\s*([a-z]+)/i.exec(part);
101
+ if (result) {
102
+ const passed = result[2].toLowerCase() === "pass";
103
+ if (result[1].toLowerCase() === "spf") spfPassed = spfPassed || passed;
104
+ else if (passed) {
105
+ const signingDomain = /header\.(?:i|d)\s*=\s*@?([^\s;]+)/i.exec(part)?.[1];
106
+ if (signingDomain) dkimPassDomains.push(signingDomain.toLowerCase().replace(/^.*@/, ""));
107
+ }
108
+ }
109
+ const envelopeFrom = /^(?:envelope-from|smtp\.mailfrom)\s*=\s*(\S+)/i.exec(part)?.[1];
110
+ if (envelopeFrom) envelopeFromDomain = domainOfAddress(envelopeFrom.replace(/^<|>$/g, ""));
111
+ const helo = /^(?:helo|smtp\.helo)\s*=\s*(\S+)/i.exec(part)?.[1];
112
+ if (helo) heloDomain = helo.toLowerCase();
113
+ }
114
+ if (spfPassed) {
115
+ const spfDomain = envelopeFromDomain || heloDomain;
116
+ if (spfDomain) spfPassDomains.push(spfDomain);
117
+ }
118
+ return { spfPassDomains, dkimPassDomains, ...scanVerdicts };
119
+ }
120
+
14
121
  const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi;
15
122
  const DOI_PATTERN = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi;
16
123
  const RESEARCH_ASSIGNMENT_PHRASES = [
@@ -337,6 +444,7 @@ export function buildEmailSubmissionMessageInput(input: {
337
444
  s3Bucket: string | null;
338
445
  s3Key: string | null;
339
446
  authorized: boolean;
447
+ rejectionKind?: string;
340
448
  authorUserProfileId: string | null;
341
449
  authorLabel: string;
342
450
  citations: Array<Record<string, unknown>>;
@@ -378,7 +486,7 @@ export function buildEmailSubmissionMessageInput(input: {
378
486
  ...(input.authorized
379
487
  ? {}
380
488
  : {
381
- rejectionKind: REJECTION_KIND_UNREGISTERED_SENDER,
489
+ rejectionKind: input.rejectionKind ?? REJECTION_KIND_UNREGISTERED_SENDER,
382
490
  }),
383
491
  directCitationCount: input.citations.length,
384
492
  directCitations: input.citations,
package/app/tailwind.css CHANGED
@@ -3,14 +3,8 @@
3
3
  @source "../components/**/*.{ts,tsx}";
4
4
  @source "../app/**/*.{ts,tsx}";
5
5
  @source "../lib/**/*.{ts,tsx}";
6
- @source "../../../streamdown/dist/*.js";
7
- @source "../../../@streamdown/code/dist/*.js";
8
- @source "../../../@streamdown/cjk/dist/*.js";
9
- @source "../../../@streamdown/math/dist/*.js";
10
- @source "../../../@streamdown/mermaid/dist/*.js";
11
6
  @import "tw-animate-css";
12
7
  @import "shadcn/tailwind.css";
13
- @import "streamdown/styles.css";
14
8
 
15
9
  @custom-variant dark (&:is(.dark *));
16
10
 
@@ -263,7 +263,6 @@ type TopicProposalEditState = {
263
263
  };
264
264
  type AnalysisCommandPlanEntry = {
265
265
  label: string;
266
- cwd: string;
267
266
  executable: string;
268
267
  args: string[];
269
268
  metadata?: Record<string, unknown>;
@@ -13607,7 +13606,6 @@ function buildAnalysisDestructivePreview({
13607
13606
  function analysisCommand(label: string, args: string[], mode: AnalysisReindexMode): AnalysisCommandPlanEntry {
13608
13607
  return {
13609
13608
  label,
13610
- cwd: "/Users/ryan/Projects/Biblicus",
13611
13609
  executable: "uv",
13612
13610
  args: ["run", "--extra", "topic-modeling", "biblicus", ...args],
13613
13611
  metadata: { mode },
package/package.json CHANGED
@@ -65,7 +65,6 @@
65
65
  "default": "./infra/index.js"
66
66
  },
67
67
  "./components/ai-elements/conversation": "./components/ai-elements/conversation.tsx",
68
- "./components/ai-elements/message": "./components/ai-elements/message.tsx",
69
68
  "./components/ai-elements/model-selector": "./components/ai-elements/model-selector.tsx",
70
69
  "./components/ai-elements/prompt-input": "./components/ai-elements/prompt-input.tsx",
71
70
  "./components/ai-elements/shimmer": "./components/ai-elements/shimmer.tsx",
@@ -340,7 +339,7 @@
340
339
  "optional": true
341
340
  }
342
341
  },
343
- "version": "1.0.0-next.64",
342
+ "version": "1.0.0-next.65",
344
343
  "dependencies": {
345
344
  "@aws-amplify/adapter-nextjs": "^1.7.3",
346
345
  "@aws-crypto/sha256-js": "5.2.0",
@@ -357,10 +356,6 @@
357
356
  "@radix-ui/colors": "3.0.0",
358
357
  "@smithy/protocol-http": "5.4.1",
359
358
  "@smithy/signature-v4": "5.7.4",
360
- "@streamdown/cjk": "^1.0.3",
361
- "@streamdown/code": "^1.1.1",
362
- "@streamdown/math": "^1.0.2",
363
- "@streamdown/mermaid": "^1.0.2",
364
359
  "@tailwindcss/postcss": "^4.3.0",
365
360
  "ai": "^6.0.191",
366
361
  "anthus-vultus": "github:AnthusAI/Vultus#af98d57",
@@ -373,12 +368,10 @@
373
368
  "esbuild": "^0.28.2",
374
369
  "gray-matter": "^4.0.3",
375
370
  "gsap": "^3.15.0",
376
- "katex": "^0.17.0",
377
371
  "lucide-react": "^1.16.0",
378
372
  "motion": "^12.40.0",
379
373
  "nanoid": "^5.1.11",
380
374
  "shadcn": "^4.8.0",
381
- "streamdown": "^2.5.0",
382
375
  "tailwind-merge": "^3.6.0",
383
376
  "tailwindcss": "^4.3.0",
384
377
  "tsx": "^4.23.15",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.64",
2
+ "version": "1.0.0-next.65",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -1,360 +0,0 @@
1
- "use client";
2
-
3
- import { Button } from "../ui/button";
4
- import {
5
- ButtonGroup,
6
- ButtonGroupText,
7
- } from "../ui/button-group";
8
- import {
9
- Tooltip,
10
- TooltipContent,
11
- TooltipProvider,
12
- TooltipTrigger,
13
- } from "../ui/tooltip";
14
- import { cn } from "../../lib/utils";
15
- import { cjk } from "@streamdown/cjk";
16
- import { code } from "@streamdown/code";
17
- import { math } from "@streamdown/math";
18
- import { mermaid } from "@streamdown/mermaid";
19
- import type { UIMessage } from "ai";
20
- import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
21
- import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
22
- import {
23
- createContext,
24
- memo,
25
- useCallback,
26
- useContext,
27
- useEffect,
28
- useMemo,
29
- useState,
30
- } from "react";
31
- import { Streamdown } from "streamdown";
32
-
33
- export type MessageProps = HTMLAttributes<HTMLDivElement> & {
34
- from: UIMessage["role"];
35
- };
36
-
37
- export const Message = ({ className, from, ...props }: MessageProps) => (
38
- <div
39
- className={cn(
40
- "group flex w-full max-w-[95%] flex-col gap-2",
41
- from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
42
- className
43
- )}
44
- {...props}
45
- />
46
- );
47
-
48
- export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
49
-
50
- export const MessageContent = ({
51
- children,
52
- className,
53
- ...props
54
- }: MessageContentProps) => (
55
- <div
56
- className={cn(
57
- "is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
58
- "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
59
- "group-[.is-assistant]:text-foreground",
60
- className
61
- )}
62
- {...props}
63
- >
64
- {children}
65
- </div>
66
- );
67
-
68
- export type MessageActionsProps = ComponentProps<"div">;
69
-
70
- export const MessageActions = ({
71
- className,
72
- children,
73
- ...props
74
- }: MessageActionsProps) => (
75
- <div className={cn("flex items-center gap-1", className)} {...props}>
76
- {children}
77
- </div>
78
- );
79
-
80
- export type MessageActionProps = ComponentProps<typeof Button> & {
81
- tooltip?: string;
82
- label?: string;
83
- };
84
-
85
- export const MessageAction = ({
86
- tooltip,
87
- children,
88
- label,
89
- variant = "ghost",
90
- size = "icon-sm",
91
- ...props
92
- }: MessageActionProps) => {
93
- const button = (
94
- <Button size={size} type="button" variant={variant} {...props}>
95
- {children}
96
- <span className="sr-only">{label || tooltip}</span>
97
- </Button>
98
- );
99
-
100
- if (tooltip) {
101
- return (
102
- <TooltipProvider>
103
- <Tooltip>
104
- <TooltipTrigger>{button}</TooltipTrigger>
105
- <TooltipContent>
106
- <p>{tooltip}</p>
107
- </TooltipContent>
108
- </Tooltip>
109
- </TooltipProvider>
110
- );
111
- }
112
-
113
- return button;
114
- };
115
-
116
- interface MessageBranchContextType {
117
- currentBranch: number;
118
- totalBranches: number;
119
- goToPrevious: () => void;
120
- goToNext: () => void;
121
- branches: ReactElement[];
122
- setBranches: (branches: ReactElement[]) => void;
123
- }
124
-
125
- const MessageBranchContext = createContext<MessageBranchContextType | null>(
126
- null
127
- );
128
-
129
- const useMessageBranch = () => {
130
- const context = useContext(MessageBranchContext);
131
-
132
- if (!context) {
133
- throw new Error(
134
- "MessageBranch components must be used within MessageBranch"
135
- );
136
- }
137
-
138
- return context;
139
- };
140
-
141
- export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
142
- defaultBranch?: number;
143
- onBranchChange?: (branchIndex: number) => void;
144
- };
145
-
146
- export const MessageBranch = ({
147
- defaultBranch = 0,
148
- onBranchChange,
149
- className,
150
- ...props
151
- }: MessageBranchProps) => {
152
- const [currentBranch, setCurrentBranch] = useState(defaultBranch);
153
- const [branches, setBranches] = useState<ReactElement[]>([]);
154
-
155
- const handleBranchChange = useCallback(
156
- (newBranch: number) => {
157
- setCurrentBranch(newBranch);
158
- onBranchChange?.(newBranch);
159
- },
160
- [onBranchChange]
161
- );
162
-
163
- const goToPrevious = useCallback(() => {
164
- const newBranch =
165
- currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
166
- handleBranchChange(newBranch);
167
- }, [currentBranch, branches.length, handleBranchChange]);
168
-
169
- const goToNext = useCallback(() => {
170
- const newBranch =
171
- currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
172
- handleBranchChange(newBranch);
173
- }, [currentBranch, branches.length, handleBranchChange]);
174
-
175
- const contextValue = useMemo<MessageBranchContextType>(
176
- () => ({
177
- branches,
178
- currentBranch,
179
- goToNext,
180
- goToPrevious,
181
- setBranches,
182
- totalBranches: branches.length,
183
- }),
184
- [branches, currentBranch, goToNext, goToPrevious]
185
- );
186
-
187
- return (
188
- <MessageBranchContext.Provider value={contextValue}>
189
- <div
190
- className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
191
- {...props}
192
- />
193
- </MessageBranchContext.Provider>
194
- );
195
- };
196
-
197
- export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
198
-
199
- export const MessageBranchContent = ({
200
- children,
201
- ...props
202
- }: MessageBranchContentProps) => {
203
- const { currentBranch, setBranches, branches } = useMessageBranch();
204
- const childrenArray = useMemo(
205
- () => (Array.isArray(children) ? children : [children]),
206
- [children]
207
- );
208
-
209
- // Use useEffect to update branches when they change
210
- useEffect(() => {
211
- if (branches.length !== childrenArray.length) {
212
- setBranches(childrenArray);
213
- }
214
- }, [childrenArray, branches, setBranches]);
215
-
216
- return childrenArray.map((branch, index) => (
217
- <div
218
- className={cn(
219
- "grid gap-2 overflow-hidden [&>div]:pb-0",
220
- index === currentBranch ? "block" : "hidden"
221
- )}
222
- key={branch.key}
223
- {...props}
224
- >
225
- {branch}
226
- </div>
227
- ));
228
- };
229
-
230
- export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
231
-
232
- export const MessageBranchSelector = ({
233
- className,
234
- ...props
235
- }: MessageBranchSelectorProps) => {
236
- const { totalBranches } = useMessageBranch();
237
-
238
- // Don't render if there's only one branch
239
- if (totalBranches <= 1) {
240
- return null;
241
- }
242
-
243
- return (
244
- <ButtonGroup
245
- className={cn(
246
- "[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
247
- className
248
- )}
249
- orientation="horizontal"
250
- {...props}
251
- />
252
- );
253
- };
254
-
255
- export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
256
-
257
- export const MessageBranchPrevious = ({
258
- children,
259
- ...props
260
- }: MessageBranchPreviousProps) => {
261
- const { goToPrevious, totalBranches } = useMessageBranch();
262
-
263
- return (
264
- <Button
265
- aria-label="Previous branch"
266
- disabled={totalBranches <= 1}
267
- onClick={goToPrevious}
268
- size="icon-sm"
269
- type="button"
270
- variant="ghost"
271
- {...props}
272
- >
273
- {children ?? <ChevronLeftIcon size={14} />}
274
- </Button>
275
- );
276
- };
277
-
278
- export type MessageBranchNextProps = ComponentProps<typeof Button>;
279
-
280
- export const MessageBranchNext = ({
281
- children,
282
- ...props
283
- }: MessageBranchNextProps) => {
284
- const { goToNext, totalBranches } = useMessageBranch();
285
-
286
- return (
287
- <Button
288
- aria-label="Next branch"
289
- disabled={totalBranches <= 1}
290
- onClick={goToNext}
291
- size="icon-sm"
292
- type="button"
293
- variant="ghost"
294
- {...props}
295
- >
296
- {children ?? <ChevronRightIcon size={14} />}
297
- </Button>
298
- );
299
- };
300
-
301
- export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
302
-
303
- export const MessageBranchPage = ({
304
- className,
305
- ...props
306
- }: MessageBranchPageProps) => {
307
- const { currentBranch, totalBranches } = useMessageBranch();
308
-
309
- return (
310
- <ButtonGroupText
311
- className={cn(
312
- "border-none bg-transparent text-muted-foreground shadow-none",
313
- className
314
- )}
315
- {...props}
316
- >
317
- {currentBranch + 1} of {totalBranches}
318
- </ButtonGroupText>
319
- );
320
- };
321
-
322
- export type MessageResponseProps = ComponentProps<typeof Streamdown>;
323
-
324
- const streamdownPlugins = { cjk, code, math, mermaid };
325
-
326
- export const MessageResponse = memo(
327
- ({ className, ...props }: MessageResponseProps) => (
328
- <Streamdown
329
- className={cn(
330
- "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
331
- className
332
- )}
333
- plugins={streamdownPlugins}
334
- {...props}
335
- />
336
- ),
337
- (prevProps, nextProps) =>
338
- prevProps.children === nextProps.children &&
339
- nextProps.isAnimating === prevProps.isAnimating
340
- );
341
-
342
- MessageResponse.displayName = "MessageResponse";
343
-
344
- export type MessageToolbarProps = ComponentProps<"div">;
345
-
346
- export const MessageToolbar = ({
347
- className,
348
- children,
349
- ...props
350
- }: MessageToolbarProps) => (
351
- <div
352
- className={cn(
353
- "mt-4 flex w-full items-center justify-between gap-4",
354
- className
355
- )}
356
- {...props}
357
- >
358
- {children}
359
- </div>
360
- );