@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,915 @@
1
+ /**
2
+ * AI Enrichment admin page: provider settings and Test, queue and bulk runs,
3
+ * review of meta-description and image-text suggestions, entities, and the
4
+ * usage log.
5
+ */
6
+ import { Badge, Banner, Button, Checkbox, Input, InputArea, Loader, Select, Tabs } from "@cloudflare/kumo";
7
+ import { ArrowClockwise, Check, DownloadSimple, Play, Sparkle, Stop, X } from "@phosphor-icons/react";
8
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
9
+ import * as React from "react";
10
+
11
+ import { entitiesToCsv } from "../ai/export.js";
12
+ import { saveFile, siteSlug, today } from "./download.js";
13
+ import { CredentialGuide } from "./guides.js";
14
+ import { SaveBar, isDirty } from "./save-bar.js";
15
+ import { PartSwitches } from "./feature-switches.js";
16
+
17
+ const API = "/_emdash/api/plugins/coywolf-pack/ai";
18
+
19
+ type ProviderId = "workers-ai" | "anthropic" | "openai" | "gemini";
20
+
21
+ interface Settings {
22
+ provider: ProviderId;
23
+ textModel: string;
24
+ visionModel: string;
25
+ apiKeySet: boolean;
26
+ maxCallsPerDay: number;
27
+ jobsPerTick: number;
28
+ collections: string[];
29
+ debounceMinutes: number;
30
+ descriptionsMode: "suggest" | "apply";
31
+ imageMode: "apply" | "suggest";
32
+ imageCaption: boolean;
33
+ imageOverwrite: boolean;
34
+ imageInstructions: string;
35
+ bindingAvailable: boolean;
36
+ imagesBindingAvailable: boolean;
37
+ }
38
+
39
+ interface Bulk {
40
+ kind: "entries" | "media";
41
+ force: boolean;
42
+ collections: string[];
43
+ queued: number;
44
+ skipped: number;
45
+ started: string;
46
+ finished?: string;
47
+ }
48
+
49
+ interface Status {
50
+ settings: Settings;
51
+ providers: Array<{ id: ProviderId; label: string; textModel: string; visionModel: string }>;
52
+ features: { ai: boolean; entities: boolean; descriptions: boolean; imageText: boolean; entitiesStandalone: boolean };
53
+ queue: { entries: number; media: number };
54
+ bulk: { entries: Bulk | null; media: Bulk | null };
55
+ callsToday: number;
56
+ running: boolean;
57
+ collections: string[];
58
+ }
59
+
60
+ interface Entity {
61
+ name: string;
62
+ type: string;
63
+ description: string;
64
+ qid: string;
65
+ wikipedia: string;
66
+ website: string;
67
+ primary: boolean;
68
+ }
69
+
70
+ interface EntryRecord {
71
+ collection: string;
72
+ entryId: string;
73
+ title: string;
74
+ status: "ok" | "error";
75
+ error: string;
76
+ entities: Entity[];
77
+ description: string;
78
+ descriptionStatus: "none" | "suggested" | "applied" | "dismissed";
79
+ updated: string;
80
+ }
81
+
82
+ interface MediaRecord {
83
+ mediaId: string;
84
+ filename: string;
85
+ url: string;
86
+ suggestion: { alt: string; caption: string; title: string } | null;
87
+ status: "suggested" | "applied" | "skipped" | "error" | "dismissed";
88
+ written: string[];
89
+ error: string;
90
+ updated: string;
91
+ }
92
+
93
+ interface UsageRow {
94
+ time: string;
95
+ feature: string;
96
+ provider: string;
97
+ model: string;
98
+ inputTokens: number;
99
+ outputTokens: number;
100
+ ok: boolean;
101
+ error?: string;
102
+ ref?: string;
103
+ }
104
+
105
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
106
+ const dateTime = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" });
107
+
108
+ async function post<T>(path: string, body: unknown = {}): Promise<T> {
109
+ const response = await apiFetch(`${API}/${path}`, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify(body),
113
+ });
114
+ return parseApiResponse<T>(response, "The request failed");
115
+ }
116
+
117
+ async function get<T>(path: string): Promise<T> {
118
+ return parseApiResponse<T>(await apiFetch(`${API}/${path}`), "The request failed");
119
+ }
120
+
121
+ function Section(props: { title: string; description?: React.ReactNode; children: React.ReactNode; actions?: React.ReactNode }) {
122
+ return (
123
+ <section className="rounded-lg border border-kumo-line">
124
+ <div className="flex items-start justify-between gap-4 border-b border-kumo-line p-4">
125
+ <div className="min-w-0">
126
+ <h2 className="text-base font-semibold">{props.title}</h2>
127
+ {props.description && <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>}
128
+ </div>
129
+ {props.actions && <div className="flex shrink-0 gap-2">{props.actions}</div>}
130
+ </div>
131
+ <div className="p-4">{props.children}</div>
132
+ </section>
133
+ );
134
+ }
135
+
136
+ // ── Overview ─────────────────────────────────────────────────────
137
+
138
+ function BulkRow(props: { kind: "entries" | "media"; label: string; enabled: boolean; bulk: Bulk | null; onChanged: (notice: string) => void; onError: (e: string) => void }) {
139
+ const [force, setForce] = React.useState(false);
140
+ const [pending, setPending] = React.useState(false);
141
+ const active = props.bulk && !props.bulk.finished;
142
+ const run = async (path: string, body: unknown, notice: string) => {
143
+ setPending(true);
144
+ try {
145
+ await post(path, body);
146
+ props.onChanged(notice);
147
+ } catch (cause) {
148
+ props.onError(errorText(cause, "The request failed"));
149
+ } finally {
150
+ setPending(false);
151
+ }
152
+ };
153
+ return (
154
+ <div className="flex flex-col gap-3 border-b border-kumo-line py-3 last:border-0 sm:flex-row sm:items-center">
155
+ <div className="min-w-0 flex-1">
156
+ <div className="text-sm font-medium">{props.label}</div>
157
+ <div className="mt-0.5 text-sm text-kumo-subtle">
158
+ {!props.enabled
159
+ ? "Turn the feature on under Plugins → Coywolf Pack first."
160
+ : props.bulk
161
+ ? `${props.bulk.finished ? "Scanned" : "Scanning"}: ${props.bulk.queued} queued, ${props.bulk.skipped} already done or not needed${props.bulk.finished ? ` (finished ${dateTime.format(new Date(props.bulk.finished))})` : "…"}`
162
+ : "No bulk run yet."}
163
+ </div>
164
+ </div>
165
+ <Checkbox
166
+ label={props.kind === "media" ? "Include images that already have text" : "Re-analyze unchanged entries"}
167
+ checked={force}
168
+ disabled={!props.enabled || pending}
169
+ onCheckedChange={(checked: boolean) => setForce(checked)}
170
+ />
171
+ {active ? (
172
+ <Button variant="secondary" icon={<Stop />} disabled={pending} onClick={() => void run("bulk/cancel", { kind: props.kind }, "Bulk run stopped and its queued items removed.")}>
173
+ Stop
174
+ </Button>
175
+ ) : (
176
+ <Button variant="primary" icon={<Play />} disabled={!props.enabled || pending} onClick={() => void run("bulk", { kind: props.kind, force }, "Bulk run started. Items are processed with each scheduled run.")}>
177
+ Run bulk
178
+ </Button>
179
+ )}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ function Overview(props: { status: Status; reload: () => Promise<void>; setNotice: (s: string) => void; setError: (s: string) => void }) {
185
+ const { status } = props;
186
+ const [running, setRunning] = React.useState(false);
187
+ const limit = status.settings.maxCallsPerDay;
188
+ const runNow = async () => {
189
+ setRunning(true);
190
+ try {
191
+ const r = await post<{ processed: number; skipped: number; failed: number; stoppedForLimit: boolean; busy?: boolean }>("run");
192
+ props.setNotice(
193
+ r.busy
194
+ ? "The scheduled run is working on the queue right now."
195
+ : r.stoppedForLimit
196
+ ? "Stopped: today's call limit is reached."
197
+ : `Processed ${r.processed}, skipped ${r.skipped}, failed ${r.failed}.`,
198
+ );
199
+ await props.reload();
200
+ } catch (cause) {
201
+ props.setError(errorText(cause, "Run failed"));
202
+ } finally {
203
+ setRunning(false);
204
+ }
205
+ };
206
+ return (
207
+ <div className="space-y-6">
208
+ <Section
209
+ title="Queue"
210
+ description="Saves, publishes, and uploads add items here. Scheduled runs work through them in the background (every two minutes, or less often if the site's cron runs less often), so saving is never slowed down."
211
+ actions={
212
+ <Button variant="secondary" icon={<Play />} disabled={running || !status.features.ai} onClick={() => void runNow()}>
213
+ {running ? "Running…" : "Run one now"}
214
+ </Button>
215
+ }
216
+ >
217
+ <dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
218
+ <div>
219
+ <dt className="text-kumo-subtle">Entries waiting</dt>
220
+ <dd className="text-lg font-semibold tabular-nums">{status.queue.entries}</dd>
221
+ </div>
222
+ <div>
223
+ <dt className="text-kumo-subtle">Images waiting</dt>
224
+ <dd className="text-lg font-semibold tabular-nums">{status.queue.media}</dd>
225
+ </div>
226
+ <div>
227
+ <dt className="text-kumo-subtle">Model calls today</dt>
228
+ <dd className="text-lg font-semibold tabular-nums">
229
+ {status.callsToday} / {limit}
230
+ </dd>
231
+ </div>
232
+ <div>
233
+ <dt className="text-kumo-subtle">Working now</dt>
234
+ <dd className="text-lg font-semibold">{status.running ? "Yes" : "No"}</dd>
235
+ </div>
236
+ </dl>
237
+ {status.callsToday >= limit && <Banner className="mt-4" variant="default" title="Today's call limit is reached. Work resumes after midnight UTC." />}
238
+ </Section>
239
+ <Section title="Bulk runs" description={`Collections: ${status.collections.join(", ") || "none found"}.`}>
240
+ <BulkRow
241
+ kind="entries"
242
+ label="Entities and meta descriptions for published entries"
243
+ enabled={status.features.entities || status.features.descriptions}
244
+ bulk={status.bulk.entries}
245
+ onChanged={(n) => {
246
+ props.setNotice(n);
247
+ void props.reload();
248
+ }}
249
+ onError={props.setError}
250
+ />
251
+ <BulkRow
252
+ kind="media"
253
+ label="Image text for the media library"
254
+ enabled={status.features.imageText}
255
+ bulk={status.bulk.media}
256
+ onChanged={(n) => {
257
+ props.setNotice(n);
258
+ void props.reload();
259
+ }}
260
+ onError={props.setError}
261
+ />
262
+ </Section>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ // ── Settings ─────────────────────────────────────────────────────
268
+
269
+ function SettingsPanel(props: { status: Status; reload: () => Promise<void>; setNotice: (s: string) => void; setError: (s: string) => void }) {
270
+ const { status } = props;
271
+ // The draft as the saved settings fill it (model names blank when they're the provider's defaults).
272
+ const savedDraft = React.useMemo(() => {
273
+ const s = status.settings;
274
+ const savedProvider = status.providers.find((p) => p.id === s.provider) ?? status.providers[0];
275
+ return {
276
+ aiProvider: s.provider,
277
+ aiModel: s.textModel !== savedProvider.textModel ? s.textModel : "",
278
+ aiVisionModel: s.visionModel !== savedProvider.visionModel ? s.visionModel : "",
279
+ aiApiKey: "",
280
+ aiMaxCallsPerDay: s.maxCallsPerDay,
281
+ aiJobsPerTick: s.jobsPerTick,
282
+ aiCollections: s.collections.join(", "),
283
+ aiDebounceMinutes: s.debounceMinutes,
284
+ aiDescriptionsMode: s.descriptionsMode,
285
+ aiImageMode: s.imageMode,
286
+ aiImageCaption: s.imageCaption,
287
+ aiImageOverwrite: s.imageOverwrite,
288
+ aiImageInstructions: s.imageInstructions,
289
+ };
290
+ }, [status.settings, status.providers]);
291
+ const [draft, setDraft] = React.useState(savedDraft);
292
+ // After a save, take the reloaded settings as the new draft (the server normalizes some fields).
293
+ const justSaved = React.useRef(false);
294
+ React.useEffect(() => {
295
+ if (!justSaved.current) return;
296
+ justSaved.current = false;
297
+ setDraft(savedDraft);
298
+ }, [savedDraft]);
299
+ const dirty = isDirty(draft, savedDraft);
300
+ const provider = status.providers.find((p) => p.id === draft.aiProvider) ?? status.providers[0];
301
+ const sameProvider = draft.aiProvider === status.settings.provider;
302
+ React.useEffect(() => {
303
+ // Show the saved model names when they differ from the provider's defaults.
304
+ setDraft((d) => ({
305
+ ...d,
306
+ aiModel: sameProvider && status.settings.textModel !== provider.textModel ? status.settings.textModel : "",
307
+ aiVisionModel: sameProvider && status.settings.visionModel !== provider.visionModel ? status.settings.visionModel : "",
308
+ }));
309
+ }, [sameProvider, provider, status.settings.textModel, status.settings.visionModel]);
310
+ const set = (patch: Partial<typeof draft>) => setDraft((d) => ({ ...d, ...patch }));
311
+ const [pending, setPending] = React.useState(false);
312
+ // Model pickers: once the saved provider has a key (or the Workers AI binding), list its models.
313
+ const canList = sameProvider && (draft.aiProvider === "workers-ai" ? status.settings.bindingAvailable : status.settings.apiKeySet);
314
+ const [models, setModels] = React.useState<{ id: string; label: string }[] | null>(null);
315
+ const [modelsError, setModelsError] = React.useState<string>();
316
+ React.useEffect(() => {
317
+ if (!canList) {
318
+ setModels(null);
319
+ return;
320
+ }
321
+ let live = true;
322
+ get<{ models: { id: string; label: string }[]; error?: string }>("models")
323
+ .then((r) => {
324
+ if (!live) return;
325
+ setModels(r.models.length ? r.models : null);
326
+ setModelsError(r.error);
327
+ })
328
+ .catch((cause) => live && setModelsError(errorText(cause, "Couldn't list models")));
329
+ return () => {
330
+ live = false;
331
+ };
332
+ }, [canList, draft.aiProvider, status.settings.apiKeySet]);
333
+ /** The model in use for a field: the draft value, else the saved value, else the provider default. */
334
+ const currentModel = (draftValue: string, saved: string, fallback: string) => draftValue || (sameProvider ? saved : "") || fallback;
335
+ const modelItems = (current: string, fallback: string) => {
336
+ const list = models ?? [];
337
+ const items = list.map((m) => ({ value: m.id, label: m.id === fallback ? `${m.label} — default` : m.label }));
338
+ if (!list.some((m) => m.id === current)) items.unshift({ value: current, label: current === fallback ? `${current} — default` : `${current} (current)` });
339
+ return items;
340
+ };
341
+ const [test, setTest] = React.useState<string>();
342
+
343
+ const save = async () => {
344
+ setPending(true);
345
+ try {
346
+ const { aiApiKey, ...rest } = draft;
347
+ await post("settings", aiApiKey.trim() ? { ...rest, aiApiKey } : rest);
348
+ set({ aiApiKey: "" });
349
+ props.setNotice("AI settings saved.");
350
+ justSaved.current = true;
351
+ await props.reload();
352
+ } catch (cause) {
353
+ props.setError(errorText(cause, "Could not save settings"));
354
+ } finally {
355
+ setPending(false);
356
+ }
357
+ };
358
+ const runTest = async () => {
359
+ setTest("Testing…");
360
+ try {
361
+ const r = await post<{ ok: boolean; reply?: string; error?: string; model?: string; ms?: number }>("test");
362
+ setTest(r.ok ? `Connected to ${r.model} (${r.ms} ms). Reply: “${r.reply}”` : `Failed${r.model ? ` (${r.model})` : ""}: ${r.error}`);
363
+ } catch (cause) {
364
+ setTest(errorText(cause, "Test failed"));
365
+ }
366
+ };
367
+ const needsKey = draft.aiProvider !== "workers-ai";
368
+ const num = (v: string, fallback: number) => (Number.isFinite(Number.parseInt(v, 10)) ? Number.parseInt(v, 10) : fallback);
369
+
370
+ return (
371
+ <>
372
+ <form
373
+ id="cw-ai-settings-form"
374
+ className="space-y-6"
375
+ onSubmit={(e) => {
376
+ e.preventDefault();
377
+ void save();
378
+ }}
379
+ >
380
+ <Section
381
+ title="Provider"
382
+ description="Save before testing. The test makes one small call that counts toward today's limit."
383
+ actions={
384
+ <Button type="button" variant="secondary" disabled={!status.features.ai} onClick={() => void runTest()}>
385
+ Test connection
386
+ </Button>
387
+ }
388
+ >
389
+ <div className="grid gap-4 sm:grid-cols-2">
390
+ <Select
391
+ label="Provider"
392
+ value={draft.aiProvider}
393
+ onValueChange={(value: string | null) => set({ aiProvider: (value ?? "workers-ai") as ProviderId })}
394
+ items={status.providers.map((p) => ({ value: p.id, label: p.label }))}
395
+ />
396
+ {needsKey ? (
397
+ <Input
398
+ type="password"
399
+ autoComplete="off"
400
+ label={status.settings.apiKeySet && sameProvider ? "API key (saved)" : "API key"}
401
+ placeholder={status.settings.apiKeySet && sameProvider ? "••••••••••••••••••••••••" : undefined}
402
+ description={status.settings.apiKeySet && sameProvider ? "Type a new key to replace the saved one." : undefined}
403
+ value={draft.aiApiKey}
404
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiApiKey: e.target.value })}
405
+ />
406
+ ) : (
407
+ <p className="self-end text-sm text-kumo-subtle">
408
+ {status.settings.bindingAvailable ? "The Workers AI binding is connected." : "Add an \"ai\" binding to wrangler.jsonc (see docs/ai.md in the Coywolf Pack repository) to use Workers AI."}
409
+ </p>
410
+ )}
411
+ <div style={{ gridColumn: "1 / -1" }}>
412
+ <CredentialGuide id={draft.aiProvider} />
413
+ </div>
414
+ {models ? (
415
+ <>
416
+ <Select
417
+ label="Text model"
418
+ value={currentModel(draft.aiModel, status.settings.textModel, provider.textModel)}
419
+ onValueChange={(value: string | null) => set({ aiModel: !value || value === provider.textModel ? "" : value })}
420
+ items={modelItems(currentModel(draft.aiModel, status.settings.textModel, provider.textModel), provider.textModel)}
421
+ />
422
+ <Select
423
+ label="Image model"
424
+ value={currentModel(draft.aiVisionModel, status.settings.visionModel, provider.visionModel)}
425
+ onValueChange={(value: string | null) => set({ aiVisionModel: !value || value === provider.visionModel ? "" : value })}
426
+ items={modelItems(currentModel(draft.aiVisionModel, status.settings.visionModel, provider.visionModel), provider.visionModel)}
427
+ />
428
+ </>
429
+ ) : (
430
+ <>
431
+ <Input label={`Text model (default ${provider.textModel})`} value={draft.aiModel} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiModel: e.target.value })} />
432
+ <Input label={`Image model (default ${provider.visionModel})`} value={draft.aiVisionModel} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiVisionModel: e.target.value })} />
433
+ </>
434
+ )}
435
+ </div>
436
+ {!models && canList && modelsError && (
437
+ <p className="mt-2 text-sm text-kumo-subtle">Couldn't list this provider's models ({modelsError}); type a model id instead.</p>
438
+ )}
439
+ {!canList && draft.aiProvider !== "workers-ai" && (
440
+ <p className="mt-2 text-sm text-kumo-subtle">Save an API key to pick from this provider's models.</p>
441
+ )}
442
+ {test && (
443
+ <p className="mt-3 text-sm text-kumo-subtle" role="status" aria-live="polite">
444
+ {test}
445
+ </p>
446
+ )}
447
+ </Section>
448
+
449
+ <Section title="Limits and scope">
450
+ <div className="grid gap-4 sm:grid-cols-2">
451
+ <Input type="number" min={1} label="Max model calls per day" value={String(draft.aiMaxCallsPerDay)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiMaxCallsPerDay: num(e.target.value, 200) })} />
452
+ <Input type="number" min={1} max={20} label="Items per scheduled run" value={String(draft.aiJobsPerTick)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiJobsPerTick: num(e.target.value, 3) })} />
453
+ <Input label="Collections (comma-separated; blank = all public)" value={draft.aiCollections} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiCollections: e.target.value })} />
454
+ <Input type="number" min={0} max={120} label="Wait after a save (minutes)" value={String(draft.aiDebounceMinutes)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ aiDebounceMinutes: num(e.target.value, 2) })} />
455
+ </div>
456
+ </Section>
457
+
458
+ <Section title="Meta descriptions and image text">
459
+ <div className="grid gap-4 sm:grid-cols-2">
460
+ <Select
461
+ label="Meta descriptions"
462
+ value={draft.aiDescriptionsMode}
463
+ onValueChange={(value: string | null) => set({ aiDescriptionsMode: value === "apply" ? "apply" : "suggest" })}
464
+ items={[
465
+ { value: "suggest", label: "Suggest for review" },
466
+ { value: "apply", label: "Fill empty descriptions automatically" },
467
+ ]}
468
+ />
469
+ <Select
470
+ label="Image text"
471
+ value={draft.aiImageMode}
472
+ onValueChange={(value: string | null) => set({ aiImageMode: value === "suggest" ? "suggest" : "apply" })}
473
+ items={[
474
+ { value: "apply", label: "Fill empty alt text automatically" },
475
+ { value: "suggest", label: "Suggest for review" },
476
+ ]}
477
+ />
478
+ </div>
479
+ <div className="mt-4 space-y-2">
480
+ <Checkbox label="Also write captions" checked={draft.aiImageCaption} onCheckedChange={(c: boolean) => set({ aiImageCaption: c })} />
481
+ <Checkbox label="Overwrite alt text and captions people already wrote" checked={draft.aiImageOverwrite} onCheckedChange={(c: boolean) => set({ aiImageOverwrite: c })} />
482
+ </div>
483
+ <div className="mt-4">
484
+ <InputArea label="Extra instructions for image text (optional)" rows={3} value={draft.aiImageInstructions} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set({ aiImageInstructions: e.target.value })} />
485
+ </div>
486
+ {!status.settings.imagesBindingAvailable && (
487
+ <p className="mt-3 text-sm text-kumo-subtle">Images over 3.5 MB are skipped unless the site has an Images binding to downscale them.</p>
488
+ )}
489
+ </Section>
490
+ </form>
491
+ <SaveBar form="cw-ai-settings-form" dirty={dirty} saving={pending} onDiscard={() => setDraft(savedDraft)} />
492
+ </>
493
+ );
494
+ }
495
+
496
+ // ── Review lists ─────────────────────────────────────────────────
497
+
498
+ function useList<T>(path: string, filter: string) {
499
+ const [items, setItems] = React.useState<T[]>();
500
+ const [error, setError] = React.useState<string>();
501
+ const load = React.useCallback(async () => {
502
+ setError(undefined);
503
+ try {
504
+ setItems((await post<{ items: T[] }>(path, { filter })).items);
505
+ } catch (cause) {
506
+ setError(errorText(cause, "Could not load"));
507
+ }
508
+ }, [path, filter]);
509
+ React.useEffect(() => {
510
+ void load();
511
+ }, [load]);
512
+ return { items, error, load };
513
+ }
514
+
515
+ function DescriptionRow(props: { entry: EntryRecord; onDone: (notice: string) => void; onError: (e: string) => void }) {
516
+ const { entry } = props;
517
+ const [text, setText] = React.useState(entry.description);
518
+ const [pending, setPending] = React.useState(false);
519
+ const act = async (path: string, body: unknown, notice: string) => {
520
+ setPending(true);
521
+ try {
522
+ await post(path, body);
523
+ props.onDone(notice);
524
+ } catch (cause) {
525
+ props.onError(errorText(cause, "The request failed"));
526
+ } finally {
527
+ setPending(false);
528
+ }
529
+ };
530
+ const ref = { collection: entry.collection, id: entry.entryId };
531
+ return (
532
+ <li className="space-y-2 py-3">
533
+ <div className="flex items-center gap-2 text-sm">
534
+ <span className="font-medium">{entry.title || entry.entryId}</span>
535
+ <Badge variant="outline">{entry.collection}</Badge>
536
+ <Badge variant={entry.descriptionStatus === "applied" ? "secondary" : "outline"}>{entry.descriptionStatus}</Badge>
537
+ </div>
538
+ <InputArea
539
+ label={`Meta description (${[...text].length}/155)`}
540
+ rows={2}
541
+ value={text}
542
+ disabled={pending}
543
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
544
+ />
545
+ <div className="flex gap-2">
546
+ <Button variant="primary" icon={<Check />} disabled={pending || !text.trim()} onClick={() => void act("description/apply", { ...ref, description: text }, "Meta description saved to the SEO panel.")}>
547
+ Apply
548
+ </Button>
549
+ <Button variant="secondary" disabled={pending || !text.trim()} onClick={() => void act("description/apply", { ...ref, description: text, replace: true }, "Meta description replaced.")}>
550
+ Replace existing
551
+ </Button>
552
+ <Button variant="ghost" icon={<X />} disabled={pending} onClick={() => void act("description/dismiss", ref, "Suggestion dismissed.")}>
553
+ Dismiss
554
+ </Button>
555
+ </div>
556
+ </li>
557
+ );
558
+ }
559
+
560
+ function DescriptionsPanel(props: { setNotice: (s: string) => void; setError: (s: string) => void }) {
561
+ const { items, error, load } = useList<EntryRecord>("entries", "suggested");
562
+ return (
563
+ <Section title="Meta description suggestions" description="Edit if needed, then apply. Apply only fills an empty description; Replace overwrites one." actions={<Button variant="ghost" shape="square" icon={<ArrowClockwise />} aria-label="Reload" onClick={() => void load()} />}>
564
+ {error && <Banner variant="error" role="alert" description={error} />}
565
+ {!items && !error ? (
566
+ <Loader />
567
+ ) : items?.length ? (
568
+ <ul className="divide-y divide-kumo-line">
569
+ {items.map((entry) => (
570
+ <DescriptionRow
571
+ key={`${entry.collection}:${entry.entryId}`}
572
+ entry={entry}
573
+ onDone={(n) => {
574
+ props.setNotice(n);
575
+ void load();
576
+ }}
577
+ onError={props.setError}
578
+ />
579
+ ))}
580
+ </ul>
581
+ ) : (
582
+ <p className="text-sm text-kumo-subtle">No suggestions waiting.</p>
583
+ )}
584
+ </Section>
585
+ );
586
+ }
587
+
588
+ function MediaRow(props: { item: MediaRecord; onDone: (notice: string) => void; onError: (e: string) => void }) {
589
+ const { item } = props;
590
+ const [alt, setAlt] = React.useState(item.suggestion?.alt ?? "");
591
+ const [caption, setCaption] = React.useState(item.suggestion?.caption ?? "");
592
+ const [pending, setPending] = React.useState(false);
593
+ const act = async (path: string, body: unknown, notice: string) => {
594
+ setPending(true);
595
+ try {
596
+ await post(path, body);
597
+ props.onDone(notice);
598
+ } catch (cause) {
599
+ props.onError(errorText(cause, "The request failed"));
600
+ } finally {
601
+ setPending(false);
602
+ }
603
+ };
604
+ return (
605
+ <li className="flex flex-col gap-3 py-3 sm:flex-row">
606
+ <img src={item.url} alt="" className="h-24 w-32 shrink-0 rounded object-cover" loading="lazy" />
607
+ <div className="min-w-0 flex-1 space-y-2">
608
+ <div className="flex items-center gap-2 text-sm">
609
+ <span className="truncate font-medium">{item.filename}</span>
610
+ <Badge variant={item.status === "applied" ? "secondary" : "outline"}>{item.status}</Badge>
611
+ {item.written.length > 0 && <span className="text-xs text-kumo-subtle">wrote {item.written.join(", ")}</span>}
612
+ </div>
613
+ {item.error && <p className="text-sm text-kumo-subtle">{item.error}</p>}
614
+ {item.suggestion && (
615
+ <>
616
+ <Input label="Alt text" value={alt} disabled={pending} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAlt(e.target.value)} />
617
+ <Input label="Caption" value={caption} disabled={pending} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCaption(e.target.value)} />
618
+ </>
619
+ )}
620
+ <div className="flex flex-wrap gap-2">
621
+ {item.suggestion && (
622
+ <>
623
+ <Button variant="primary" icon={<Check />} disabled={pending} onClick={() => void act("media/apply", { mediaId: item.mediaId, alt }, "Alt text saved where it was empty.")}>
624
+ Apply alt text
625
+ </Button>
626
+ <Button variant="secondary" disabled={pending} onClick={() => void act("media/apply", { mediaId: item.mediaId, alt, caption, replace: true }, "Alt text and caption saved.")}>
627
+ Replace alt and caption
628
+ </Button>
629
+ </>
630
+ )}
631
+ <Button variant="secondary" icon={<Sparkle />} disabled={pending} onClick={() => void act("media/generate", { mediaId: item.mediaId }, "Queued for new image text.")}>
632
+ Regenerate
633
+ </Button>
634
+ {item.status === "suggested" && (
635
+ <Button variant="ghost" icon={<X />} disabled={pending} onClick={() => void act("media/dismiss", { mediaId: item.mediaId }, "Suggestion dismissed.")}>
636
+ Dismiss
637
+ </Button>
638
+ )}
639
+ </div>
640
+ </div>
641
+ </li>
642
+ );
643
+ }
644
+
645
+ function ImagesPanel(props: { setNotice: (s: string) => void; setError: (s: string) => void }) {
646
+ const [filter, setFilter] = React.useState("suggested");
647
+ const { items, error, load } = useList<MediaRecord>("media", filter);
648
+ return (
649
+ <Section
650
+ title="Image text"
651
+ description="Alt text is written only where it's empty unless you choose Replace. EmDash media has alt text and captions; titles are shown for reference."
652
+ actions={
653
+ <div className="w-44">
654
+ <Select
655
+ aria-label="Show"
656
+ value={filter}
657
+ onValueChange={(v: string | null) => setFilter(v ?? "suggested")}
658
+ items={[
659
+ { value: "suggested", label: "Suggestions" },
660
+ { value: "applied", label: "Applied" },
661
+ { value: "skipped", label: "Skipped" },
662
+ { value: "error", label: "Errors" },
663
+ ]}
664
+ />
665
+ </div>
666
+ }
667
+ >
668
+ {error && <Banner variant="error" role="alert" description={error} />}
669
+ {!items && !error ? (
670
+ <Loader />
671
+ ) : items?.length ? (
672
+ <ul className="divide-y divide-kumo-line">
673
+ {items.map((item) => (
674
+ <MediaRow
675
+ key={item.mediaId}
676
+ item={item}
677
+ onDone={(n) => {
678
+ props.setNotice(n);
679
+ void load();
680
+ }}
681
+ onError={props.setError}
682
+ />
683
+ ))}
684
+ </ul>
685
+ ) : (
686
+ <p className="text-sm text-kumo-subtle">Nothing here yet.</p>
687
+ )}
688
+ </Section>
689
+ );
690
+ }
691
+
692
+ function EntitiesPanel(props: { setNotice: (s: string) => void; setError: (s: string) => void }) {
693
+ const [filter, setFilter] = React.useState("all");
694
+ const { items, error, load } = useList<EntryRecord>("entries", filter);
695
+ const [exporting, setExporting] = React.useState(false);
696
+ /** Every analyzed entry (all pages, whatever the filter), as CSV. */
697
+ const exportCsv = async () => {
698
+ setExporting(true);
699
+ try {
700
+ const all: EntryRecord[] = [];
701
+ let cursor: string | undefined;
702
+ for (let page = 0; page < 1000; page++) {
703
+ const result = await post<{ items: EntryRecord[]; cursor?: string; hasMore: boolean }>("entries", { filter: "all", ...(cursor ? { cursor } : {}) });
704
+ all.push(...result.items);
705
+ if (!result.hasMore || !result.cursor) break;
706
+ cursor = result.cursor;
707
+ }
708
+ saveFile(entitiesToCsv(all), `ai-entities-${siteSlug() || "site"}-${today()}.csv`, "text/csv");
709
+ props.setNotice(`Exported ${all.length.toLocaleString()} analyzed ${all.length === 1 ? "entry" : "entries"}.`);
710
+ } catch (cause) {
711
+ props.setError(errorText(cause, "Export failed"));
712
+ } finally {
713
+ setExporting(false);
714
+ }
715
+ };
716
+ const reanalyze = async (entry: EntryRecord) => {
717
+ try {
718
+ await post("entry/reanalyze", { collection: entry.collection, id: entry.entryId });
719
+ props.setNotice(`Queued ${entry.title || entry.entryId} for a fresh analysis.`);
720
+ } catch (cause) {
721
+ props.setError(errorText(cause, "Could not queue"));
722
+ }
723
+ };
724
+ return (
725
+ <Section
726
+ title="Analyzed entries"
727
+ description="Primary subjects become Schema.org about; passing references become mentions. Each links to Wikidata and, when there is one, Wikipedia and the official website."
728
+ actions={
729
+ <div className="flex gap-2">
730
+ <div className="w-36">
731
+ <Select
732
+ aria-label="Show"
733
+ value={filter}
734
+ onValueChange={(v: string | null) => setFilter(v ?? "all")}
735
+ items={[
736
+ { value: "all", label: "All" },
737
+ { value: "error", label: "Errors" },
738
+ ]}
739
+ />
740
+ </div>
741
+ <Button variant="secondary" icon={<DownloadSimple />} disabled={exporting || !items?.length} onClick={() => void exportCsv()}>
742
+ {exporting ? "Exporting…" : "Export CSV"}
743
+ </Button>
744
+ <Button variant="ghost" shape="square" icon={<ArrowClockwise />} aria-label="Reload" onClick={() => void load()} />
745
+ </div>
746
+ }
747
+ >
748
+ {error && <Banner variant="error" role="alert" description={error} />}
749
+ {!items && !error ? (
750
+ <Loader />
751
+ ) : items?.length ? (
752
+ <ul className="divide-y divide-kumo-line">
753
+ {items.map((entry) => (
754
+ <li key={`${entry.collection}:${entry.entryId}`} className="py-3 text-sm">
755
+ <div className="flex items-center gap-2">
756
+ <span className="font-medium">{entry.title || entry.entryId}</span>
757
+ <Badge variant="outline">{entry.collection}</Badge>
758
+ <span className="text-xs text-kumo-subtle">{dateTime.format(new Date(entry.updated))}</span>
759
+ <Button className="ms-auto" variant="ghost" size="sm" icon={<ArrowClockwise />} onClick={() => void reanalyze(entry)}>
760
+ Re-analyze
761
+ </Button>
762
+ </div>
763
+ {entry.status === "error" && <p className="mt-1 text-kumo-subtle">Failed: {entry.error}</p>}
764
+ {entry.entities.length > 0 ? (
765
+ <div className="mt-2 flex flex-wrap gap-1.5">
766
+ {entry.entities.map((e) => (
767
+ <a key={e.qid} href={`https://www.wikidata.org/wiki/${e.qid}`} target="_blank" rel="noreferrer" title={`${e.type} · ${e.qid}`}>
768
+ <Badge variant={e.primary ? "secondary" : "outline"}>
769
+ {e.name}
770
+ {e.primary ? " (about)" : ""}
771
+ </Badge>
772
+ </a>
773
+ ))}
774
+ </div>
775
+ ) : (
776
+ entry.status === "ok" && <p className="mt-1 text-kumo-subtle">No verifiable entities.</p>
777
+ )}
778
+ </li>
779
+ ))}
780
+ </ul>
781
+ ) : (
782
+ <p className="text-sm text-kumo-subtle">No entries analyzed yet.</p>
783
+ )}
784
+ </Section>
785
+ );
786
+ }
787
+
788
+ function UsagePanel() {
789
+ const [rows, setRows] = React.useState<UsageRow[]>();
790
+ const [error, setError] = React.useState<string>();
791
+ React.useEffect(() => {
792
+ get<{ items: UsageRow[] }>("usage")
793
+ .then((d) => setRows(d.items))
794
+ .catch((cause) => setError(errorText(cause, "Could not load the usage log")));
795
+ }, []);
796
+ const totals = (rows ?? []).reduce((t, r) => ({ input: t.input + r.inputTokens, output: t.output + r.outputTokens }), { input: 0, output: 0 });
797
+ return (
798
+ <Section title="Usage log" description={`The latest 100 model calls (kept 30 days). Tokens shown: ${totals.input.toLocaleString()} in, ${totals.output.toLocaleString()} out.`}>
799
+ {error && <Banner variant="error" role="alert" description={error} />}
800
+ {!rows && !error ? (
801
+ <Loader />
802
+ ) : rows?.length ? (
803
+ <div className="overflow-x-auto">
804
+ <table className="w-full text-sm">
805
+ <thead className="text-left text-kumo-subtle">
806
+ <tr>
807
+ <th className="py-1 pe-3 font-medium">Time</th>
808
+ <th className="py-1 pe-3 font-medium">Feature</th>
809
+ <th className="py-1 pe-3 font-medium">Model</th>
810
+ <th className="py-1 pe-3 text-end font-medium">In</th>
811
+ <th className="py-1 pe-3 text-end font-medium">Out</th>
812
+ <th className="py-1 font-medium">Result</th>
813
+ </tr>
814
+ </thead>
815
+ <tbody>
816
+ {rows.map((r) => (
817
+ <tr key={`${r.time}${r.ref ?? ""}`} className="border-t border-kumo-line">
818
+ <td className="py-1 pe-3 whitespace-nowrap">{dateTime.format(new Date(r.time))}</td>
819
+ <td className="py-1 pe-3">{r.feature}</td>
820
+ <td className="py-1 pe-3 font-mono text-xs">{r.model}</td>
821
+ <td className="py-1 pe-3 text-end tabular-nums">{r.inputTokens.toLocaleString()}</td>
822
+ <td className="py-1 pe-3 text-end tabular-nums">{r.outputTokens.toLocaleString()}</td>
823
+ <td className="py-1">
824
+ {r.ok ? (
825
+ "OK"
826
+ ) : (
827
+ <>
828
+ <span title={r.error}>Failed</span>
829
+ {r.error && <span className="block max-w-xs text-xs break-words text-kumo-subtle">{r.error}</span>}
830
+ </>
831
+ )}
832
+ </td>
833
+ </tr>
834
+ ))}
835
+ </tbody>
836
+ </table>
837
+ </div>
838
+ ) : (
839
+ <p className="text-sm text-kumo-subtle">No calls yet.</p>
840
+ )}
841
+ </Section>
842
+ );
843
+ }
844
+
845
+ // ── Page ─────────────────────────────────────────────────────────
846
+
847
+ export function AiPage() {
848
+ const [status, setStatus] = React.useState<Status>();
849
+ const [error, setError] = React.useState<string>();
850
+ const [notice, setNotice] = React.useState<string>();
851
+ const [tab, setTab] = React.useState("overview");
852
+
853
+ const load = React.useCallback(async () => {
854
+ try {
855
+ setStatus(await get<Status>("status"));
856
+ } catch (cause) {
857
+ setError(errorText(cause, "Could not load AI Enrichment"));
858
+ }
859
+ }, []);
860
+ React.useEffect(() => {
861
+ void load();
862
+ }, [load]);
863
+
864
+ const say = (n: string) => {
865
+ setError(undefined);
866
+ setNotice(n);
867
+ };
868
+
869
+ return (
870
+ <div className="space-y-6">
871
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
872
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">AI Enrichment</h1>
873
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
874
+ Entities for Schema.org verified against Wikidata, meta descriptions, and image alt text. Everything runs in the background after saves and uploads, within your daily call limit.
875
+ </p>
876
+ </header>
877
+
878
+ <PartSwitches module="ai" />
879
+
880
+ <div aria-live="polite">
881
+ {notice && <Banner variant="default" role="status" title={notice} />}
882
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
883
+ </div>
884
+
885
+ {status && !status.features.ai && <Banner variant="default" title="AI Enrichment is off. Turn it and its features on under Plugins → Coywolf Pack." />}
886
+
887
+ {!status && !error ? (
888
+ <div className="flex justify-center py-12">
889
+ <Loader />
890
+ </div>
891
+ ) : status ? (
892
+ <>
893
+ <Tabs
894
+ value={tab}
895
+ onValueChange={setTab}
896
+ tabs={[
897
+ { value: "overview", label: "Overview" },
898
+ { value: "settings", label: "Settings" },
899
+ { value: "descriptions", label: "Descriptions" },
900
+ { value: "images", label: "Images" },
901
+ { value: "entities", label: "Entities" },
902
+ { value: "usage", label: "Usage" },
903
+ ]}
904
+ />
905
+ {tab === "overview" && <Overview status={status} reload={load} setNotice={say} setError={setError} />}
906
+ {tab === "settings" && <SettingsPanel status={status} reload={load} setNotice={say} setError={setError} />}
907
+ {tab === "descriptions" && <DescriptionsPanel setNotice={say} setError={setError} />}
908
+ {tab === "images" && <ImagesPanel setNotice={say} setError={setError} />}
909
+ {tab === "entities" && <EntitiesPanel setNotice={say} setError={setError} />}
910
+ {tab === "usage" && <UsagePanel />}
911
+ </>
912
+ ) : null}
913
+ </div>
914
+ );
915
+ }