@anthusai/papyrus 2.0.0-next.91 → 2.0.0-next.93

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.
@@ -14,4 +14,10 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ monitoringEmailSubmission: a
18
+ .query()
19
+ .arguments({ receiptId: a.string().required() })
20
+ .returns(a.json())
21
+ .authorization((allow) => [allow.groups(editors)])
22
+ .handler(functionHandler("monitoringView", monitoringView)),
17
23
  };
@@ -16,4 +16,10 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ monitoringEmailSubmission: a
20
+ .query()
21
+ .arguments({ receiptId: a.string().required() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
19
25
  };
@@ -0,0 +1,17 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handlePapyrusHostedChatRequest } from "../../../../../lib/papyrus-hosted-chat";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ type RouteContext = { params: Promise<{ path: string[] }> };
8
+
9
+ export async function GET(request: NextRequest, context: RouteContext) {
10
+ const { path } = await context.params;
11
+ return handlePapyrusHostedChatRequest(request, path);
12
+ }
13
+
14
+ export async function POST(request: NextRequest, context: RouteContext) {
15
+ const { path } = await context.params;
16
+ return handlePapyrusHostedChatRequest(request, path);
17
+ }
package/app/layout.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ import "chatticus/chat.css";
2
+ import "../components/papyrus-console-conversation.css";
1
3
  import type { Metadata } from "next";
2
4
  import { Playfair_Display, Plus_Jakarta_Sans } from "next/font/google";
3
5
  import Script from "next/script";
@@ -152,7 +154,9 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
152
154
  {showStagingBanner() ? <StagingBanner /> : null}
153
155
  <AmplifyClientProvider>
154
156
  <TooltipProvider>
155
- <PapyrusConsoleShell>{children}</PapyrusConsoleShell>
157
+ <PapyrusConsoleShell privateChatEnabled={process.env.PAPYRUS_PRIVATE_CHAT_ENABLED === "true"}>
158
+ {children}
159
+ </PapyrusConsoleShell>
156
160
  </TooltipProvider>
157
161
  </AmplifyClientProvider>
158
162
  </body>
@@ -9,6 +9,8 @@ import {
9
9
  routingLabel,
10
10
  usageText,
11
11
  type MonitoringItem,
12
+ type MonitoringEmailDetails,
13
+ type MonitoringEmailSummary,
12
14
  type MonitoringOverview,
13
15
  type MonitoringRelevance,
14
16
  type MonitoringResult,
@@ -88,7 +90,45 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
88
90
  );
89
91
  }
90
92
 
