@coywolf/emdash 0.0.0-stage → 0.35.14

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.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * AI Enrichment admin routes (/_emdash/api/plugins/coywolf-pack/ai/*).
3
+ * Everything that costs a model call is gated by its feature switch.
4
+ */
5
+ import type { PluginContext } from "emdash";
6
+ import { PluginRouteError, definePluginRoute } from "emdash";
7
+ import { z } from "zod";
8
+
9
+ import { ctxFeatures, isOn, requireFeature } from "../core/features.js";
10
+ import { parseInput } from "../shared.js";
11
+ import { type QueueJob, cleanText, DESCRIPTION_MAX, ALT_MAX, CAPTION_MAX } from "./logic.js";
12
+ import { type ModelOption, PROVIDERS, chat, listModels } from "./providers.js";
13
+ import {
14
+ type AiOptions,
15
+ type BulkState,
16
+ type EntryRecord,
17
+ type MediaRecord,
18
+ type UsageRow,
19
+ SETTINGS_SCHEMA,
20
+ bulkKey,
21
+ callsToday,
22
+ col,
23
+ enqueue,
24
+ entryKey,
25
+ lockHeld,
26
+ logUsage,
27
+ providerConfig,
28
+ publicSettings,
29
+ queueCounts,
30
+ readSettings,
31
+ takeCall,
32
+ } from "./store.js";
33
+ import { applyDescription, applyImageText, targetCollections, tick } from "./worker.js";
34
+
35
+ const asCtx = (ctx: unknown) => ctx as PluginContext;
36
+ /** Model lists per provider, per isolate (keyed to the saved key so a new key refetches). */
37
+ const modelCache = new Map<string, { key: string; at: number; models: ModelOption[] }>();
38
+
39
+ const settingsInput = z.object({
40
+ aiProvider: z.enum(["workers-ai", "anthropic", "openai", "gemini"]).optional(),
41
+ aiModel: z.string().max(200).optional(),
42
+ aiVisionModel: z.string().max(200).optional(),
43
+ /** A new key; omit to keep the saved one. */
44
+ aiApiKey: z.string().max(500).optional(),
45
+ clearApiKey: z.boolean().optional(),
46
+ aiMaxCallsPerDay: z.number().int().min(1).max(100000).optional(),
47
+ aiJobsPerTick: z.number().int().min(1).max(20).optional(),
48
+ aiCollections: z.string().max(2000).optional(),
49
+ aiDebounceMinutes: z.number().int().min(0).max(120).optional(),
50
+ aiDescriptionsMode: z.enum(["suggest", "apply"]).optional(),
51
+ aiImageMode: z.enum(["apply", "suggest"]).optional(),
52
+ aiImageCaption: z.boolean().optional(),
53
+ aiImageOverwrite: z.boolean().optional(),
54
+ aiImageInstructions: z.string().max(4000).optional(),
55
+ });
56
+
57
+ const entryRef = z.object({ collection: z.string().min(1).max(200), id: z.string().min(1).max(200) });
58
+ const listInput = z.object({ cursor: z.string().max(2000).optional(), filter: z.string().max(40).optional() });
59
+
60
+ async function featureForBulk(ctx: PluginContext, kind: BulkState["kind"]) {
61
+ const features = await ctxFeatures(ctx);
62
+ const on = kind === "media" ? isOn(features, "ai.imageText") : isOn(features, "ai.entities") || isOn(features, "ai.descriptions");
63
+ if (!on) throw PluginRouteError.notFound("Turn on the matching AI feature first (Plugins → Coywolf Pack).");
64
+ }
65
+
66
+ export function aiRoutes(options: AiOptions) {
67
+ return {
68
+ "ai/status": {
69
+ permission: "plugins:manage" as const,
70
+ handler: async (raw: unknown) => {
71
+ const ctx = asCtx(raw);
72
+ const settings = await readSettings(ctx, options);
73
+ const features = await ctxFeatures(ctx);
74
+ const [entriesBulk, mediaBulk] = await Promise.all([ctx.kv.get<BulkState>(bulkKey("entries")), ctx.kv.get<BulkState>(bulkKey("media"))]);
75
+ return {
76
+ settings: publicSettings(settings),
77
+ providers: PROVIDERS,
78
+ features: {
79
+ ai: isOn(features, "ai"),
80
+ entities: isOn(features, "ai.entities"),
81
+ descriptions: isOn(features, "ai.descriptions"),
82
+ imageText: isOn(features, "ai.imageText"),
83
+ entitiesStandalone: isOn(features, "ai.entitiesStandalone"),
84
+ },
85
+ queue: await queueCounts(ctx),
86
+ bulk: { entries: entriesBulk, media: mediaBulk },
87
+ callsToday: await callsToday(ctx),
88
+ running: await lockHeld(ctx),
89
+ collections: await targetCollections(ctx, settings).catch(() => []),
90
+ };
91
+ },
92
+ },
93
+
94
+ "ai/settings": definePluginRoute({
95
+ permission: "plugins:manage",
96
+ methods: ["POST"],
97
+ request: { body: "json" },
98
+ handler: async (ctx) => {
99
+ const input = parseInput(settingsInput, ctx.input);
100
+ const { aiApiKey, clearApiKey, ...rest } = input;
101
+ for (const [key, value] of Object.entries(rest)) {
102
+ if (value !== undefined && key in SETTINGS_SCHEMA) await ctx.settings.set(key, typeof value === "string" ? value.trim() : value);
103
+ }
104
+ if (clearApiKey) await ctx.settings.delete("aiApiKey");
105
+ else if (aiApiKey?.trim()) await ctx.settings.set("aiApiKey", aiApiKey.trim());
106
+ ctx.log.info("AI settings saved", { keys: Object.keys(rest), apiKeyChanged: Boolean(clearApiKey || aiApiKey) });
107
+ return { settings: publicSettings(await readSettings(ctx, options)) };
108
+ },
109
+ }),
110
+
111
+ /** Models the saved key (or Workers AI) can use, for the settings page's pickers. No model call. */
112
+ "ai/models": {
113
+ permission: "plugins:manage" as const,
114
+ handler: async (raw: unknown) => {
115
+ const ctx = asCtx(raw);
116
+ const s = await readSettings(ctx, options);
117
+ if (s.provider !== "workers-ai" && !s.apiKey) return { provider: s.provider, models: [] as ModelOption[] };
118
+ const cached = modelCache.get(s.provider);
119
+ if (cached && cached.key === s.apiKey && Date.now() - cached.at < 10 * 60_000) return { provider: s.provider, models: cached.models };
120
+ try {
121
+ const cfg = await providerConfig(ctx, options, s, false);
122
+ const models = await listModels(cfg);
123
+ modelCache.set(s.provider, { key: s.apiKey, at: Date.now(), models });
124
+ return { provider: s.provider, models };
125
+ } catch (error) {
126
+ return { provider: s.provider, models: [] as ModelOption[], error: String((error as Error).message ?? error).slice(0, 200) };
127
+ }
128
+ },
129
+ },
130
+
131
+ /** One tiny real call with the saved settings. Counts toward the daily limit. */
132
+ "ai/test": definePluginRoute({
133
+ permission: "plugins:manage",
134
+ methods: ["POST"],
135
+ request: { body: "json" },
136
+ handler: async (raw) => {
137
+ const ctx = asCtx(raw);
138
+ await requireFeature(ctx, "ai");
139
+ const s = await readSettings(ctx, options);
140
+ if (!(await takeCall(ctx, s.maxCallsPerDay))) return { ok: false, error: "Today's call limit is reached." };
141
+ const cfg = await providerConfig(ctx, options, s, false);
142
+ const started = Date.now();
143
+ try {
144
+ const result = await chat(cfg, { system: "Reply with exactly: OK", user: "ping", maxTokens: 20 });
145
+ await logUsage(ctx, { feature: "test", provider: s.provider, model: cfg.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, ok: true });
146
+ return { ok: result.text.trim().length > 0, reply: cleanText(result.text, 100), provider: s.provider, model: cfg.model, ms: Date.now() - started };
147
+ } catch (error) {
148
+ const message = String((error as Error).message ?? error).slice(0, 300);
149
+ await logUsage(ctx, { feature: "test", provider: s.provider, model: cfg.model, inputTokens: 0, outputTokens: 0, ok: false, error: message });
150
+ return { ok: false, error: message, provider: s.provider, model: cfg.model };
151
+ }
152
+ },
153
+ }),
154
+
155
+ /** Start a bulk run. The cron tick turns it into queue jobs a page at a time. */
156
+ "ai/bulk": definePluginRoute({
157
+ permission: "plugins:manage",
158
+ methods: ["POST"],
159
+ request: { body: "json" },
160
+ handler: async (raw) => {
161
+ const ctx = asCtx(raw);
162
+ const { kind, force } = parseInput(z.object({ kind: z.enum(["entries", "media"]), force: z.boolean().optional() }), (raw as { input: unknown }).input);
163
+ await featureForBulk(ctx, kind);
164
+ const s = await readSettings(ctx, options);
165
+ const state: BulkState = {
166
+ kind,
167
+ force: force ?? false,
168
+ collections: kind === "entries" ? await targetCollections(ctx, s) : [],
169
+ queued: 0,
170
+ skipped: 0,
171
+ started: new Date().toISOString(),
172
+ };
173
+ await ctx.kv.set(bulkKey(kind), state);
174
+ return { bulk: state };
175
+ },
176
+ }),
177
+
178
+ /** Stop a bulk run and drop its queued jobs. */
179
+ "ai/bulk/cancel": definePluginRoute({
180
+ permission: "plugins:manage",
181
+ methods: ["POST"],
182
+ request: { body: "json" },
183
+ handler: async (raw) => {
184
+ const ctx = asCtx(raw);
185
+ const { kind } = parseInput(z.object({ kind: z.enum(["entries", "media"]) }), (raw as { input: unknown }).input);
186
+ await ctx.kv.delete(bulkKey(kind));
187
+ const queue = col<QueueJob>(ctx, "aiQueue");
188
+ let removed = 0;
189
+ for (let i = 0; i < 20; i++) {
190
+ const page = await queue.query({ where: { kind: kind === "media" ? "media" : "entry" }, limit: 100 });
191
+ if (!page.items.length) break;
192
+ removed += await queue.deleteMany(page.items.map((j) => j.id));
193
+ }
194
+ return { removed };
195
+ },
196
+ }),
197
+
198
+ /** Process one queued item now (for trying settings without waiting for the schedule). */
199
+ "ai/run": definePluginRoute({
200
+ permission: "plugins:manage",
201
+ methods: ["POST"],
202
+ request: { body: "json" },
203
+ handler: async (raw) => {
204
+ const ctx = asCtx(raw);
205
+ await requireFeature(ctx, "ai");
206
+ return tick(ctx, options, { maxJobs: 1, budgetMs: 20_000 });
207
+ },
208
+ }),
209
+
210
+ "ai/entries": definePluginRoute({
211
+ permission: "plugins:manage",
212
+ methods: ["POST"],
213
+ request: { body: "json" },
214
+ handler: async (raw) => {
215
+ const ctx = asCtx(raw);
216
+ const { cursor, filter } = parseInput(listInput, (raw as { input: unknown }).input);
217
+ const where: Record<string, string> | undefined = filter === "suggested" ? { descriptionStatus: "suggested" } : filter === "error" ? { status: "error" } : undefined;
218
+ const page = await col<EntryRecord>(ctx, "aiEntries").query({ where, orderBy: { updated: "desc" }, limit: 50, cursor });
219
+ return { items: page.items.map((i) => i.data), cursor: page.cursor, hasMore: page.hasMore };
220
+ },
221
+ }),
222
+
223
+ "ai/entry/reanalyze": definePluginRoute({
224
+ permission: "plugins:manage",
225
+ methods: ["POST"],
226
+ request: { body: "json" },
227
+ handler: async (raw) => {
228
+ const ctx = asCtx(raw);
229
+ const features = await ctxFeatures(ctx);
230
+ if (!isOn(features, "ai.entities") && !isOn(features, "ai.descriptions")) throw PluginRouteError.notFound("Turn on AI entities or descriptions first.");
231
+ const { collection, id } = parseInput(entryRef, (raw as { input: unknown }).input);
232
+ await enqueue(ctx, { kind: "entry", collection, entryId: id, due: Date.now(), force: true });
233
+ return { queued: true };
234
+ },
235
+ }),
236
+
237
+ "ai/description/apply": definePluginRoute({
238
+ permission: "plugins:manage",
239
+ methods: ["POST"],
240
+ request: { body: "json" },
241
+ handler: async (raw) => {
242
+ const ctx = asCtx(raw);
243
+ await requireFeature(ctx, "ai.descriptions");
244
+ const input = parseInput(entryRef.extend({ description: z.string().min(1).max(500), replace: z.boolean().optional() }), (raw as { input: unknown }).input);
245
+ const description = cleanText(input.description, DESCRIPTION_MAX * 2);
246
+ const applied = await applyDescription(ctx, input.collection, input.id, description, input.replace ?? false);
247
+ if (!applied) throw PluginRouteError.badRequest("This entry already has a meta description (or its collection has no SEO panel). Choose Replace to overwrite it.");
248
+ const records = col<EntryRecord>(ctx, "aiEntries");
249
+ const key = entryKey(input.collection, input.id);
250
+ const prev = await records.get(key);
251
+ if (prev) await records.put(key, { ...prev, description, descriptionStatus: "applied", updated: new Date().toISOString() });
252
+ return { applied: true };
253
+ },
254
+ }),
255
+
256
+ "ai/description/dismiss": definePluginRoute({
257
+ permission: "plugins:manage",
258
+ methods: ["POST"],
259
+ request: { body: "json" },
260
+ handler: async (raw) => {
261
+ const ctx = asCtx(raw);
262
+ const { collection, id } = parseInput(entryRef, (raw as { input: unknown }).input);
263
+ const records = col<EntryRecord>(ctx, "aiEntries");
264
+ const prev = await records.get(entryKey(collection, id));
265
+ if (prev) await records.put(entryKey(collection, id), { ...prev, descriptionStatus: "dismissed", updated: new Date().toISOString() });
266
+ return { dismissed: true };
267
+ },
268
+ }),
269
+
270
+ "ai/media": definePluginRoute({
271
+ permission: "plugins:manage",
272
+ methods: ["POST"],
273
+ request: { body: "json" },
274
+ handler: async (raw) => {
275
+ const ctx = asCtx(raw);
276
+ const { cursor, filter } = parseInput(listInput, (raw as { input: unknown }).input);
277
+ const where: Record<string, string> | undefined = filter && ["suggested", "applied", "skipped", "error"].includes(filter) ? { status: filter } : undefined;
278
+ const page = await col<MediaRecord>(ctx, "aiMedia").query({ where, orderBy: { updated: "desc" }, limit: 50, cursor });
279
+ return { items: page.items.map((i) => i.data), cursor: page.cursor, hasMore: page.hasMore };
280
+ },
281
+ }),
282
+
283
+ "ai/media/generate": definePluginRoute({
284
+ permission: "plugins:manage",
285
+ methods: ["POST"],
286
+ request: { body: "json" },
287
+ handler: async (raw) => {
288
+ const ctx = asCtx(raw);
289
+ await requireFeature(ctx, "ai.imageText");
290
+ const { mediaId } = parseInput(z.object({ mediaId: z.string().min(1).max(200) }), (raw as { input: unknown }).input);
291
+ await enqueue(ctx, { kind: "media", mediaId, due: Date.now(), force: true });
292
+ return { queued: true };
293
+ },
294
+ }),
295
+
296
+ "ai/media/apply": definePluginRoute({
297
+ permission: "plugins:manage",
298
+ methods: ["POST"],
299
+ request: { body: "json" },
300
+ handler: async (raw) => {
301
+ const ctx = asCtx(raw);
302
+ await requireFeature(ctx, "ai.imageText");
303
+ const input = parseInput(
304
+ z.object({ mediaId: z.string().min(1).max(200), alt: z.string().max(1000).optional(), caption: z.string().max(1000).optional(), replace: z.boolean().optional() }),
305
+ (raw as { input: unknown }).input,
306
+ );
307
+ const alt = cleanText(input.alt ?? "", ALT_MAX);
308
+ const caption = cleanText(input.caption ?? "", CAPTION_MAX);
309
+ const written = await applyImageText(ctx, input.mediaId, { alt, caption }, { caption: Boolean(caption), overwrite: input.replace ?? false });
310
+ const records = col<MediaRecord>(ctx, "aiMedia");
311
+ const prev = await records.get(input.mediaId);
312
+ if (prev && written.length) await records.put(input.mediaId, { ...prev, status: "applied", written, updated: new Date().toISOString() });
313
+ return { written };
314
+ },
315
+ }),
316
+
317
+ "ai/media/dismiss": definePluginRoute({
318
+ permission: "plugins:manage",
319
+ methods: ["POST"],
320
+ request: { body: "json" },
321
+ handler: async (raw) => {
322
+ const ctx = asCtx(raw);
323
+ const { mediaId } = parseInput(z.object({ mediaId: z.string().min(1).max(200) }), (raw as { input: unknown }).input);
324
+ const records = col<MediaRecord>(ctx, "aiMedia");
325
+ const prev = await records.get(mediaId);
326
+ if (prev) await records.put(mediaId, { ...prev, status: "dismissed", updated: new Date().toISOString() });
327
+ return { dismissed: true };
328
+ },
329
+ }),
330
+
331
+ "ai/usage": {
332
+ permission: "plugins:manage" as const,
333
+ handler: async (raw: unknown) => {
334
+ const ctx = asCtx(raw);
335
+ const page = await col<UsageRow>(ctx, "aiUsage").query({ orderBy: { time: "desc" }, limit: 100 });
336
+ return { items: page.items.map((i) => i.data), callsToday: await callsToday(ctx) };
337
+ },
338
+ },
339
+
340
+ "ai/queue/clear": definePluginRoute({
341
+ permission: "plugins:manage",
342
+ methods: ["POST"],
343
+ request: { body: "json" },
344
+ handler: async (raw) => {
345
+ const ctx = asCtx(raw);
346
+ const queue = col<QueueJob>(ctx, "aiQueue");
347
+ let removed = 0;
348
+ for (let i = 0; i < 20; i++) {
349
+ const page = await queue.query({ limit: 100 });
350
+ if (!page.items.length) break;
351
+ removed += await queue.deleteMany(page.items.map((j) => j.id));
352
+ }
353
+ await ctx.kv.delete(bulkKey("entries"));
354
+ await ctx.kv.delete(bulkKey("media"));
355
+ return { removed };
356
+ },
357
+ }),
358
+ };
359
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * AI Enrichment state: settings, plugin-storage records, the job queue, the
3
+ * daily call counter, the usage log, and a cron lock.
4
+ *
5
+ * Storage collections (plugin storage, D1):
6
+ * aiQueue — pending jobs, id "entry:<collection>:<id>" or "media:<id>"
7
+ * aiEntries — per-entry analysis: hash, entities, description suggestion
8
+ * aiMedia — per-image suggestion and what was written
9
+ * aiUsage — one row per model call (kept 30 days)
10
+ */
11
+ import type { PluginContext, StorageCollection } from "emdash";
12
+
13
+ import { workerEnv } from "../shared.js";
14
+ import { type Entity, type ImageText, type QueueJob, dayKey, mergeJob, queueId } from "./logic.js";
15
+ import { PROVIDERS, type ProviderConfig, type ProviderId } from "./providers.js";
16
+
17
+ export interface AiOptions {
18
+ /** Workers AI binding name. Default "AI". */
19
+ binding?: string;
20
+ /** Cloudflare Images binding used to downscale large images before analysis. Default "IMAGES". */
21
+ images?: string;
22
+ }
23
+
24
+ export const STORAGE = {
25
+ aiQueue: { indexes: ["due", "kind"] },
26
+ aiEntries: { indexes: ["collection", "status", "descriptionStatus", "updated"] },
27
+ aiMedia: { indexes: ["status", "updated"] },
28
+ aiUsage: { indexes: ["time"] },
29
+ };
30
+
31
+ export const SETTINGS_SCHEMA = {
32
+ aiProvider: {
33
+ type: "select" as const,
34
+ label: "AI: provider",
35
+ description: "Workers AI runs on your Cloudflare account (needs the AI binding). The others need your own API key.",
36
+ options: PROVIDERS.map((p) => ({ value: p.id, label: p.label })),
37
+ default: "workers-ai",
38
+ },
39
+ aiModel: { type: "string" as const, label: "AI: text model", description: "Leave blank for the provider's default." },
40
+ aiVisionModel: { type: "string" as const, label: "AI: image model", description: "Must accept images. Leave blank for the provider's default." },
41
+ aiApiKey: { type: "secret" as const, label: "AI: API key", description: "For Anthropic, OpenAI, or Gemini. Stored encrypted." },
42
+ aiMaxCallsPerDay: { type: "number" as const, label: "AI: max model calls per day", min: 1, max: 100000, default: 200 },
43
+ aiJobsPerTick: { type: "number" as const, label: "AI: items per scheduled run", description: "Scheduled runs happen every two minutes, or with each cron tick if the site's cron runs less often.", min: 1, max: 20, default: 3 },
44
+ aiCollections: { type: "string" as const, label: "AI: collections", description: "Comma-separated collection slugs. Blank = every public collection." },
45
+ aiDebounceMinutes: { type: "number" as const, label: "AI: wait after a save (minutes)", min: 0, max: 120, default: 2 },
46
+ aiDescriptionsMode: {
47
+ type: "select" as const,
48
+ label: "AI: meta descriptions",
49
+ options: [
50
+ { value: "suggest", label: "Suggest (review on the AI page)" },
51
+ { value: "apply", label: "Fill empty descriptions automatically" },
52
+ ],
53
+ default: "suggest",
54
+ },
55
+ aiImageMode: {
56
+ type: "select" as const,
57
+ label: "AI: image text",
58
+ options: [
59
+ { value: "apply", label: "Fill empty alt text automatically" },
60
+ { value: "suggest", label: "Suggest (review on the AI page)" },
61
+ ],
62
+ default: "apply",
63
+ },
64
+ aiImageCaption: { type: "boolean" as const, label: "AI: also write captions", default: false },
65
+ aiImageOverwrite: { type: "boolean" as const, label: "AI: overwrite existing alt text and captions", default: false },
66
+ aiImageInstructions: { type: "string" as const, label: "AI: extra image instructions", multiline: true },
67
+ };
68
+
69
+ export interface AiSettings {
70
+ provider: ProviderId;
71
+ textModel: string;
72
+ visionModel: string;
73
+ apiKeySet: boolean;
74
+ maxCallsPerDay: number;
75
+ jobsPerTick: number;
76
+ collections: string[];
77
+ debounceMinutes: number;
78
+ descriptionsMode: "suggest" | "apply";
79
+ imageMode: "apply" | "suggest";
80
+ imageCaption: boolean;
81
+ imageOverwrite: boolean;
82
+ imageInstructions: string;
83
+ bindingAvailable: boolean;
84
+ imagesBindingAvailable: boolean;
85
+ }
86
+
87
+ type Ctx = Pick<PluginContext, "settings" | "storage" | "kv" | "log"> & Partial<Pick<PluginContext, "http">>;
88
+
89
+ const clamp = (n: unknown, min: number, max: number, fallback: number) => {
90
+ const v = typeof n === "number" && Number.isFinite(n) ? n : fallback;
91
+ return Math.min(max, Math.max(min, Math.round(v)));
92
+ };
93
+
94
+ export async function bindings(options: AiOptions) {
95
+ const env = await workerEnv().catch(() => ({}) as Record<string, unknown>);
96
+ const ai = env[options.binding ?? "AI"] as ProviderConfig["binding"] | undefined;
97
+ const images = env[options.images ?? "IMAGES"] as ImagesBinding | undefined;
98
+ return { ai: ai && typeof ai.run === "function" ? ai : undefined, images: images && typeof images.input === "function" ? images : undefined };
99
+ }
100
+
101
+ export async function readSettings(ctx: Pick<PluginContext, "settings">, options: AiOptions): Promise<AiSettings & { apiKey: string }> {
102
+ const keys = Object.keys(SETTINGS_SCHEMA) as Array<keyof typeof SETTINGS_SCHEMA>;
103
+ const [{ ai, images }, values] = await Promise.all([
104
+ bindings(options),
105
+ Promise.all(keys.map((key) => ctx.settings.get<unknown>(key).catch(() => null))),
106
+ ]);
107
+ const v = Object.fromEntries(keys.map((key, i) => [key, values[i]])) as Record<keyof typeof SETTINGS_SCHEMA, unknown>;
108
+ const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
109
+ const stored = str(v.aiProvider) as ProviderId;
110
+ const provider: ProviderId = PROVIDERS.some((p) => p.id === stored) ? stored : ai ? "workers-ai" : "anthropic";
111
+ const def = PROVIDERS.find((p) => p.id === provider)!;
112
+ const apiKey = str(v.aiApiKey);
113
+ return {
114
+ provider,
115
+ textModel: str(v.aiModel) || def.textModel,
116
+ visionModel: str(v.aiVisionModel) || def.visionModel,
117
+ apiKey,
118
+ apiKeySet: apiKey.length > 0,
119
+ maxCallsPerDay: clamp(v.aiMaxCallsPerDay, 1, 100000, 200),
120
+ jobsPerTick: clamp(v.aiJobsPerTick, 1, 20, 3),
121
+ collections: str(v.aiCollections)
122
+ .split(",")
123
+ .map((c) => c.trim())
124
+ .filter(Boolean),
125
+ debounceMinutes: clamp(v.aiDebounceMinutes, 0, 120, 2),
126
+ descriptionsMode: v.aiDescriptionsMode === "apply" ? "apply" : "suggest",
127
+ imageMode: v.aiImageMode === "suggest" ? "suggest" : "apply",
128
+ imageCaption: v.aiImageCaption === true,
129
+ imageOverwrite: v.aiImageOverwrite === true,
130
+ imageInstructions: typeof v.aiImageInstructions === "string" ? v.aiImageInstructions : "",
131
+ bindingAvailable: Boolean(ai),
132
+ imagesBindingAvailable: Boolean(images),
133
+ };
134
+ }
135
+
136
+ /** Settings safe to send to the admin (no key). */
137
+ export function publicSettings(s: AiSettings & { apiKey: string }): AiSettings {
138
+ const { apiKey: _omit, ...rest } = s;
139
+ return rest;
140
+ }
141
+
142
+ export async function providerConfig(ctx: Ctx, options: AiOptions, s: AiSettings & { apiKey: string }, vision: boolean): Promise<ProviderConfig> {
143
+ const { ai } = await bindings(options);
144
+ return {
145
+ provider: s.provider,
146
+ model: vision ? s.visionModel : s.textModel,
147
+ apiKey: s.apiKey,
148
+ binding: ai,
149
+ fetch: (url, init) => (ctx.http ? ctx.http.fetch(url, init) : fetch(url, init)),
150
+ };
151
+ }
152
+
153
+ // ── Storage records ──────────────────────────────────────────────
154
+
155
+ export interface EntryRecord {
156
+ collection: string;
157
+ entryId: string;
158
+ title: string;
159
+ hash: string;
160
+ status: "ok" | "error";
161
+ error: string;
162
+ entities: Entity[];
163
+ /** AI-written description and what happened to it. */
164
+ description: string;
165
+ descriptionStatus: "none" | "suggested" | "applied" | "dismissed";
166
+ updated: string;
167
+ }
168
+
169
+ export interface MediaRecord {
170
+ mediaId: string;
171
+ filename: string;
172
+ url: string;
173
+ suggestion: ImageText | null;
174
+ status: "suggested" | "applied" | "skipped" | "error" | "dismissed";
175
+ written: string[];
176
+ error: string;
177
+ updated: string;
178
+ }
179
+
180
+ export interface UsageRow {
181
+ time: string;
182
+ feature: "entities" | "descriptions" | "imageText" | "test";
183
+ provider: string;
184
+ model: string;
185
+ inputTokens: number;
186
+ outputTokens: number;
187
+ ok: boolean;
188
+ error?: string;
189
+ ref?: string;
190
+ }
191
+
192
+ export function col<T>(ctx: Pick<PluginContext, "storage">, name: keyof typeof STORAGE): StorageCollection<T> {
193
+ return (ctx.storage as Record<string, StorageCollection>)[name] as StorageCollection<T>;
194
+ }
195
+
196
+ export const entryKey = (collection: string, id: string) => `${collection}:${id}`;
197
+
198
+ // ── Queue ────────────────────────────────────────────────────────
199
+
200
+ export async function enqueue(ctx: Pick<PluginContext, "storage">, job: Omit<QueueJob, "attempts" | "enqueuedAt"> & Partial<QueueJob>): Promise<void> {
201
+ const queue = col<QueueJob>(ctx, "aiQueue");
202
+ const id = queueId(job);
203
+ const next: QueueJob = { attempts: 0, enqueuedAt: Date.now(), ...job };
204
+ await queue.put(id, mergeJob(await queue.get(id), next));
205
+ }
206
+
207
+ export async function queueCounts(ctx: Pick<PluginContext, "storage">) {
208
+ const queue = col<QueueJob>(ctx, "aiQueue");
209
+ const [entries, media] = await Promise.all([queue.count({ kind: "entry" }), queue.count({ kind: "media" })]);
210
+ return { entries, media };
211
+ }
212
+
213
+ // ── Daily call cap ───────────────────────────────────────────────
214
+
215
+ const callsKey = (now = Date.now()) => `state:ai:calls:${dayKey(now)}`;
216
+
217
+ export async function callsToday(ctx: Pick<PluginContext, "kv">): Promise<number> {
218
+ return (await ctx.kv.get<number>(callsKey())) ?? 0;
219
+ }
220
+
221
+ /** Count one model call against today's cap. Returns false (and counts nothing) when the cap is reached. */
222
+ export async function takeCall(ctx: Pick<PluginContext, "kv">, max: number): Promise<boolean> {
223
+ const key = callsKey();
224
+ for (let i = 0; i < 5; i++) {
225
+ const current = await ctx.kv.getVersioned<number>(key);
226
+ const used = current?.value ?? 0;
227
+ if (used >= max) return false;
228
+ const result = await ctx.kv.compareAndSet(key, current?.revision ?? null, used + 1);
229
+ if (result.applied) return true;
230
+ }
231
+ return false;
232
+ }
233
+
234
+ export class DailyLimitError extends Error {
235
+ constructor() {
236
+ super("Today's AI call limit is reached. Work resumes tomorrow (UTC), or raise the limit on the AI page.");
237
+ this.name = "DailyLimitError";
238
+ }
239
+ }
240
+
241
+ // ── Usage log ────────────────────────────────────────────────────
242
+
243
+ export async function logUsage(ctx: Pick<PluginContext, "storage">, row: Omit<UsageRow, "time">): Promise<void> {
244
+ const time = new Date().toISOString();
245
+ try {
246
+ await col<UsageRow>(ctx, "aiUsage").put(`${time}-${crypto.randomUUID().slice(0, 8)}`, { time, ...row });
247
+ } catch {
248
+ // The log is best effort.
249
+ }
250
+ }
251
+
252
+ /** Delete usage rows older than 30 days, a page at a time. */
253
+ export async function pruneUsage(ctx: Pick<PluginContext, "storage">): Promise<void> {
254
+ const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString();
255
+ const usage = col<UsageRow>(ctx, "aiUsage");
256
+ const old = await usage.query({ where: { time: { lt: cutoff } }, limit: 200 });
257
+ if (old.items.length) await usage.deleteMany(old.items.map((i) => i.id));
258
+ }
259
+
260
+ // ── Lock ─────────────────────────────────────────────────────────
261
+
262
+ const LOCK_KEY = "state:ai:lock";
263
+
264
+ /** A soft lock so the cron tick and "Run now" don't process the queue concurrently. */
265
+ export async function withLock<T>(ctx: Pick<PluginContext, "kv">, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
266
+ const now = Date.now();
267
+ const current = await ctx.kv.getVersioned<{ until: number; token: string }>(LOCK_KEY);
268
+ if (current?.value && current.value.until > now) return null;
269
+ const token = crypto.randomUUID();
270
+ const taken = await ctx.kv.compareAndSet(LOCK_KEY, current?.revision ?? null, { until: now + ttlMs, token });
271
+ if (!taken.applied) return null;
272
+ try {
273
+ return await fn();
274
+ } finally {
275
+ const mine = await ctx.kv.getVersioned<{ token: string }>(LOCK_KEY);
276
+ if (mine?.value?.token === token) await ctx.kv.compareAndDelete(LOCK_KEY, mine.revision).catch(() => undefined);
277
+ }
278
+ }
279
+
280
+ export async function lockHeld(ctx: Pick<PluginContext, "kv">): Promise<boolean> {
281
+ const current = await ctx.kv.get<{ until: number }>(LOCK_KEY);
282
+ return Boolean(current && current.until > Date.now());
283
+ }
284
+
285
+ // ── Bulk scan state ──────────────────────────────────────────────
286
+
287
+ export interface BulkState {
288
+ kind: "entries" | "media";
289
+ force: boolean;
290
+ /** Collections still to scan (entries). */
291
+ collections: string[];
292
+ cursor?: string;
293
+ queued: number;
294
+ skipped: number;
295
+ started: string;
296
+ finished?: string;
297
+ }
298
+
299
+ export const bulkKey = (kind: BulkState["kind"]) => `state:ai:bulk:${kind}`;
300
+ /** Epoch ms until which Wikidata asked to slow down (shared by every entry job, so one Retry-After throttles them all). */
301
+ export const WIKIDATA_BUSY_KEY = "state:ai:wikidataBusyUntil";