@growth-labs/cms 0.5.6 → 0.5.8
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/README.md +15 -0
- package/dist/engine/ai-dek-notifications.d.ts +16 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -0
- package/dist/engine/ai-dek-notifications.js +34 -0
- package/dist/engine/ai-dek-notifications.js.map +1 -0
- package/dist/engine/ai-dek.d.ts +35 -0
- package/dist/engine/ai-dek.d.ts.map +1 -0
- package/dist/engine/ai-dek.js +99 -0
- package/dist/engine/ai-dek.js.map +1 -0
- package/dist/engine/ai-prompts.d.ts +12 -0
- package/dist/engine/ai-prompts.d.ts.map +1 -1
- package/dist/engine/ai-prompts.js +17 -0
- package/dist/engine/ai-prompts.js.map +1 -1
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +4 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +4 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +26 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +5 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/routes/ai.d.ts +2 -0
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +42 -0
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts +2 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +3 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +49 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/surveys/schema.d.ts +6 -6
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
- package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
- package/dist/ui/api/content/[id]/ai/dek.js +5 -0
- package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
- package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
- package/dist/ui/editor/AiAssistPanel.js +28 -4
- package/dist/ui/editor/AiAssistPanel.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts +5 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +8 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/ai-assist.d.ts +2 -2
- package/dist/ui/editor/ai-assist.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +1 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +6 -1
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +5 -1
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +3 -3
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +5 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -2
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts +5 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +20 -14
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/library-data.d.ts +6 -0
- package/dist/ui/screens/library-data.d.ts.map +1 -1
- package/dist/ui/screens/library-data.js +8 -1
- package/dist/ui/screens/library-data.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +5 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +50 -0
- package/src/engine/ai-dek.ts +138 -0
- package/src/engine/ai-prompts.ts +25 -0
- package/src/engine/index.ts +14 -0
- package/src/integration/index.ts +4 -0
- package/src/integration/options.ts +6 -0
- package/src/routes/ai.ts +44 -0
- package/src/routes/authz-matrix.ts +12 -1
- package/src/routes/content.ts +52 -2
- package/src/routes/context.ts +1 -0
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/content/[id]/ai/dek.ts +8 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/editor/AiAssistPanel.tsx +55 -3
- package/src/ui/editor/ContentForm.tsx +33 -1
- package/src/ui/editor/ai-assist.ts +2 -2
- package/src/ui/editor/content-payload.ts +6 -1
- package/src/ui/screens/ContentRoute.tsx +10 -1
- package/src/ui/screens/EditorScreen.tsx +3 -0
- package/src/ui/screens/LibraryScreen.tsx +76 -6
- package/src/ui/screens/library-data.ts +12 -1
- package/src/ui/screens/registry.tsx +8 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { D1Database } from './d1.js'
|
|
2
|
+
|
|
3
|
+
export interface DekFailureNotificationInput {
|
|
4
|
+
db: D1Database
|
|
5
|
+
workspaceId: string
|
|
6
|
+
contentId: string
|
|
7
|
+
title: string
|
|
8
|
+
reason: string
|
|
9
|
+
userId?: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create unread, in-app notifications when publication ships without a dek.
|
|
14
|
+
* Interactive publishes target the actor; background publishes target every
|
|
15
|
+
* editorial member because no actor is present to receive the failure.
|
|
16
|
+
*/
|
|
17
|
+
export async function createDekFailureNotifications(
|
|
18
|
+
input: DekFailureNotificationInput,
|
|
19
|
+
): Promise<number> {
|
|
20
|
+
let userIds: string[]
|
|
21
|
+
if (input.userId) {
|
|
22
|
+
userIds = [input.userId]
|
|
23
|
+
} else {
|
|
24
|
+
const rows = await input.db
|
|
25
|
+
.prepare(
|
|
26
|
+
`SELECT user_id
|
|
27
|
+
FROM workspace_memberships
|
|
28
|
+
WHERE workspace_id = ?
|
|
29
|
+
AND role IN ('owner', 'senior_editor', 'editor')
|
|
30
|
+
ORDER BY user_id ASC`,
|
|
31
|
+
)
|
|
32
|
+
.bind(input.workspaceId)
|
|
33
|
+
.all<{ user_id: string }>()
|
|
34
|
+
userIds = (rows.results ?? []).map((row) => row.user_id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reason = input.reason.trim().slice(0, 160) || 'unknown error'
|
|
38
|
+
const body = `Content was published without a subheading: “${input.title}”. AI generation failed (${reason}). Open the item to add one manually.`
|
|
39
|
+
for (const userId of [...new Set(userIds)]) {
|
|
40
|
+
await input.db
|
|
41
|
+
.prepare(
|
|
42
|
+
`INSERT INTO cms_notifications
|
|
43
|
+
(id, workspace_id, user_id, kind, body, created_at)
|
|
44
|
+
VALUES (?, ?, ?, 'ai_dek_failed', ?, unixepoch())`,
|
|
45
|
+
)
|
|
46
|
+
.bind(crypto.randomUUID(), input.workspaceId, userId, body)
|
|
47
|
+
.run()
|
|
48
|
+
}
|
|
49
|
+
return new Set(userIds).size
|
|
50
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { LlmDispatch } from '../providers/types.js'
|
|
2
|
+
import { dekPrompt } from './ai-prompts.js'
|
|
3
|
+
import { applyAiWrite, parseLockedFields } from './ai-writeback.js'
|
|
4
|
+
import type { D1Database } from './d1.js'
|
|
5
|
+
import { type ContentItemRecord, getContentItem } from './publisher.js'
|
|
6
|
+
|
|
7
|
+
export const DEK_EXEMPLAR_LIMIT = 6
|
|
8
|
+
|
|
9
|
+
export interface DekGenerationContext {
|
|
10
|
+
item: ContentItemRecord
|
|
11
|
+
source: string
|
|
12
|
+
exemplars: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type GenerateContentDekResult =
|
|
16
|
+
| { status: 'generated'; dek: string; written: true }
|
|
17
|
+
| { status: 'locked'; dek: string; written: false }
|
|
18
|
+
| { status: 'unavailable'; dek: null; written: false; reason: string }
|
|
19
|
+
| { status: 'not_found'; dek: null; written: false; reason: 'not_found' }
|
|
20
|
+
|
|
21
|
+
async function loadTypeSource(db: D1Database, item: ContentItemRecord): Promise<string> {
|
|
22
|
+
if (item.type === 'video') {
|
|
23
|
+
const row = await db
|
|
24
|
+
.prepare('SELECT script FROM video_content WHERE content_id = ? LIMIT 1')
|
|
25
|
+
.bind(item.id)
|
|
26
|
+
.first<{ script: string | null }>()
|
|
27
|
+
return row?.script?.trim() ?? ''
|
|
28
|
+
}
|
|
29
|
+
if (item.type === 'podcast') {
|
|
30
|
+
const row = await db
|
|
31
|
+
.prepare('SELECT transcript FROM podcast_content WHERE content_id = ? LIMIT 1')
|
|
32
|
+
.bind(item.id)
|
|
33
|
+
.first<{ transcript: string | null }>()
|
|
34
|
+
return row?.transcript?.trim() ?? ''
|
|
35
|
+
}
|
|
36
|
+
const row = await db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT body_markdown, body_html, subtitle
|
|
39
|
+
FROM article_content WHERE content_id = ? LIMIT 1`,
|
|
40
|
+
)
|
|
41
|
+
.bind(item.id)
|
|
42
|
+
.first<{
|
|
43
|
+
body_markdown: string | null
|
|
44
|
+
body_html: string | null
|
|
45
|
+
subtitle: string | null
|
|
46
|
+
}>()
|
|
47
|
+
return (row?.body_markdown || row?.body_html || row?.subtitle || '').trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loadDekGenerationContext(
|
|
51
|
+
db: D1Database,
|
|
52
|
+
contentId: string,
|
|
53
|
+
): Promise<DekGenerationContext | null> {
|
|
54
|
+
const item = await getContentItem(db, contentId)
|
|
55
|
+
if (!item) return null
|
|
56
|
+
|
|
57
|
+
const typeSource = await loadTypeSource(db, item)
|
|
58
|
+
const description = item.description?.trim() ?? ''
|
|
59
|
+
const source = [description, typeSource].filter(Boolean).join('\n\n')
|
|
60
|
+
const rows = await db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT title, excerpt
|
|
63
|
+
FROM content_items
|
|
64
|
+
WHERE status = 'published'
|
|
65
|
+
AND deleted_at IS NULL
|
|
66
|
+
AND id <> ?
|
|
67
|
+
AND excerpt IS NOT NULL
|
|
68
|
+
AND trim(excerpt) <> ''
|
|
69
|
+
ORDER BY CASE WHEN type = ? THEN 0 ELSE 1 END,
|
|
70
|
+
COALESCE(published_at, 0) DESC,
|
|
71
|
+
id ASC
|
|
72
|
+
LIMIT ?`,
|
|
73
|
+
)
|
|
74
|
+
.bind(contentId, item.type, DEK_EXEMPLAR_LIMIT)
|
|
75
|
+
.all<{ title: string; excerpt: string }>()
|
|
76
|
+
const exemplars = (rows.results ?? []).map(
|
|
77
|
+
(row) => `Headline: ${row.title}\nDek: ${row.excerpt.trim()}`,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return { item, source, exemplars }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeDek(text: string): string {
|
|
84
|
+
return text
|
|
85
|
+
.trim()
|
|
86
|
+
.replace(/^(["“])|(["”])$/g, '')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function generateContentDek(input: {
|
|
92
|
+
db: D1Database
|
|
93
|
+
contentId: string
|
|
94
|
+
llm: LlmDispatch
|
|
95
|
+
}): Promise<GenerateContentDekResult> {
|
|
96
|
+
const context = await loadDekGenerationContext(input.db, input.contentId)
|
|
97
|
+
if (!context) {
|
|
98
|
+
return { status: 'not_found', dek: null, written: false, reason: 'not_found' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await input.llm.dispatch({
|
|
102
|
+
...dekPrompt({
|
|
103
|
+
title: context.item.title,
|
|
104
|
+
source: context.source,
|
|
105
|
+
exemplars: context.exemplars,
|
|
106
|
+
}),
|
|
107
|
+
stream: false,
|
|
108
|
+
})
|
|
109
|
+
if (result instanceof ReadableStream) {
|
|
110
|
+
return { status: 'unavailable', dek: null, written: false, reason: 'stream_result' }
|
|
111
|
+
}
|
|
112
|
+
if (result.status === 'unavailable') {
|
|
113
|
+
return {
|
|
114
|
+
status: 'unavailable',
|
|
115
|
+
dek: null,
|
|
116
|
+
written: false,
|
|
117
|
+
reason: result.reason ?? 'unavailable',
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const dek = normalizeDek(result.text)
|
|
122
|
+
if (!dek) {
|
|
123
|
+
return { status: 'unavailable', dek: null, written: false, reason: 'empty_result' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const write = applyAiWrite({
|
|
127
|
+
locked: parseLockedFields(context.item.ai_locked_fields),
|
|
128
|
+
field: 'excerpt',
|
|
129
|
+
value: dek,
|
|
130
|
+
})
|
|
131
|
+
if (!write.written) return { status: 'locked', dek, written: false }
|
|
132
|
+
|
|
133
|
+
await input.db
|
|
134
|
+
.prepare('UPDATE content_items SET excerpt = ?, updated_at = unixepoch() WHERE id = ?')
|
|
135
|
+
.bind(dek, input.contentId)
|
|
136
|
+
.run()
|
|
137
|
+
return { status: 'generated', dek, written: true }
|
|
138
|
+
}
|
package/src/engine/ai-prompts.ts
CHANGED
|
@@ -47,12 +47,37 @@ export interface MetaDescriptionPromptInput {
|
|
|
47
47
|
body: string
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Input to dekPrompt. A dek is display copy, not SEO metadata. */
|
|
51
|
+
export interface DekPromptInput {
|
|
52
|
+
title: string
|
|
53
|
+
source: string
|
|
54
|
+
exemplars?: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
/** The typed prompt contract shape consumed by LlmDispatch.dispatch(). */
|
|
51
58
|
export interface AiPromptContract {
|
|
52
59
|
task: string
|
|
53
60
|
prompt: string
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Build the prompt for a standfirst-style display subheading (dek).
|
|
65
|
+
* This deliberately differs from the SEO meta-description task: it tells the
|
|
66
|
+
* reader what the piece argues, reveals, or shows in the publication's voice.
|
|
67
|
+
*/
|
|
68
|
+
export function dekPrompt(input: DekPromptInput): AiPromptContract {
|
|
69
|
+
const exemplarsBlock = buildExemplarsBlock(input.exemplars)
|
|
70
|
+
const prompt = `${HOUSE_STYLE_SYSTEM}${exemplarsBlock}
|
|
71
|
+
|
|
72
|
+
Task: Write one standfirst-style subheading (dek) for this piece. It must be 1–2 sentences, roughly 120–200 characters, and reader-facing. State what the piece argues, reveals, or shows. It must not repeat or lightly rephrase the headline. Avoid clickbait and never use the boilerplate shape “with context on the stakes for…”. Return only the dek text with no label, quotation marks, commentary, or preamble.
|
|
73
|
+
|
|
74
|
+
Headline: ${input.title}
|
|
75
|
+
|
|
76
|
+
Source material:
|
|
77
|
+
${input.source}`
|
|
78
|
+
return { task: 'cms.dek', prompt }
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
/** Build the exemplars block when exemplars are supplied. */
|
|
57
82
|
function buildExemplarsBlock(exemplars: string[] | undefined): string {
|
|
58
83
|
if (!exemplars || exemplars.length === 0) return ''
|
package/src/engine/index.ts
CHANGED
|
@@ -5,9 +5,23 @@
|
|
|
5
5
|
|
|
6
6
|
// Activity log write helper — first-party D1, cms_activity_log (P5 Task 3).
|
|
7
7
|
export { type ActivityEntry, logActivity } from './activity-log.js'
|
|
8
|
+
// Display-subheading generation with deterministic house exemplars.
|
|
9
|
+
export {
|
|
10
|
+
DEK_EXEMPLAR_LIMIT,
|
|
11
|
+
type DekGenerationContext,
|
|
12
|
+
type GenerateContentDekResult,
|
|
13
|
+
generateContentDek,
|
|
14
|
+
loadDekGenerationContext,
|
|
15
|
+
} from './ai-dek.js'
|
|
16
|
+
export {
|
|
17
|
+
createDekFailureNotifications,
|
|
18
|
+
type DekFailureNotificationInput,
|
|
19
|
+
} from './ai-dek-notifications.js'
|
|
8
20
|
// AI prompt contracts + house-style system prompt (P4 Task 1).
|
|
9
21
|
export {
|
|
10
22
|
type AiPromptContract,
|
|
23
|
+
type DekPromptInput,
|
|
24
|
+
dekPrompt,
|
|
11
25
|
type HeadlinesPromptInput,
|
|
12
26
|
HOUSE_STYLE_SYSTEM,
|
|
13
27
|
headlinesPrompt,
|
package/src/integration/index.ts
CHANGED
|
@@ -121,6 +121,10 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
121
121
|
entrypoint: resolveEntrypoint('../ui/api/content/[id]'),
|
|
122
122
|
})
|
|
123
123
|
// AI sub-routes
|
|
124
|
+
injectRoute({
|
|
125
|
+
pattern: `${base}/api/content/[id]/ai/dek`,
|
|
126
|
+
entrypoint: resolveEntrypoint('../ui/api/content/[id]/ai/dek'),
|
|
127
|
+
})
|
|
124
128
|
injectRoute({
|
|
125
129
|
pattern: `${base}/api/content/[id]/ai/headlines`,
|
|
126
130
|
entrypoint: resolveEntrypoint('../ui/api/content/[id]/ai/headlines'),
|
|
@@ -8,6 +8,11 @@ const workspaceSchema = z.object({
|
|
|
8
8
|
color: z.string().default('#0c4a6e'),
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
+
const primaryCategorySchema = z.object({
|
|
12
|
+
slug: z.string().min(1),
|
|
13
|
+
label: z.string().min(1),
|
|
14
|
+
})
|
|
15
|
+
|
|
11
16
|
const themeSchema = z
|
|
12
17
|
.object({
|
|
13
18
|
mode: z.enum(['light', 'dark']).default('dark'),
|
|
@@ -32,6 +37,7 @@ const surveyResultsSchema = z
|
|
|
32
37
|
export const cmsIntegrationOptionsSchema = z
|
|
33
38
|
.object({
|
|
34
39
|
workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
|
|
40
|
+
primaryCategories: z.array(primaryCategorySchema).default([]),
|
|
35
41
|
activeWorkspaceId: z.string().optional(),
|
|
36
42
|
adminBasePath: z.string().default('/admin'),
|
|
37
43
|
searchEnabled: z.boolean().default(true),
|
package/src/routes/ai.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// apply the ai_locked_fields gate, persist when allowed, and return the result.
|
|
15
15
|
// - OG-image generation lands in Task 5.
|
|
16
16
|
|
|
17
|
+
import { generateContentDek } from '../engine/ai-dek.js'
|
|
17
18
|
import {
|
|
18
19
|
headlinesPrompt,
|
|
19
20
|
metaDescriptionPrompt,
|
|
@@ -24,6 +25,7 @@ import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
|
|
|
24
25
|
import { buildOgCardSpec } from '../engine/og-render.js'
|
|
25
26
|
import { type ContentItemRecord, getContentItem } from '../engine/publisher.js'
|
|
26
27
|
import type { LlmDispatchResult } from '../providers/types.js'
|
|
28
|
+
import { canPerformContentAction } from './authz-matrix.js'
|
|
27
29
|
import type { CmsRouteConfig } from './config.js'
|
|
28
30
|
import { resolveConfig } from './config.js'
|
|
29
31
|
import { type Authz, type Capability, json, type RouteContext, requireId } from './context.js'
|
|
@@ -45,6 +47,8 @@ async function aiGuard(authz: Authz, ctx: RouteContext): Promise<Response | null
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface AiRouteHandlers {
|
|
50
|
+
/** POST /content/:id/ai/dek — sync: generate + persist a display subheading. */
|
|
51
|
+
suggestDek(ctx: RouteContext): Promise<Response>
|
|
48
52
|
/** POST /content/:id/ai/proofread — SSE streaming proofread & tighten. */
|
|
49
53
|
proofreadAndTighten(ctx: RouteContext): Promise<Response>
|
|
50
54
|
/** POST /content/:id/ai/headlines — SSE streaming 5-headline suggestions. */
|
|
@@ -171,6 +175,45 @@ async function loadArticleBody(
|
|
|
171
175
|
export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
172
176
|
const resolved = resolveConfig(config)
|
|
173
177
|
|
|
178
|
+
async function suggestDek(ctx: RouteContext): Promise<Response> {
|
|
179
|
+
const idResult = requireId(ctx)
|
|
180
|
+
if ('error' in idResult) return idResult.error
|
|
181
|
+
const item = await getContentItem(ctx.db, idResult.id)
|
|
182
|
+
if (!item) return json({ error: 'Not found' }, 404)
|
|
183
|
+
|
|
184
|
+
if (resolved.authz.getUser) {
|
|
185
|
+
const user = await resolved.authz.getUser(ctx)
|
|
186
|
+
if (!user) return json({ error: 'Unauthorized' }, 401)
|
|
187
|
+
const allowed = canPerformContentAction(
|
|
188
|
+
user.role,
|
|
189
|
+
{ actorSubject: user.subject, contentAuthorId: item.author_id },
|
|
190
|
+
item.status === 'published' ? 'write_published' : 'write',
|
|
191
|
+
)
|
|
192
|
+
if (!allowed) return json({ error: 'Forbidden', role: user.role }, 403)
|
|
193
|
+
} else {
|
|
194
|
+
const authGuard = await aiGuard(resolved.authz, ctx)
|
|
195
|
+
if (authGuard) return authGuard
|
|
196
|
+
}
|
|
197
|
+
if (!resolved.hooks.llm) {
|
|
198
|
+
return json({ error: 'AI generation is not configured' }, 501)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = await generateContentDek({
|
|
202
|
+
db: ctx.db,
|
|
203
|
+
contentId: idResult.id,
|
|
204
|
+
llm: resolved.hooks.llm,
|
|
205
|
+
})
|
|
206
|
+
if (result.status === 'not_found') return json({ error: 'Not found' }, 404)
|
|
207
|
+
if (result.status === 'unavailable') {
|
|
208
|
+
return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
|
|
209
|
+
}
|
|
210
|
+
return json({
|
|
211
|
+
dek: result.dek,
|
|
212
|
+
written: result.written,
|
|
213
|
+
...(result.status === 'locked' ? { locked: true } : {}),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
async function proofreadAndTighten(ctx: RouteContext): Promise<Response> {
|
|
175
218
|
// Guard: edit_own_draft capability (any member; ownership-aware write-back).
|
|
176
219
|
const authGuard = await aiGuard(resolved.authz, ctx)
|
|
@@ -452,6 +495,7 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
452
495
|
}
|
|
453
496
|
|
|
454
497
|
return {
|
|
498
|
+
suggestDek,
|
|
455
499
|
proofreadAndTighten,
|
|
456
500
|
suggestHeadlines,
|
|
457
501
|
generateKeyTakeaways,
|
|
@@ -54,6 +54,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
|
|
|
54
54
|
publish_article: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
55
55
|
publish_video: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'producer']),
|
|
56
56
|
edit_own_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'contributor', 'producer']),
|
|
57
|
+
edit_published: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
57
58
|
publish_others_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
58
59
|
submit_for_review: new Set<CmsRole>([
|
|
59
60
|
'owner',
|
|
@@ -150,6 +151,7 @@ export function canActOnContent(
|
|
|
150
151
|
* - 'write' — create / update / save a draft, set-status, snapshot,
|
|
151
152
|
* contributors edit. Lowest bar: edit_own_draft (any member;
|
|
152
153
|
* contributor/producer gated to OWN content by canActOnContent).
|
|
154
|
+
* - 'write_published' — update published copy. Explicitly editorial-only.
|
|
153
155
|
* - 'publish' — publish/unpublish/archive/unschedule an article. Requires
|
|
154
156
|
* publish_others_draft (owner/senior_editor/editor); on OWN
|
|
155
157
|
* content publish_article suffices (still excludes contributor).
|
|
@@ -158,7 +160,14 @@ export function canActOnContent(
|
|
|
158
160
|
* - 'trash' — soft-delete / bulk-trash. Destructive → publish_others_draft.
|
|
159
161
|
* - 'media' — dispatch-to-foundry / regenerate AI artefacts. media_pipeline.
|
|
160
162
|
*/
|
|
161
|
-
export type ContentAction =
|
|
163
|
+
export type ContentAction =
|
|
164
|
+
| 'write'
|
|
165
|
+
| 'write_published'
|
|
166
|
+
| 'publish'
|
|
167
|
+
| 'publish_video'
|
|
168
|
+
| 'schedule'
|
|
169
|
+
| 'trash'
|
|
170
|
+
| 'media'
|
|
162
171
|
|
|
163
172
|
/**
|
|
164
173
|
* Resolve whether `role` may perform a content `action` on an item authored by
|
|
@@ -180,6 +189,8 @@ export function canPerformContentAction(
|
|
|
180
189
|
switch (action) {
|
|
181
190
|
case 'write':
|
|
182
191
|
return canActOnContent(role, ownership, 'edit_own_draft')
|
|
192
|
+
case 'write_published':
|
|
193
|
+
return canActOnContent(role, ownership, 'edit_published')
|
|
183
194
|
case 'media':
|
|
184
195
|
return canActOnContent(role, ownership, 'media_pipeline')
|
|
185
196
|
case 'publish_video':
|
package/src/routes/content.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { z } from 'zod'
|
|
18
18
|
import { logActivity } from '../engine/activity-log.js'
|
|
19
|
+
import { generateContentDek } from '../engine/ai-dek.js'
|
|
20
|
+
import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
|
|
19
21
|
import { listContributors, setContributors } from '../engine/contributors.js'
|
|
20
22
|
import type { D1Database } from '../engine/d1.js'
|
|
21
23
|
import {
|
|
@@ -823,6 +825,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
823
825
|
const cursor = parseCursor(url.searchParams.get('cursor'))
|
|
824
826
|
const q = url.searchParams.get('q') || null
|
|
825
827
|
const author = url.searchParams.get('author') || null
|
|
828
|
+
const channel = url.searchParams.get('channel') || null
|
|
826
829
|
|
|
827
830
|
const bindings: unknown[] = []
|
|
828
831
|
// Always exclude soft-deleted rows (migration 0002 added deleted_at).
|
|
@@ -844,6 +847,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
844
847
|
where += ' AND c.author_id = ?'
|
|
845
848
|
bindings.push(author)
|
|
846
849
|
}
|
|
850
|
+
if (channel) {
|
|
851
|
+
where += ' AND c.channel = ?'
|
|
852
|
+
bindings.push(channel)
|
|
853
|
+
}
|
|
847
854
|
if (cursor) {
|
|
848
855
|
where += ' AND (c.updated_at < ? OR (c.updated_at = ? AND c.id < ?))'
|
|
849
856
|
bindings.push(cursor.updatedAt, cursor.updatedAt, cursor.id)
|
|
@@ -857,7 +864,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
857
864
|
// EVERY row, mis-stamping all rows as PROCESSING. Mirrors the JOIN the
|
|
858
865
|
// counts handler below already uses.
|
|
859
866
|
const query = `
|
|
860
|
-
SELECT c.id, c.type, c.status, c.slug, c.title, c.visibility, c.featured,
|
|
867
|
+
SELECT c.id, c.type, c.status, c.slug, c.title, c.channel, c.visibility, c.featured,
|
|
861
868
|
c.publish_at, c.published_at, c.updated_at, c.author_id,
|
|
862
869
|
a.slug AS author_slug, a.name AS author_name,
|
|
863
870
|
vc.processing_state AS processing_state
|
|
@@ -877,6 +884,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
877
884
|
status: ContentStatus
|
|
878
885
|
slug: string
|
|
879
886
|
title: string
|
|
887
|
+
channel: string | null
|
|
880
888
|
visibility: 'free' | 'premium'
|
|
881
889
|
featured: number | null
|
|
882
890
|
publish_at: number | null
|
|
@@ -1126,7 +1134,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1126
1134
|
if (!existing) return json({ error: 'Not found' }, 404)
|
|
1127
1135
|
|
|
1128
1136
|
// Edit = write; contributor/producer gated to their own content.
|
|
1129
|
-
const denied = await requireContentAction(
|
|
1137
|
+
const denied = await requireContentAction(
|
|
1138
|
+
ctx,
|
|
1139
|
+
existing.status === 'published' ? 'write_published' : 'write',
|
|
1140
|
+
existing.author_id,
|
|
1141
|
+
)
|
|
1130
1142
|
if (denied) return denied
|
|
1131
1143
|
|
|
1132
1144
|
const parsed = UpdateSchema.safeParse(await readJson(ctx))
|
|
@@ -1320,6 +1332,44 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1320
1332
|
422,
|
|
1321
1333
|
)
|
|
1322
1334
|
}
|
|
1335
|
+
|
|
1336
|
+
if (!existing.excerpt?.trim()) {
|
|
1337
|
+
let failureReason: string | null = null
|
|
1338
|
+
if (!resolved.hooks.llm) {
|
|
1339
|
+
failureReason = 'AI generation is not configured'
|
|
1340
|
+
} else {
|
|
1341
|
+
try {
|
|
1342
|
+
const generated = await generateContentDek({
|
|
1343
|
+
db: ctx.db,
|
|
1344
|
+
contentId: id,
|
|
1345
|
+
llm: resolved.hooks.llm,
|
|
1346
|
+
})
|
|
1347
|
+
if (generated.status === 'unavailable' || generated.status === 'not_found') {
|
|
1348
|
+
failureReason = generated.reason
|
|
1349
|
+
}
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
failureReason = error instanceof Error ? error.message : String(error)
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (failureReason) {
|
|
1355
|
+
console.error('[cms] dek generation failed; publishing without a subheading', {
|
|
1356
|
+
contentId: id,
|
|
1357
|
+
reason: failureReason,
|
|
1358
|
+
})
|
|
1359
|
+
try {
|
|
1360
|
+
await createDekFailureNotifications({
|
|
1361
|
+
db: ctx.db,
|
|
1362
|
+
workspaceId: activeWorkspaceId,
|
|
1363
|
+
contentId: id,
|
|
1364
|
+
title: existing.title,
|
|
1365
|
+
reason: failureReason,
|
|
1366
|
+
userId: ctx.userId,
|
|
1367
|
+
})
|
|
1368
|
+
} catch (notificationError) {
|
|
1369
|
+
console.error('[cms] failed to create dek generation notification', notificationError)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1323
1373
|
await publishContent(ctx.db, id, now)
|
|
1324
1374
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1325
1375
|
|
package/src/routes/context.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { buildSiteAuthz } from './_authz.js'
|
|
|
19
19
|
* ContentRouteConfig so adapters can surface them without a separate locals key.
|
|
20
20
|
*/
|
|
21
21
|
type CmsHooksLocals = ContentRouteConfig['hooks'] & {
|
|
22
|
+
activeWorkspaceId?: string
|
|
22
23
|
dispatchTakeaways?: DispatchTakeawaysHook
|
|
23
24
|
dispatchFaq?: DispatchFaqHook
|
|
24
25
|
dispatchWebhook?: DispatchWebhookHook
|
|
@@ -62,6 +63,7 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
|
|
|
62
63
|
const hooks = locals.cmsHooks ?? {}
|
|
63
64
|
return {
|
|
64
65
|
authz: buildSiteAuthz(c),
|
|
66
|
+
activeWorkspaceId: hooks.activeWorkspaceId,
|
|
65
67
|
hooks: {
|
|
66
68
|
llm: hooks.llm,
|
|
67
69
|
foundryVideo: hooks.foundryVideo,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// POST /admin/api/content/:id/ai/dek — generate and persist a display subheading.
|
|
2
|
+
|
|
3
|
+
import type { APIContext } from 'astro'
|
|
4
|
+
import { createAiRoutes } from '../../../../../routes/ai.js'
|
|
5
|
+
import { toContentConfig, toContentCtx } from '../../../_content-config.js'
|
|
6
|
+
|
|
7
|
+
export const POST = (c: APIContext) =>
|
|
8
|
+
createAiRoutes(toContentConfig(c)).suggestDek(toContentCtx(c, { id: c.params.id ?? '' }))
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -37,6 +37,7 @@ export async function mount(elementId = 'masthead-root') {
|
|
|
37
37
|
const initial = initialWorkspaceState(config.workspaces, config.activeWorkspaceId, user)
|
|
38
38
|
const screens = createCmsScreens({
|
|
39
39
|
allowInlineAuthorCreation: config.socialSharing.allowInlineAuthorCreation,
|
|
40
|
+
primaryCategories: config.primaryCategories,
|
|
40
41
|
surveyId: config.surveyResults?.surveyId,
|
|
41
42
|
})
|
|
42
43
|
createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
|
|
@@ -27,6 +27,13 @@ interface ActionMeta {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const ACTIONS: ActionMeta[] = [
|
|
30
|
+
{
|
|
31
|
+
action: 'dek',
|
|
32
|
+
icon: 'sparkle',
|
|
33
|
+
title: 'Suggest subheading',
|
|
34
|
+
description: 'Display copy in house voice',
|
|
35
|
+
kind: 'sync',
|
|
36
|
+
},
|
|
30
37
|
{
|
|
31
38
|
action: 'proofread',
|
|
32
39
|
icon: 'wand',
|
|
@@ -74,6 +81,10 @@ interface PanelState {
|
|
|
74
81
|
headlines: string[]
|
|
75
82
|
/** Meta description text from the sync result (for the "Apply" button). */
|
|
76
83
|
metaDescription: string | null
|
|
84
|
+
/** Generated display subheading. */
|
|
85
|
+
dek: string | null
|
|
86
|
+
/** Whether the generated subheading was applied to the controlled form. */
|
|
87
|
+
dekApplied: boolean
|
|
77
88
|
/** Whether the meta was applied/written. */
|
|
78
89
|
metaApplied: boolean
|
|
79
90
|
/** Whether the takeaways were written (server-side). */
|
|
@@ -89,6 +100,8 @@ const INITIAL_STATE: PanelState = {
|
|
|
89
100
|
activeAction: null,
|
|
90
101
|
headlines: [],
|
|
91
102
|
metaDescription: null,
|
|
103
|
+
dek: null,
|
|
104
|
+
dekApplied: false,
|
|
92
105
|
metaApplied: false,
|
|
93
106
|
takeawaysWritten: false,
|
|
94
107
|
locked: false,
|
|
@@ -103,9 +116,11 @@ export interface AiAssistPanelProps {
|
|
|
103
116
|
docId: string
|
|
104
117
|
/** Called when the meta description should be applied to the SEO description field. */
|
|
105
118
|
onMetaApplied?: (description: string) => void
|
|
119
|
+
/** Called after an unlocked generated dek is stored, keeping the form in sync. */
|
|
120
|
+
onDekApplied?: (dek: string) => void
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
123
|
+
export function AiAssistPanel({ docId, onMetaApplied, onDekApplied }: AiAssistPanelProps) {
|
|
109
124
|
const [state, setState] = useState<PanelState>(INITIAL_STATE)
|
|
110
125
|
|
|
111
126
|
const handleAction = useCallback(
|
|
@@ -118,6 +133,8 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
118
133
|
activeAction: meta.action,
|
|
119
134
|
headlines: [],
|
|
120
135
|
metaDescription: null,
|
|
136
|
+
dek: null,
|
|
137
|
+
dekApplied: false,
|
|
121
138
|
metaApplied: false,
|
|
122
139
|
takeawaysWritten: false,
|
|
123
140
|
locked: false,
|
|
@@ -142,7 +159,18 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
142
159
|
} else {
|
|
143
160
|
// Sync: takeaways or meta-description
|
|
144
161
|
const data = (await syncAi(meta.action, docId)) as Record<string, unknown>
|
|
145
|
-
if (meta.action === '
|
|
162
|
+
if (meta.action === 'dek') {
|
|
163
|
+
const dek = typeof data.dek === 'string' ? data.dek : null
|
|
164
|
+
const written = data.written === true
|
|
165
|
+
if (dek && written) onDekApplied?.(dek)
|
|
166
|
+
setState((prev) => ({
|
|
167
|
+
...prev,
|
|
168
|
+
running: null,
|
|
169
|
+
dek,
|
|
170
|
+
dekApplied: Boolean(dek && written),
|
|
171
|
+
locked: data.locked === true,
|
|
172
|
+
}))
|
|
173
|
+
} else if (meta.action === 'meta-description') {
|
|
146
174
|
setState((prev) => ({
|
|
147
175
|
...prev,
|
|
148
176
|
running: null,
|
|
@@ -167,7 +195,7 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
167
195
|
}))
|
|
168
196
|
}
|
|
169
197
|
},
|
|
170
|
-
[docId],
|
|
198
|
+
[docId, onDekApplied],
|
|
171
199
|
)
|
|
172
200
|
|
|
173
201
|
const applyMeta = useCallback(async () => {
|
|
@@ -262,6 +290,30 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
262
290
|
)}
|
|
263
291
|
|
|
264
292
|
{/* Result pane — sync: meta description */}
|
|
293
|
+
{state.activeAction === 'dek' && state.dek !== null && (
|
|
294
|
+
<div style={resultPane}>
|
|
295
|
+
<div style={resultHeader}>
|
|
296
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-soft)' }}>
|
|
297
|
+
Suggested subheading
|
|
298
|
+
</span>
|
|
299
|
+
{state.locked && (
|
|
300
|
+
<span style={{ fontSize: 11, color: 'var(--amber)', marginLeft: 6 }}>
|
|
301
|
+
field locked — not written
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
<p style={{ fontSize: 13, color: 'var(--ink)', lineHeight: 1.5, margin: '6px 0' }}>
|
|
306
|
+
{state.dek}
|
|
307
|
+
</p>
|
|
308
|
+
{state.dekApplied && (
|
|
309
|
+
<span style={{ fontSize: 12, color: 'var(--green)' }}>
|
|
310
|
+
<Icon name="checkCircle" size={13} style={{ marginRight: 4 }} />
|
|
311
|
+
Applied to subheading
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
265
317
|
{state.activeAction === 'meta-description' && state.metaDescription !== null && (
|
|
266
318
|
<div style={resultPane}>
|
|
267
319
|
<div style={resultHeader}>
|