91
- function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
93
+ function EmailSubmissions({ items, detail, onOpen }: {
94
+ items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
95
+ }) {
96
+ return (
97
+ <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
98
+ <div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
99
+ {items.length ? <ul className="news-desk-monitoring__list">
100
+ {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
101
+ <strong>{item.subject || "(no subject)"}</strong>
102
+ <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.processedCount} queued</span>
103
+ {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
104
+ {item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
105
+ <span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
106
+ <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
107
+ {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
108
+ {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
109
+ <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
110
+ </li>)}
111
+ </ul> : <p>No email submissions are available for this publication.</p>}
112
+ {detail && "submission" in detail ? (
113
+ <section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
114
+ <h4>{detail.submission.subject || "Submitted email"}</h4>
115
+ <p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
116
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
117
+ <h5>Candidate references ({detail.candidateLinks.length})</h5>
118
+ <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
119
+ {link.resolutionStatus === "unresolved" ? <span>{link.title || link.url} (unresolved; not opened)</span>
120
+ : <a href={link.url} target="_blank" rel="noreferrer noopener">{link.title || link.url}</a>}
121
+ </li>)}</ul>
122
+ </section>
123
+ ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
124
+ : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
125
+ </article>
126
+ );
127
+ }
128
+
129
+ function Ready({ data, demo, emailDetail, onEmailOpen }: {
130
+ data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
131
+ }) {
92
132
  const ceilings = data.usage?.ceilings ?? null;
93
133
  const router = data.router;
94
134
  return (
@@ -97,6 +137,8 @@ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
97
137
  <p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router&apos;s outputs, so no relevance is shown for it here.</p>
98
138
  ) : null}
99
139
 
140
+ <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen} />
141
+
100
142
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
101
143
  <div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
102
144
  {router ? (
@@ -192,12 +234,16 @@ export function MonitoringOverviewView({
192
234
  mode,
193
235
  onModeChange,
194
236
  demo = false,
237
+ emailDetail = null,
238
+ onEmailOpen = () => {},
195
239
  }: {
196
240
  result: MonitoringResult | null;
197
241
  canViewAll: boolean;
198
242
  mode: MonitoringMode;
199
243
  onModeChange: (mode: MonitoringMode) => void;
200
244
  demo?: boolean;
245
+ emailDetail?: MonitoringEmailDetails | null;
246
+ onEmailOpen?: (receiptId: string) => void;
201
247
  }) {
202
248
  let body: React.ReactNode;
203
249
  if (result === null) {
@@ -213,7 +259,7 @@ export function MonitoringOverviewView({
213
259
  } else if (result.state === "empty") {
214
260
  body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector&apos;s first run.</Message>;
215
261
  } else {
216
- body = <Ready data={result} demo={demo} />;
262
+ body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen} />;
217
263
  }
218
264
  const scopeLine = result && "scope" in result
219
265
  ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { loadMonitoringOverview } from "../lib/monitoring-client";
4
+ import { loadMonitoringEmailSubmission, loadMonitoringOverview } from "../lib/monitoring-client";
5
5
  import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
6
- import type { MonitoringResult } from "../lib/monitoring-overview";
6
+ import type { MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
7
7
  import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
8
8
 
9
9
  const REFRESH_MILLISECONDS = 60_000;
@@ -12,6 +12,7 @@ const REFRESH_MILLISECONDS = 60_000;
12
12
  export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
13
13
  const [mode, setMode] = useState<MonitoringMode>("publication");
14
14
  const [result, setResult] = useState<MonitoringResult | null>(null);
15
+ const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
15
16
 
16
17
  useEffect(() => {
17
18
  if (isDemo) {
@@ -36,5 +37,12 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
36
37
  };
37
38
  }, [mode, isDemo]);
38
39
 
39
- return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo} />;
40
+ const openEmail = async (receiptId: string) => {
41
+ setEmailDetail({ state: "unavailable" });
42
+ const next = await loadMonitoringEmailSubmission(receiptId);
43
+ setEmailDetail(next);
44
+ };
45
+
46
+ return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
47
+ emailDetail={emailDetail} onEmailOpen={openEmail} />;
40
48
  }
@@ -0,0 +1,14 @@
1
+ .papyrus-console-conversation {
2
+ display: flex;
3
+ flex: 1;
4
+ flex-direction: column;
5
+ min-height: 0;
6
+ --chatticus-chat-surface: rgb(var(--paper-rgb));
7
+ --chatticus-chat-raised: color-mix(in srgb, rgb(var(--paper-rgb)) 94%, rgb(var(--ink-rgb)));
8
+ --chatticus-chat-foreground: var(--ink);
9
+ --chatticus-chat-accent: var(--ink);
10
+ --chatticus-chat-error: var(--ink);
11
+ --chatticus-chat-action: var(--ink);
12
+ --chatticus-chat-action-text: rgb(var(--paper-rgb));
13
+ }
14
+ .papyrus-console-conversation .papyrus-console__suggestions { padding: 12px 0; }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ReactNode } from "react";
4
+ import { ChatRuntime, type ChatMessage, type MessagePresentation } from "chatticus/chat-react";
5
+
6
+ /** Editorial console data adapted to the shared presentation without exposing GraphQL or auth. */
7
+ export type ConsoleConversationMessage = {
8
+ id: string;
9
+ role: string | null | undefined;
10
+ text: string;
11
+ createdAt?: string;
12
+ pending?: boolean;
13
+ failed?: boolean;
14
+ error?: string | null;
15
+ context?: boolean;
16
+ };
17
+
18
+ /** Normalize host records; Papyrus keeps thread ownership, persistence and response state. */
19
+ export function consoleConversationMessages(messages: readonly ConsoleConversationMessage[]): ChatMessage[] {
20
+ return messages.map((message) => ({
21
+ id: message.id,
22
+ role: message.role === "USER" ? "user" : "assistant",
23
+ content: [{ type: "text", text: message.failed ? message.error || "Responder failed" : message.text }],
24
+ createdAt: message.createdAt ? new Date(message.createdAt) : undefined,
25
+ status: message.failed ? { type: "incomplete", reason: "error" } : message.pending ? { type: "running" } : undefined,
26
+ metadata: { custom: {
27
+ authorLabel: message.context ? "Page context" : undefined,
28
+ failed: message.failed,
29
+ isStreaming: message.pending,
30
+ isStreamingShell: message.pending && !message.text,
31
+ createdAt: message.createdAt,
32
+ } },
33
+ }));
34
+ }
35
+
36
+ /** Controlled newsroom adapter for the reusable Chatticus transcript and composer. */
37
+ export function PapyrusConsoleConversation({ messages, isSending, isRunning, onSendMessage, presentation, composerActions, emptyState, error }: {
38
+ messages: readonly ConsoleConversationMessage[];
39
+ isSending: boolean;
40
+ isRunning: boolean;
41
+ onSendMessage: (text: string) => Promise<void>;
42
+ presentation?: MessagePresentation;
43
+ composerActions?: ReactNode;
44
+ emptyState?: ReactNode;
45
+ error?: string | null;
46
+ }) {
47
+ const sharedMessages = useMemo(() => consoleConversationMessages(messages), [messages]);
48
+ return <div className="papyrus-console-conversation" data-reply-running={isRunning || undefined}>
49
+ {/* Papyrus routes can accept another editor message while the current reply runs.
50
+ The pending assistant message carries its own running status for display. */}
51
+ <ChatRuntime messages={sharedMessages} isRunning={false} isSendDisabled={isSending}
52
+ onSendMessage={onSendMessage} presentation={presentation}
53
+ threadProps={{ composerPlaceholder: "Write a message...", composerActions, emptyState, streamError: error }} />
54
+ </div>;
55
+ }