@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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +16 -8
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +189 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +90 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { App } from "aws-cdk-lib";
|
|
6
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell";
|
|
7
|
+
import { PapyrusChatStack } from "./chat-stack";
|
|
8
|
+
import { parseSiteConfig, resolveChatLogGroupPrefix, resolveChatStackName, resolveStackName } from "./site-config";
|
|
9
|
+
|
|
10
|
+
// Executes infra/amplify-app-shell/features/per-site-private-chat.feature (run through scripts/test-infra-chat.ts).
|
|
11
|
+
const examples = path.resolve(__dirname, "../examples");
|
|
12
|
+
const example = (name: string, change: (config: any) => void = () => {}): any => {
|
|
13
|
+
const config = JSON.parse(fs.readFileSync(path.join(examples, `${name}.site.json`), "utf8"));
|
|
14
|
+
change(config);
|
|
15
|
+
return config;
|
|
16
|
+
};
|
|
17
|
+
const siteNames = fs.readdirSync(examples).filter((name) => name.endsWith(".site.json")).map((name) => name.replace(/\.site\.json$/, ""));
|
|
18
|
+
const lambdaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "papyrus-chat-test-"));
|
|
19
|
+
for (const file of ["run-worker.js", "probe-worker.js"]) fs.writeFileSync(path.join(lambdaDirectory, file), "exports.handler = async () => ({});\n");
|
|
20
|
+
|
|
21
|
+
const resourcesOf = (template: any, type: string): any[] => Object.values<any>(template.Resources).filter((resource) => resource.Type === type);
|
|
22
|
+
function shellTemplate(raw: unknown): any {
|
|
23
|
+
const config = parseSiteConfig(raw);
|
|
24
|
+
const stack = new AmplifyAppShellStack(new App(), "Shell", config, { env: { account: "712236451410", region: "us-east-1" }, stackName: resolveStackName(config) });
|
|
25
|
+
return JSON.parse(JSON.stringify((App.of(stack) as App).synth().getStackByName(stack.stackName).template));
|
|
26
|
+
}
|
|
27
|
+
function chatTemplate(raw: unknown): any {
|
|
28
|
+
const config = parseSiteConfig(raw);
|
|
29
|
+
const app = new App();
|
|
30
|
+
const stack = new PapyrusChatStack(app, { config, env: { account: "712236451410", region: "us-east-1" }, lambdaAssetDirectory: lambdaDirectory });
|
|
31
|
+
return JSON.parse(JSON.stringify(app.synth().getStackByName(stack.stackName).template));
|
|
32
|
+
}
|
|
33
|
+
const branchEnvironment = (template: any, branchName: string): Record<string, unknown> => {
|
|
34
|
+
const branch = resourcesOf(template, "AWS::Amplify::Branch").find((resource) => resource.Properties.BranchName === branchName);
|
|
35
|
+
return Object.fromEntries(branch.Properties.EnvironmentVariables.map((variable: any) => [variable.Name, variable.Value]));
|
|
36
|
+
};
|
|
37
|
+
const CHAT_NAMES = ["PAPYRUS_CHAT_MESSAGING_TABLE", "PAPYRUS_CHAT_CONVERSATIONS_TABLE", "PAPYRUS_CHAT_SESSION_BUCKET", "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL", "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL", "PAPYRUS_CHAT_PRIVATE_BOT_ID", "PAPYRUS_CHAT_PRIVATE_BOT_NAME"];
|
|
38
|
+
|
|
39
|
+
// Scenario: a site with chat enabled gets the chat environment and a data stack
|
|
40
|
+
{
|
|
41
|
+
const raw = example("anth-us");
|
|
42
|
+
assert.equal(raw.chat.modelKeyParameter, "/papyrus/shipped/openai-api-key");
|
|
43
|
+
const config = parseSiteConfig(raw);
|
|
44
|
+
assert.equal(resolveChatStackName(config), "papyrus-chat-anth-us");
|
|
45
|
+
const production = branchEnvironment(shellTemplate(raw), "main");
|
|
46
|
+
assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "true");
|
|
47
|
+
assert.deepEqual(production.PAPYRUS_CHAT_MESSAGING_TABLE, { "Fn::ImportValue": "papyrus-chat-anth-us:MessagingTableName" });
|
|
48
|
+
assert.deepEqual(production.PAPYRUS_CHAT_SESSION_BUCKET, { "Fn::ImportValue": "papyrus-chat-anth-us:SessionBucketName" });
|
|
49
|
+
assert.deepEqual(production.PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL, { "Fn::ImportValue": "papyrus-chat-anth-us:TurnRunsQueueUrl" });
|
|
50
|
+
assert.equal(production.PAPYRUS_CHAT_PRIVATE_BOT_ID, "papyrus-private-assistant");
|
|
51
|
+
|
|
52
|
+
const chat = chatTemplate(raw);
|
|
53
|
+
assert.equal(resourcesOf(chat, "AWS::DynamoDB::Table").length, 2);
|
|
54
|
+
const [bucket] = resourcesOf(chat, "AWS::S3::Bucket");
|
|
55
|
+
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
|
56
|
+
assert.equal(bucket.Properties.PublicAccessBlockConfiguration.BlockPublicPolicy, true);
|
|
57
|
+
assert.equal(resourcesOf(chat, "AWS::SQS::Queue").length, 4);
|
|
58
|
+
assert.equal(resourcesOf(chat, "AWS::Lambda::Function").length, 2);
|
|
59
|
+
const mappings = resourcesOf(chat, "AWS::Lambda::EventSourceMapping");
|
|
60
|
+
assert.equal(mappings.length, 2);
|
|
61
|
+
assert.ok(mappings.every((mapping) => mapping.Properties.Enabled === true));
|
|
62
|
+
for (const key of ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"]) {
|
|
63
|
+
assert.equal(chat.Outputs[key].Export.Name, `papyrus-chat-anth-us:${key}`);
|
|
64
|
+
}
|
|
65
|
+
assert.deepEqual(Object.keys(chat.Parameters).filter((name) => name !== "BootstrapVersion").sort(), ["GatewayRoleArn", "PrivateAssistantBotId", "PrivateAssistantBotName"]);
|
|
66
|
+
|
|
67
|
+
// Only the run worker may read the model key parameter; the gateway policy never names it.
|
|
68
|
+
const parameterGrants = resourcesOf(chat, "AWS::IAM::Policy").filter((policy) => JSON.stringify(policy.Properties.PolicyDocument).includes("openai-api-key"));
|
|
69
|
+
assert.equal(parameterGrants.length, 1);
|
|
70
|
+
assert.ok(parameterGrants[0].Properties.Roles.some((role: any) => JSON.stringify(role).includes("RunWorkerRole")));
|
|
71
|
+
assert.equal(JSON.stringify(chat).split("/papyrus/shipped/openai-api-key").length - 1, 3, "the parameter appears in the run worker grant, the run worker environment and one output only");
|
|
72
|
+
const gateway = resourcesOf(chat, "AWS::IAM::Policy").find((policy) => policy.Properties.Roles.some((role: any) => JSON.stringify(role).includes("GatewayRoleArn")));
|
|
73
|
+
assert.ok(gateway, "the gateway policy attaches to the role named by GatewayRoleArn");
|
|
74
|
+
const gatewayActions = gateway.Properties.PolicyDocument.Statement.flatMap((statement: any) => [statement.Action].flat());
|
|
75
|
+
assert.ok(!gatewayActions.some((action: string) => action.startsWith("ssm:") || action.endsWith("*")), "the gateway has no SSM access and no wildcard action");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Scenario: the staging branch never gets private chat
|
|
79
|
+
{
|
|
80
|
+
const staging = branchEnvironment(shellTemplate(example("p-apyr-us")), "staging");
|
|
81
|
+
assert.equal(staging.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
|
|
82
|
+
assert.deepEqual(CHAT_NAMES.filter((name) => name in staging), []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Scenario: a site without chat gets neither
|
|
86
|
+
{
|
|
87
|
+
const withoutBlock = example("anth-us", (config) => delete config.chat);
|
|
88
|
+
const disabled = example("anth-us", (config) => (config.chat.enabled = false));
|
|
89
|
+
for (const raw of [withoutBlock, disabled]) {
|
|
90
|
+
const production = branchEnvironment(shellTemplate(raw), "main");
|
|
91
|
+
assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
|
|
92
|
+
assert.deepEqual(CHAT_NAMES.filter((name) => name in production), []);
|
|
93
|
+
assert.equal(resolveChatStackName(parseSiteConfig(raw)), undefined);
|
|
94
|
+
assert.throws(() => chatTemplate(raw), /does not enable chat/);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Scenario: the chat block cannot be contradicted by hand-set variables
|
|
99
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED = "false"))), /cms\.environment\.PAPYRUS_PRIVATE_CHAT_ENABLED/);
|
|
100
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_CHAT_SESSION_BUCKET = "x"))), /cms\.environment\.PAPYRUS_CHAT_SESSION_BUCKET/);
|
|
101
|
+
// Without chat, the existing hand-managed flag keeps working.
|
|
102
|
+
assert.equal(branchEnvironment(shellTemplate(example("anth-us", (config) => { delete config.chat; config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED = "true"; })), "main").PAPYRUS_PRIVATE_CHAT_ENABLED, "true");
|
|
103
|
+
|
|
104
|
+
// Scenario: chat needs a CMS and a model key parameter
|
|
105
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => delete config.chat.modelKeyParameter)), /chat\.modelKeyParameter/);
|
|
106
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.modelKeyParameter = "not-a-path"))), /chat\.modelKeyParameter/);
|
|
107
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.enabled = "yes"))), /chat\.enabled/);
|
|
108
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.extra = 1))), /chat\.extra/);
|
|
109
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.workerCodeS3Key = "abc"))), /chat\.workerCodeS3Key/);
|
|
110
|
+
assert.throws(() => parseSiteConfig({ siteId: "reader-only", repository: "https://github.com/example/x", brand: "x", frontend: "markus-static", papyrusVersion: "5.42.1", hostedZoneId: "Z10285921B1G7MVRV06W9", reader: { domainName: "x.example.test", buildCommand: "npm run build", baseDirectory: "out" }, chat: { enabled: true, modelKeyParameter: "/papyrus/x/openai-api-key" } }), /chat\.enabled.*requires cms/);
|
|
111
|
+
assert.throws(() => new PapyrusChatStack(new App(), { config: parseSiteConfig(example("p-apyr-us", (config) => delete config.chat.workerCodeS3Key)), env: { account: "712236451410", region: "us-east-1" } }), /bundle the chat workers first/);
|
|
112
|
+
|
|
113
|
+
// Scenario: two sites in one account do not share chat storage or log groups
|
|
114
|
+
{
|
|
115
|
+
const enabled = siteNames.map((name) => parseSiteConfig(example(name))).filter((config) => config.chat?.enabled);
|
|
116
|
+
assert.deepEqual(enabled.map((config) => config.siteId).sort(), ["anth-us", "dataparade", "distinctive", "p-apyr-us", "pilobol-us", "shipped", "threat-intelligence"], "every Papyrus site example enables chat");
|
|
117
|
+
const names = enabled.map((config) => resolveChatStackName(config));
|
|
118
|
+
const prefixes = enabled.map((config) => resolveChatLogGroupPrefix(config));
|
|
119
|
+
assert.equal(new Set(names).size, names.length, "stack names are unique");
|
|
120
|
+
assert.equal(new Set(prefixes).size, prefixes.length, "worker log group prefixes are unique");
|
|
121
|
+
for (const config of enabled) {
|
|
122
|
+
assert.ok(/^\/(papyrus\/[a-z0-9-]+\/openai-api-key|amplify\/shared\/[a-z0-9]+\/OPENAI_API_KEY)$/.test(config.chat!.modelKeyParameter), `${config.siteId} reuses an existing model key parameter`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Scenario: the activated p.apyr.us stack is adopted without replacing anything
|
|
127
|
+
{
|
|
128
|
+
const activated = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../shared-chat-runtime/cdk/PapyrusChatDataCandidate.template.json"), "utf8"));
|
|
129
|
+
const generated = chatTemplate(example("p-apyr-us"));
|
|
130
|
+
assert.equal(resolveChatStackName(parseSiteConfig(example("p-apyr-us"))), "PapyrusChatDataCandidate");
|
|
131
|
+
const normalize = (resource: any) => {
|
|
132
|
+
const copy = JSON.parse(JSON.stringify(resource));
|
|
133
|
+
delete copy.Metadata;
|
|
134
|
+
const statements = copy.Properties?.PolicyDocument?.Statement;
|
|
135
|
+
if (statements) copy.Properties.PolicyDocument.Statement = [...statements].sort((a: any, b: any) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
136
|
+
return copy;
|
|
137
|
+
};
|
|
138
|
+
assert.deepEqual(Object.keys(generated.Resources).sort(), Object.keys(activated.Resources).sort(), "no resource is added, removed or renamed (a changed logical id would replace it)");
|
|
139
|
+
for (const [logicalId, resource] of Object.entries<any>(activated.Resources)) {
|
|
140
|
+
const expected = normalize(resource);
|
|
141
|
+
// The activated template is the reviewed candidate; the queue mappings were enabled live afterwards.
|
|
142
|
+
if (resource.Type === "AWS::Lambda::EventSourceMapping") expected.Properties.Enabled = true;
|
|
143
|
+
assert.deepEqual(normalize(generated.Resources[logicalId]), expected, `${logicalId} is unchanged`);
|
|
144
|
+
}
|
|
145
|
+
for (const key of Object.keys(activated.Outputs)) {
|
|
146
|
+
assert.deepEqual({ ...generated.Outputs[key], Export: undefined }, { ...activated.Outputs[key], Export: undefined, ...(key === "FeatureDefault" ? { Value: generated.Outputs[key].Value } : {}) }, `output ${key} keeps its value`);
|
|
147
|
+
}
|
|
148
|
+
assert.deepEqual(Object.keys(generated.Outputs).sort(), Object.keys(activated.Outputs).sort());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("per-site private chat infra tests passed");
|
package/infra/index.js
CHANGED
|
@@ -10,6 +10,7 @@ try {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
|
|
13
|
+
export const { PapyrusChatStack } = await import("./chat-stack.js");
|
|
13
14
|
export const { SecureStringParameter } = await import("./secure-string-parameter.js");
|
|
14
15
|
export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
|
|
15
16
|
export { parseSiteConfig } from "./site-config.js";
|
package/infra/site-config.js
CHANGED
|
@@ -28,6 +28,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
|
28
28
|
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
29
29
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
30
30
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
31
|
+
const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
|
|
32
|
+
const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
|
|
33
|
+
const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
|
|
31
34
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
32
35
|
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
33
36
|
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
@@ -211,6 +214,58 @@ export function resolveStagingDomainName(config) {
|
|
|
211
214
|
export function resolveStackName(config) {
|
|
212
215
|
return config.stackName ?? `amplify-app-shell-${config.siteId}`;
|
|
213
216
|
}
|
|
217
|
+
/** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
|
|
218
|
+
export function resolveChatStackName(config) {
|
|
219
|
+
if (!config.chat?.enabled)
|
|
220
|
+
return undefined;
|
|
221
|
+
return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
|
|
222
|
+
}
|
|
223
|
+
export function resolveChatLogGroupPrefix(config) {
|
|
224
|
+
return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
|
|
225
|
+
}
|
|
226
|
+
/** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
|
|
227
|
+
export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"];
|
|
228
|
+
export const CHAT_ENVIRONMENT_NAMES = {
|
|
229
|
+
MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
|
|
230
|
+
ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
|
|
231
|
+
SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
|
|
232
|
+
TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
|
|
233
|
+
TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
|
|
234
|
+
};
|
|
235
|
+
export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
|
|
236
|
+
export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
|
|
237
|
+
export function chatExportName(stackName, resource) {
|
|
238
|
+
return `${stackName}:${resource}`;
|
|
239
|
+
}
|
|
240
|
+
function parseChat(value, cms) {
|
|
241
|
+
if (value === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
const record = requireObject(value, "chat");
|
|
244
|
+
rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
|
|
245
|
+
if (typeof record.enabled !== "boolean")
|
|
246
|
+
fail("chat.enabled", "must be a boolean");
|
|
247
|
+
const chat = {
|
|
248
|
+
enabled: record.enabled,
|
|
249
|
+
modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
|
|
250
|
+
stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
|
|
251
|
+
logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
|
|
252
|
+
workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
|
|
253
|
+
};
|
|
254
|
+
for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"]) {
|
|
255
|
+
if (chat[key] === undefined)
|
|
256
|
+
delete chat[key];
|
|
257
|
+
}
|
|
258
|
+
if (chat.enabled && cms === undefined)
|
|
259
|
+
fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
|
|
260
|
+
if (chat.enabled && cms) {
|
|
261
|
+
for (const key of Object.keys(cms.environment)) {
|
|
262
|
+
if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
|
|
263
|
+
fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return chat;
|
|
268
|
+
}
|
|
214
269
|
export function resolveStoragePreviewPrefix(config) {
|
|
215
270
|
return config.storagePreviewPrefix ?? "preview/";
|
|
216
271
|
}
|
|
@@ -355,7 +410,7 @@ function parseAws(value) {
|
|
|
355
410
|
}
|
|
356
411
|
export function parseSiteConfig(raw) {
|
|
357
412
|
const record = requireObject(raw, "site");
|
|
358
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
413
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
359
414
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
360
415
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
361
416
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -448,6 +503,7 @@ export function parseSiteConfig(raw) {
|
|
|
448
503
|
fail("github", "ownerId and repoId must be set together");
|
|
449
504
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
450
505
|
}
|
|
506
|
+
const chat = parseChat(record.chat, cms);
|
|
451
507
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
452
508
|
if (hasDomain && hostedZoneId === undefined) {
|
|
453
509
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
@@ -469,6 +525,7 @@ export function parseSiteConfig(raw) {
|
|
|
469
525
|
reader,
|
|
470
526
|
papyrusVersion,
|
|
471
527
|
storagePreviewPrefix,
|
|
528
|
+
...(chat ? { chat } : {}),
|
|
472
529
|
autoDeploy: autoDeploy,
|
|
473
530
|
github,
|
|
474
531
|
...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
|
package/infra/site-config.ts
CHANGED
|
@@ -55,6 +55,21 @@ export type CmsConfig = {
|
|
|
55
55
|
deployStackInBuild?: boolean;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
export type ChatConfig = {
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
/** SSM SecureString parameter holding the site's OpenAI key; only the chat run worker may read it. */
|
|
61
|
+
modelKeyParameter: string;
|
|
62
|
+
/** Chat data stack name (also its CDK construct id). Default `papyrus-chat-<siteId>`. */
|
|
63
|
+
stackName?: string;
|
|
64
|
+
/** CloudWatch log group prefix of the two workers (`<prefix>-run-worker`, `<prefix>-probe-worker`). Default `/aws/lambda/papyrus-chat-<siteId>`. */
|
|
65
|
+
logGroupPrefix?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Pins the worker code to an already uploaded CDK asset object (key in the CDK assets bucket) instead of
|
|
68
|
+
* bundling and uploading new bytes. Only for adopting a stack whose workers are already verified live.
|
|
69
|
+
*/
|
|
70
|
+
workerCodeS3Key?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
58
73
|
export type AmplifyAppShellSiteConfig = {
|
|
59
74
|
siteId: string;
|
|
60
75
|
repository: string;
|
|
@@ -82,6 +97,11 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
82
97
|
};
|
|
83
98
|
papyrusVersion: string;
|
|
84
99
|
storagePreviewPrefix?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Private newsroom chat for this publication (docs/private-newsroom-chat-per-site.md). Absent or
|
|
102
|
+
* `enabled: false` means the site gets neither the chat data stack nor the chat environment on its CMS.
|
|
103
|
+
*/
|
|
104
|
+
chat?: ChatConfig;
|
|
85
105
|
/**
|
|
86
106
|
* The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
|
|
87
107
|
* infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
|
|
@@ -153,6 +173,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
|
153
173
|
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
154
174
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
155
175
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
176
|
+
const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
|
|
177
|
+
const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
|
|
178
|
+
const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
|
|
156
179
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
157
180
|
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
158
181
|
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
@@ -339,6 +362,57 @@ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId
|
|
|
339
362
|
return config.stackName ?? `amplify-app-shell-${config.siteId}`;
|
|
340
363
|
}
|
|
341
364
|
|
|
365
|
+
/** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
|
|
366
|
+
export function resolveChatStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string | undefined {
|
|
367
|
+
if (!config.chat?.enabled) return undefined;
|
|
368
|
+
return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function resolveChatLogGroupPrefix(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string {
|
|
372
|
+
return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
|
|
376
|
+
export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"] as const;
|
|
377
|
+
export const CHAT_ENVIRONMENT_NAMES: Record<(typeof CHAT_EXPORTED_RESOURCES)[number], string> = {
|
|
378
|
+
MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
|
|
379
|
+
ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
|
|
380
|
+
SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
|
|
381
|
+
TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
|
|
382
|
+
TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
|
|
383
|
+
};
|
|
384
|
+
export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
|
|
385
|
+
export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
|
|
386
|
+
export function chatExportName(stackName: string, resource: (typeof CHAT_EXPORTED_RESOURCES)[number]): string {
|
|
387
|
+
return `${stackName}:${resource}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function parseChat(value: unknown, cms: CmsConfig | undefined): ChatConfig | undefined {
|
|
391
|
+
if (value === undefined) return undefined;
|
|
392
|
+
const record = requireObject(value, "chat");
|
|
393
|
+
rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
|
|
394
|
+
if (typeof record.enabled !== "boolean") fail("chat.enabled", "must be a boolean");
|
|
395
|
+
const chat: ChatConfig = {
|
|
396
|
+
enabled: record.enabled,
|
|
397
|
+
modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
|
|
398
|
+
stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
|
|
399
|
+
logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
|
|
400
|
+
workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
|
|
401
|
+
};
|
|
402
|
+
for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"] as const) {
|
|
403
|
+
if (chat[key] === undefined) delete chat[key];
|
|
404
|
+
}
|
|
405
|
+
if (chat.enabled && cms === undefined) fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
|
|
406
|
+
if (chat.enabled && cms) {
|
|
407
|
+
for (const key of Object.keys(cms.environment)) {
|
|
408
|
+
if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
|
|
409
|
+
fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return chat;
|
|
414
|
+
}
|
|
415
|
+
|
|
342
416
|
export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
|
|
343
417
|
return config.storagePreviewPrefix ?? "preview/";
|
|
344
418
|
}
|
|
@@ -498,7 +572,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
498
572
|
const record = requireObject(raw, "site");
|
|
499
573
|
rejectUnknownKeys(
|
|
500
574
|
record,
|
|
501
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
575
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"],
|
|
502
576
|
"site",
|
|
503
577
|
);
|
|
504
578
|
|
|
@@ -590,6 +664,8 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
590
664
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
591
665
|
}
|
|
592
666
|
|
|
667
|
+
const chat = parseChat(record.chat, cms);
|
|
668
|
+
|
|
593
669
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
594
670
|
if (hasDomain && hostedZoneId === undefined) {
|
|
595
671
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
@@ -612,6 +688,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
612
688
|
reader,
|
|
613
689
|
papyrusVersion,
|
|
614
690
|
storagePreviewPrefix,
|
|
691
|
+
...(chat ? { chat } : {}),
|
|
615
692
|
autoDeploy: autoDeploy as boolean,
|
|
616
693
|
github,
|
|
617
694
|
...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
|
|
@@ -656,6 +656,10 @@ export type NewsroomSummaryFacets = {
|
|
|
656
656
|
|
|
657
657
|
export type CategorySteeringDashboard = {
|
|
658
658
|
isDemo?: boolean;
|
|
659
|
+
/** Local preview only: the demo newsroom is showing a real export (scripts/export-reference-feed-fixture.py), so it must not say "synthetic". */
|
|
660
|
+
realDataPreview?: boolean;
|
|
661
|
+
/** Local preview only: review requests and their drafts from scripts/export-notification-center-fixture.py. */
|
|
662
|
+
notificationFixture?: { requests: import("./review-requests").ReviewRequest[]; drafts: import("./review-drafts").DraftRow[] } | null;
|
|
659
663
|
isPublicSkeleton?: boolean;
|
|
660
664
|
summaryStatus?: "loading" | "missing" | "ready";
|
|
661
665
|
summary?: NewsroomSummaryRecord | null;
|
|
@@ -820,7 +824,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
|
|
|
820
824
|
}
|
|
821
825
|
|
|
822
826
|
function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
|
|
823
|
-
|
|
827
|
+
// A data-driven site names a seed file it does not ship: its Sections live in the backend, so there is nothing to seed.
|
|
828
|
+
if (!sectionsConfigPath || !fs.existsSync(sectionsConfigPath)) return [];
|
|
824
829
|
return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
|
|
825
830
|
...row,
|
|
826
831
|
enabledStatus: "enabled",
|
|
@@ -30,6 +30,20 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
|
|
|
30
30
|
export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
|
|
31
31
|
export type UnpublishItemResult = { ok: true; changed: boolean };
|
|
32
32
|
|
|
33
|
+
export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
|
|
34
|
+
export type CorrectReferenceCaptureInput = {
|
|
35
|
+
referenceId: string;
|
|
36
|
+
/** The full corrected or pasted text. */
|
|
37
|
+
text?: string;
|
|
38
|
+
/** The id of a PDF the editor uploaded to this Reference (a ModelAttachment); the server reads its text. Sent instead of `text`. */
|
|
39
|
+
pdfAttachmentId?: string;
|
|
40
|
+
grobid?: Record<string, unknown>;
|
|
41
|
+
/** Required with text; alone it is just a label on the capture. */
|
|
42
|
+
assessment?: CaptureAssessment;
|
|
43
|
+
note?: string;
|
|
44
|
+
};
|
|
45
|
+
export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: Array<{ sortKey?: string; via?: string }>; label: unknown; summaryStale: boolean };
|
|
46
|
+
|
|
33
47
|
export class ContentActionFailure extends Error {
|
|
34
48
|
readonly errors: ContentActionError[];
|
|
35
49
|
|
|
@@ -48,6 +62,7 @@ export type ContentActionsTransport = {
|
|
|
48
62
|
saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
49
63
|
publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
50
64
|
unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
65
|
+
correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
51
66
|
};
|
|
52
67
|
};
|
|
53
68
|
|
|
@@ -80,6 +95,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
|
|
|
80
95
|
async publishItem(id: string): Promise<PublishItemResult> {
|
|
81
96
|
return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
82
97
|
},
|
|
98
|
+
async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
|
|
99
|
+
return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
|
|
100
|
+
},
|
|
83
101
|
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
84
102
|
return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
85
103
|
},
|
|
@@ -99,3 +117,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
|
|
|
99
117
|
export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
|
|
100
118
|
export const publishItem = (id: string) => contentActionsClient().publishItem(id);
|
|
101
119
|
export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
|
|
120
|
+
|
|
121
|
+
export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversations: Messages written about a Reference (later a Story, or nothing at all). One conversation per thing, held in one
|
|
3
|
+
* MessageThread found by its anchor key. Pure helpers only, so the browser and the tests share them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CONVERSATION_FEED_KEY = "conversations";
|
|
7
|
+
export const REFERENCE_CONVERSATION_KIND = "reference_conversation";
|
|
8
|
+
export const STORY_CONVERSATION_KIND = "story_conversation";
|
|
9
|
+
export const GENERAL_CONVERSATION_KIND = "conversation";
|
|
10
|
+
|
|
11
|
+
/** What a conversation is about. `none` is a conversation that is about nothing but itself. */
|
|
12
|
+
export type ConversationAnchor =
|
|
13
|
+
| { kind: "reference" | "item"; lineageId: string; /** The version id when it differs from the lineage id. */ id?: string; title?: string }
|
|
14
|
+
| { kind: "none"; lineageId?: undefined; title?: string };
|
|
15
|
+
|
|
16
|
+
/** A Message as the conversation shows it. */
|
|
17
|
+
export type ConversationMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
parentMessageId: string | null;
|
|
20
|
+
authorLabel: string;
|
|
21
|
+
content: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
sequenceNumber: number;
|
|
24
|
+
/** True while an optimistic Message waits for the save. */
|
|
25
|
+
pending?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ConversationReply = { message: ConversationMessage; answers: string | null };
|
|
29
|
+
export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
|
|
30
|
+
|
|
31
|
+
function slug(value: string): string {
|
|
32
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "thing";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `reference#<lineageId>`, `item#<lineageId>`, or null when the conversation is anchored to nothing. */
|
|
36
|
+
export function conversationAnchorKey(anchor: ConversationAnchor): string | null {
|
|
37
|
+
return anchor.kind === "none" || !anchor.lineageId?.trim() ? null : `${anchor.kind}#${anchor.lineageId.trim()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function conversationThreadKind(anchor: ConversationAnchor): string {
|
|
41
|
+
return anchor.kind === "reference" ? REFERENCE_CONVERSATION_KIND : anchor.kind === "item" ? STORY_CONVERSATION_KIND : GENERAL_CONVERSATION_KIND;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The thread id follows the anchor, so two editors starting the same conversation at once collide on create instead of splitting it. */
|
|
45
|
+
export function conversationThreadId(anchor: ConversationAnchor): string {
|
|
46
|
+
const key = conversationAnchorKey(anchor);
|
|
47
|
+
if (!key) throw new Error("A conversation without an anchor needs its own thread id.");
|
|
48
|
+
return `message-thread-conversation-${slug(key)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function conversationMessageId(threadId: string, sequenceNumber: number): string {
|
|
52
|
+
return `message-conversation-${slug(threadId)}-${String(sequenceNumber).padStart(4, "0")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One more than the highest sequence number in use. */
|
|
56
|
+
export function nextConversationSequence(existing: ReadonlyArray<{ sequenceNumber?: number | null }>): number {
|
|
57
|
+
return Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A create that lost a race for its sequence number (the id derives from the number, so the second writer collides). */
|
|
61
|
+
export function isSequenceConflict(error: unknown): boolean {
|
|
62
|
+
const text = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error ?? "");
|
|
63
|
+
return /conditional|already exists|duplicate|ConditionalCheckFailed/i.test(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "ryan@example.test" is shown as "ryan"; a name stays as it is. */
|
|
67
|
+
export function conversationAuthorName(label: string | null | undefined): string {
|
|
68
|
+
const text = String(label ?? "").trim();
|
|
69
|
+
if (!text) return "Someone";
|
|
70
|
+
const at = text.indexOf("@");
|
|
71
|
+
return at > 0 ? text.slice(0, at) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 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
|
+
* A reply whose parent is missing is shown as a top-level Message rather than lost.
|
|
77
|
+
*/
|
|
78
|
+
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>): ConversationRoot[] {
|
|
79
|
+
const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
|
|
80
|
+
const byId = new Map(sorted.map((message) => [message.id, message]));
|
|
81
|
+
const rootOf = (message: ConversationMessage): string => {
|
|
82
|
+
let current = message;
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
while (current.parentMessageId && byId.has(current.parentMessageId) && !seen.has(current.id)) {
|
|
85
|
+
seen.add(current.id);
|
|
86
|
+
current = byId.get(current.parentMessageId)!;
|
|
87
|
+
}
|
|
88
|
+
return current.id;
|
|
89
|
+
};
|
|
90
|
+
const roots = new Map<string, ConversationRoot>();
|
|
91
|
+
for (const message of sorted) {
|
|
92
|
+
if (rootOf(message) === message.id) roots.set(message.id, { message, replies: [] });
|
|
93
|
+
}
|
|
94
|
+
for (const message of sorted) {
|
|
95
|
+
const root = roots.get(rootOf(message));
|
|
96
|
+
if (!root || root.message.id === message.id) continue;
|
|
97
|
+
const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
|
|
98
|
+
root.replies.push({ message, answers: parent && parent.id !== root.message.id ? conversationAuthorName(parent.authorLabel) : null });
|
|
99
|
+
}
|
|
100
|
+
return [...roots.values()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Short relative time: "now", "5m", "3h", "2d". */
|
|
104
|
+
export function conversationAge(createdAt: string, now: number): string {
|
|
105
|
+
const time = Date.parse(createdAt);
|
|
106
|
+
if (!Number.isFinite(time)) return "";
|
|
107
|
+
const minutes = Math.max(0, Math.round((now - time) / 60000));
|
|
108
|
+
if (minutes < 1) return "now";
|
|
109
|
+
if (minutes < 60) return `${minutes}m`;
|
|
110
|
+
const hours = Math.round(minutes / 60);
|
|
111
|
+
if (hours < 24) return `${hours}h`;
|
|
112
|
+
return `${Math.round(hours / 24)}d`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function messageCountLabel(count: number): string {
|
|
116
|
+
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a Message with the next sequence number. The id derives from the number, so when another writer took it the create is
|
|
121
|
+
* refused and this tries the next number, up to `attempts` times. `list` returns the Messages already in the thread.
|
|
122
|
+
*/
|
|
123
|
+
export async function createWithSequenceRetry<Message>(input: {
|
|
124
|
+
list: () => Promise<ReadonlyArray<{ sequenceNumber?: number | null }>>;
|
|
125
|
+
create: (sequenceNumber: number) => Promise<Message>;
|
|
126
|
+
attempts?: number;
|
|
127
|
+
}): Promise<{ created: Message; sequenceNumber: number }> {
|
|
128
|
+
const attempts = input.attempts ?? 5;
|
|
129
|
+
let sequenceNumber = nextConversationSequence(await input.list());
|
|
130
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
return { created: await input.create(sequenceNumber), sequenceNumber };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!isSequenceConflict(error) || attempt >= attempts) throw error;
|
|
135
|
+
sequenceNumber = nextConversationSequence(await input.list());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Finds the conversation's thread, else creates it; a create that loses a race for the (anchor-derived) id uses the winner's thread. */
|
|
141
|
+
export async function findOrCreateThread<Thread>(input: {
|
|
142
|
+
find: () => Promise<Thread | null>;
|
|
143
|
+
create: () => Promise<Thread>;
|
|
144
|
+
}): Promise<Thread> {
|
|
145
|
+
const existing = await input.find();
|
|
146
|
+
if (existing) return existing;
|
|
147
|
+
try {
|
|
148
|
+
return await input.create();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const raced = isSequenceConflict(error) ? await input.find() : null;
|
|
151
|
+
if (raced) return raced;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|