@growth-labs/cms 0.5.6 → 0.5.7
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 +10 -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/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 +43 -1
- 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/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.map +1 -1
- package/dist/ui/editor/ContentForm.js +1 -1
- 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/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/routes/ai.ts +44 -0
- package/src/routes/authz-matrix.ts +12 -1
- package/src/routes/content.ts +45 -1
- 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/editor/AiAssistPanel.tsx +55 -3
- package/src/ui/editor/ContentForm.tsx +3 -1
- package/src/ui/editor/ai-assist.ts +2 -2
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 {
|
|
@@ -1126,7 +1128,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1126
1128
|
if (!existing) return json({ error: 'Not found' }, 404)
|
|
1127
1129
|
|
|
1128
1130
|
// Edit = write; contributor/producer gated to their own content.
|
|
1129
|
-
const denied = await requireContentAction(
|
|
1131
|
+
const denied = await requireContentAction(
|
|
1132
|
+
ctx,
|
|
1133
|
+
existing.status === 'published' ? 'write_published' : 'write',
|
|
1134
|
+
existing.author_id,
|
|
1135
|
+
)
|
|
1130
1136
|
if (denied) return denied
|
|
1131
1137
|
|
|
1132
1138
|
const parsed = UpdateSchema.safeParse(await readJson(ctx))
|
|
@@ -1320,6 +1326,44 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1320
1326
|
422,
|
|
1321
1327
|
)
|
|
1322
1328
|
}
|
|
1329
|
+
|
|
1330
|
+
if (!existing.excerpt?.trim()) {
|
|
1331
|
+
let failureReason: string | null = null
|
|
1332
|
+
if (!resolved.hooks.llm) {
|
|
1333
|
+
failureReason = 'AI generation is not configured'
|
|
1334
|
+
} else {
|
|
1335
|
+
try {
|
|
1336
|
+
const generated = await generateContentDek({
|
|
1337
|
+
db: ctx.db,
|
|
1338
|
+
contentId: id,
|
|
1339
|
+
llm: resolved.hooks.llm,
|
|
1340
|
+
})
|
|
1341
|
+
if (generated.status === 'unavailable' || generated.status === 'not_found') {
|
|
1342
|
+
failureReason = generated.reason
|
|
1343
|
+
}
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
failureReason = error instanceof Error ? error.message : String(error)
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (failureReason) {
|
|
1349
|
+
console.error('[cms] dek generation failed; publishing without a subheading', {
|
|
1350
|
+
contentId: id,
|
|
1351
|
+
reason: failureReason,
|
|
1352
|
+
})
|
|
1353
|
+
try {
|
|
1354
|
+
await createDekFailureNotifications({
|
|
1355
|
+
db: ctx.db,
|
|
1356
|
+
workspaceId: activeWorkspaceId,
|
|
1357
|
+
contentId: id,
|
|
1358
|
+
title: existing.title,
|
|
1359
|
+
reason: failureReason,
|
|
1360
|
+
userId: ctx.userId,
|
|
1361
|
+
})
|
|
1362
|
+
} catch (notificationError) {
|
|
1363
|
+
console.error('[cms] failed to create dek generation notification', notificationError)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1323
1367
|
await publishContent(ctx.db, id, now)
|
|
1324
1368
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1325
1369
|
|
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 ?? '' }))
|
|
@@ -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}>
|
|
@@ -795,7 +795,9 @@ export function ContentForm({
|
|
|
795
795
|
</div>
|
|
796
796
|
|
|
797
797
|
{/* AI assist panel — shown when toggle is on and doc is saved */}
|
|
798
|
-
{form.aiOpen && form.savedId !== null &&
|
|
798
|
+
{form.aiOpen && form.savedId !== null && (
|
|
799
|
+
<AiAssistPanel docId={form.savedId} onDekApplied={handleDekChange} />
|
|
800
|
+
)}
|
|
799
801
|
|
|
800
802
|
{/* Title */}
|
|
801
803
|
<div style={fieldGroup}>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// streamAi(action, docId, onChunk) → reads SSE body incrementally
|
|
11
11
|
// syncAi(action, docId) → POSTs and returns parsed JSON
|
|
12
12
|
|
|
13
|
-
/** The
|
|
14
|
-
export type AiAction = 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
|
|
13
|
+
/** The AI actions supported by the panel. */
|
|
14
|
+
export type AiAction = 'dek' | 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Build the URL for a given AI action + doc id.
|