@agent-native/core 0.92.5 → 0.92.6

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 (147) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/docs/content/template-assets.mdx +139 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +96 -6
  6. package/corpus/core/src/client/AssistantChat.tsx +127 -15
  7. package/corpus/core/src/client/chat/tool-call-display.tsx +9 -3
  8. package/corpus/core/src/client/org/TeamPage.tsx +274 -177
  9. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  10. package/corpus/core/src/client/settings/SettingsPanel.tsx +1 -1
  11. package/corpus/core/src/client/settings/SettingsTabsPage.tsx +49 -1
  12. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  13. package/corpus/core/src/demo/config.ts +3 -3
  14. package/corpus/core/src/demo/redact.ts +720 -0
  15. package/corpus/core/src/extensions/url-safety.ts +13 -1
  16. package/corpus/core/src/localization/default-messages.ts +7 -0
  17. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +7 -0
  18. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +7 -0
  19. package/corpus/core/src/templates/default/app/i18n/en-US.ts +7 -0
  20. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +7 -0
  21. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +7 -0
  22. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +7 -0
  23. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +7 -0
  24. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +7 -0
  25. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +7 -0
  26. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +7 -0
  27. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +7 -0
  28. package/corpus/templates/analytics/AGENTS.md +6 -3
  29. package/corpus/templates/analytics/changelog/2026-07-09-uptime-monitors-use-scheduled-workers-in-production-serverless.md +6 -0
  30. package/corpus/templates/analytics/docs/uptime-monitoring.md +5 -0
  31. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +14 -2
  32. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +35 -0
  33. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +19 -0
  34. package/corpus/templates/assets/AGENTS.md +13 -0
  35. package/corpus/templates/assets/actions/_generation-preset-settings.ts +73 -0
  36. package/corpus/templates/assets/actions/_preset-skeleton-validation.ts +63 -0
  37. package/corpus/templates/assets/actions/create-generation-preset.ts +22 -8
  38. package/corpus/templates/assets/actions/duplicate-library.ts +49 -1
  39. package/corpus/templates/assets/actions/generate-image-batch.ts +14 -1
  40. package/corpus/templates/assets/actions/generate-image.ts +195 -26
  41. package/corpus/templates/assets/actions/import-asset-from-url.ts +323 -0
  42. package/corpus/templates/assets/actions/list-generation-presets.ts +1 -1
  43. package/corpus/templates/assets/actions/rerun-generation-run.ts +37 -0
  44. package/corpus/templates/assets/actions/update-generation-preset.ts +19 -1
  45. package/corpus/templates/assets/app/i18n/ar-SA.ts +1 -0
  46. package/corpus/templates/assets/app/i18n/de-DE.ts +1 -0
  47. package/corpus/templates/assets/app/i18n/es-ES.ts +1 -0
  48. package/corpus/templates/assets/app/i18n/fr-FR.ts +1 -0
  49. package/corpus/templates/assets/app/i18n/hi-IN.ts +1 -0
  50. package/corpus/templates/assets/app/i18n/ja-JP.ts +1 -0
  51. package/corpus/templates/assets/app/i18n/ko-KR.ts +1 -0
  52. package/corpus/templates/assets/app/i18n/pt-BR.ts +1 -0
  53. package/corpus/templates/assets/app/i18n/zh-CN.ts +1 -0
  54. package/corpus/templates/assets/app/i18n/zh-TW.ts +41 -0
  55. package/corpus/templates/assets/app/i18n-data.ts +93 -0
  56. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +66 -11
  57. package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +959 -24
  58. package/corpus/templates/assets/changelog/2026-07-07-preset-skeleton-controls-fit-correctly-on-narrower-edit-page.md +6 -0
  59. package/corpus/templates/assets/changelog/2026-07-09-import-reference-images-from-url.md +6 -0
  60. package/corpus/templates/assets/changelog/2026-07-09-preset-reference-board.md +6 -0
  61. package/corpus/templates/assets/server/handlers/assets.ts +7 -27
  62. package/corpus/templates/assets/server/lib/generation.ts +83 -2
  63. package/corpus/templates/assets/server/lib/preset-chat-context.ts +17 -4
  64. package/corpus/templates/assets/server/lib/preset-references.ts +182 -0
  65. package/corpus/templates/assets/server/lib/upload-validation.ts +38 -0
  66. package/corpus/templates/assets/shared/api.ts +20 -0
  67. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +6 -0
  68. package/corpus/templates/calendar/actions/create-event.ts +8 -2
  69. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +22 -7
  70. package/corpus/templates/calendar/changelog/2026-07-09-calendar-events-with-guests-now-automatically-get-a-google-m.md +6 -0
  71. package/corpus/templates/calendar/changelog/2026-07-09-new-events-now-mark-the-creator-rsvp-as-yes.md +6 -0
  72. package/corpus/templates/calendar/server/handlers/events.ts +9 -2
  73. package/corpus/templates/calendar/server/lib/event-video-conferencing.ts +58 -0
  74. package/corpus/templates/calendar/server/lib/google-calendar.ts +1 -0
  75. package/corpus/templates/clips/changelog/2026-07-09-meeting-reminder-popovers-can-now-be-dismissed-with-a-top-le.md +6 -0
  76. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +3 -1
  77. package/corpus/templates/clips/desktop/src/styles.css +13 -9
  78. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +5 -2
  79. package/dist/agent/production-agent.d.ts +34 -1
  80. package/dist/agent/production-agent.d.ts.map +1 -1
  81. package/dist/agent/production-agent.js +58 -5
  82. package/dist/agent/production-agent.js.map +1 -1
  83. package/dist/client/AssistantChat.d.ts +3 -1
  84. package/dist/client/AssistantChat.d.ts.map +1 -1
  85. package/dist/client/AssistantChat.js +96 -15
  86. package/dist/client/AssistantChat.js.map +1 -1
  87. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  88. package/dist/client/chat/tool-call-display.js +5 -1
  89. package/dist/client/chat/tool-call-display.js.map +1 -1
  90. package/dist/client/org/TeamPage.d.ts.map +1 -1
  91. package/dist/client/org/TeamPage.js +32 -13
  92. package/dist/client/org/TeamPage.js.map +1 -1
  93. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  94. package/dist/client/settings/DemoModeSection.js +4 -4
  95. package/dist/client/settings/DemoModeSection.js.map +1 -1
  96. package/dist/client/settings/SettingsPanel.js +1 -1
  97. package/dist/client/settings/SettingsPanel.js.map +1 -1
  98. package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
  99. package/dist/client/settings/SettingsTabsPage.js +38 -2
  100. package/dist/client/settings/SettingsTabsPage.js.map +1 -1
  101. package/dist/collab/awareness.d.ts +2 -2
  102. package/dist/collab/awareness.d.ts.map +1 -1
  103. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  104. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  105. package/dist/demo/config.js +3 -3
  106. package/dist/demo/config.js.map +1 -1
  107. package/dist/demo/redact.d.ts +1 -1
  108. package/dist/demo/redact.d.ts.map +1 -1
  109. package/dist/demo/redact.js +0 -0
  110. package/dist/demo/redact.js.map +1 -1
  111. package/dist/extensions/url-safety.d.ts +5 -0
  112. package/dist/extensions/url-safety.d.ts.map +1 -1
  113. package/dist/extensions/url-safety.js +10 -0
  114. package/dist/extensions/url-safety.js.map +1 -1
  115. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  116. package/dist/localization/default-messages.d.ts +7 -0
  117. package/dist/localization/default-messages.d.ts.map +1 -1
  118. package/dist/localization/default-messages.js +7 -0
  119. package/dist/localization/default-messages.js.map +1 -1
  120. package/dist/observability/routes.d.ts +5 -5
  121. package/dist/progress/routes.d.ts +1 -1
  122. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  123. package/dist/resources/handlers.d.ts +1 -1
  124. package/dist/templates/default/app/i18n/ar-SA.ts +7 -0
  125. package/dist/templates/default/app/i18n/de-DE.ts +7 -0
  126. package/dist/templates/default/app/i18n/en-US.ts +7 -0
  127. package/dist/templates/default/app/i18n/es-ES.ts +7 -0
  128. package/dist/templates/default/app/i18n/fr-FR.ts +7 -0
  129. package/dist/templates/default/app/i18n/hi-IN.ts +7 -0
  130. package/dist/templates/default/app/i18n/ja-JP.ts +7 -0
  131. package/dist/templates/default/app/i18n/ko-KR.ts +7 -0
  132. package/dist/templates/default/app/i18n/pt-BR.ts +7 -0
  133. package/dist/templates/default/app/i18n/zh-CN.ts +7 -0
  134. package/dist/templates/default/app/i18n/zh-TW.ts +7 -0
  135. package/docs/content/template-assets.mdx +139 -0
  136. package/package.json +1 -1
  137. package/src/templates/default/app/i18n/ar-SA.ts +7 -0
  138. package/src/templates/default/app/i18n/de-DE.ts +7 -0
  139. package/src/templates/default/app/i18n/en-US.ts +7 -0
  140. package/src/templates/default/app/i18n/es-ES.ts +7 -0
  141. package/src/templates/default/app/i18n/fr-FR.ts +7 -0
  142. package/src/templates/default/app/i18n/hi-IN.ts +7 -0
  143. package/src/templates/default/app/i18n/ja-JP.ts +7 -0
  144. package/src/templates/default/app/i18n/ko-KR.ts +7 -0
  145. package/src/templates/default/app/i18n/pt-BR.ts +7 -0
  146. package/src/templates/default/app/i18n/zh-CN.ts +7 -0
  147. package/src/templates/default/app/i18n/zh-TW.ts +7 -0
@@ -0,0 +1,323 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
+ import { and, eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+
7
+ import { getDb, schema } from "../server/db/index.js";
8
+ import { createAssetFromBuffer } from "../server/lib/assets.js";
9
+ import { getObject } from "../server/lib/storage.js";
10
+ import {
11
+ filterDuplicateAssetUploads,
12
+ hashAssetBuffer,
13
+ } from "../server/lib/upload-dedupe.js";
14
+ import {
15
+ hasAllowedSignature,
16
+ IMAGE_MIME_TYPES,
17
+ MAX_IMAGE_UPLOAD_BYTES,
18
+ } from "../server/lib/upload-validation.js";
19
+ import { IMAGE_CATEGORIES } from "../shared/api.js";
20
+ import type { ImageCategory } from "../shared/api.js";
21
+ import { serializeAsset } from "./_helpers.js";
22
+
23
+ const IMPORTABLE_REFERENCE_ROLES = [
24
+ "style_reference",
25
+ "subject_reference",
26
+ "product_reference",
27
+ "background_reference",
28
+ "logo_reference",
29
+ "diagram_reference",
30
+ ] as const;
31
+
32
+ const FETCH_TIMEOUT_MS = 15_000;
33
+ const MAX_REDIRECTS = 3;
34
+
35
+ // Mirrors the upload route's category↔role mapping so imported references
36
+ // appear in the same category-filtered views as uploaded equivalents.
37
+ const DEFAULT_CATEGORY_BY_ROLE: Record<
38
+ (typeof IMPORTABLE_REFERENCE_ROLES)[number],
39
+ ImageCategory
40
+ > = {
41
+ style_reference: "style-only",
42
+ subject_reference: "other",
43
+ product_reference: "product",
44
+ background_reference: "other",
45
+ logo_reference: "logo",
46
+ diagram_reference: "diagram",
47
+ };
48
+
49
+ function normalizedImageMimeType(contentType: string | null): string {
50
+ return contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
51
+ }
52
+
53
+ async function assertCollectionBelongsToLibrary(
54
+ collectionId: string,
55
+ libraryId: string,
56
+ ) {
57
+ const [collection] = await getDb()
58
+ .select({
59
+ id: schema.assetCollections.id,
60
+ libraryId: schema.assetCollections.libraryId,
61
+ })
62
+ .from(schema.assetCollections)
63
+ .where(eq(schema.assetCollections.id, collectionId))
64
+ .limit(1);
65
+ if (!collection || collection.libraryId !== libraryId) {
66
+ throw new Error("collectionId does not belong to this library.");
67
+ }
68
+ }
69
+
70
+ async function assertFolderBelongsToLibrary(
71
+ folderId: string,
72
+ libraryId: string,
73
+ ) {
74
+ const [folder] = await getDb()
75
+ .select({
76
+ id: schema.assetFolders.id,
77
+ libraryId: schema.assetFolders.libraryId,
78
+ })
79
+ .from(schema.assetFolders)
80
+ .where(eq(schema.assetFolders.id, folderId))
81
+ .limit(1);
82
+ if (!folder || folder.libraryId !== libraryId) {
83
+ throw new Error("folderId does not belong to this library.");
84
+ }
85
+ }
86
+
87
+ function validateHttpsUrl(url: string) {
88
+ const parsed = new URL(url);
89
+ if (parsed.protocol !== "https:") {
90
+ throw new Error("Only HTTPS image URLs can be imported.");
91
+ }
92
+ if (parsed.username || parsed.password) {
93
+ throw new Error("URLs with embedded credentials cannot be imported.");
94
+ }
95
+ }
96
+
97
+ // Query params that carry bearer credentials (S3/GCS presigning, Azure SAS,
98
+ // generic tokens). Provenance drops the query when one is present so signed
99
+ // URLs do not become durable asset metadata; the fetch still uses the full URL.
100
+ const CREDENTIAL_QUERY_PARAM_RE =
101
+ /^(x-amz-|x-goog-)|^(sig|signature|token|access[-_]?token|auth|authorization|expires|policy|credential|apikey|api[-_]?key|key|secret|session|sv|se|sp|st|spr|sr|skoid)$/i;
102
+
103
+ function sanitizeProvenanceUrl(url: string): string {
104
+ const parsed = new URL(url);
105
+ parsed.username = "";
106
+ parsed.password = "";
107
+ parsed.hash = "";
108
+ for (const param of parsed.searchParams.keys()) {
109
+ if (CREDENTIAL_QUERY_PARAM_RE.test(param)) {
110
+ parsed.search = "";
111
+ break;
112
+ }
113
+ }
114
+ return parsed.toString();
115
+ }
116
+
117
+ /** Release an unread response body so its connection is not held until GC. */
118
+ async function discardResponseBody(response: Response) {
119
+ await response.body?.cancel().catch(() => {});
120
+ }
121
+
122
+ async function readResponseBytes(response: Response): Promise<Buffer> {
123
+ const contentLength = response.headers.get("content-length");
124
+ if (contentLength && Number(contentLength) > MAX_IMAGE_UPLOAD_BYTES) {
125
+ await discardResponseBody(response);
126
+ throw new Error("Image too large (max 25 MB).");
127
+ }
128
+
129
+ const reader = response.body?.getReader?.();
130
+ if (!reader) {
131
+ const buffer = Buffer.from(await response.arrayBuffer());
132
+ if (buffer.byteLength > MAX_IMAGE_UPLOAD_BYTES) {
133
+ throw new Error("Image too large (max 25 MB).");
134
+ }
135
+ return buffer;
136
+ }
137
+
138
+ const chunks: Uint8Array[] = [];
139
+ let total = 0;
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ if (!value) continue;
144
+ total += value.byteLength;
145
+ if (total > MAX_IMAGE_UPLOAD_BYTES) {
146
+ await reader.cancel().catch(() => {});
147
+ throw new Error("Image too large (max 25 MB).");
148
+ }
149
+ chunks.push(value);
150
+ }
151
+
152
+ return Buffer.concat(
153
+ chunks.map((chunk) => Buffer.from(chunk)),
154
+ total,
155
+ );
156
+ }
157
+
158
+ async function fetchImageBytes(url: string): Promise<{
159
+ buffer: Buffer;
160
+ mimeType: string;
161
+ }> {
162
+ let response: Response;
163
+ try {
164
+ response = await ssrfSafeFetch(
165
+ url,
166
+ { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
167
+ { maxRedirects: MAX_REDIRECTS, httpsOnly: true },
168
+ );
169
+ } catch {
170
+ throw new Error("Could not fetch that URL.");
171
+ }
172
+
173
+ if (!response.ok) {
174
+ await discardResponseBody(response);
175
+ throw new Error(`Could not fetch that URL (${response.status}).`);
176
+ }
177
+
178
+ const mimeType = normalizedImageMimeType(
179
+ response.headers.get("content-type"),
180
+ );
181
+ if (!IMAGE_MIME_TYPES.has(mimeType)) {
182
+ await discardResponseBody(response);
183
+ throw new Error("Only PNG, JPEG, WebP, and AVIF images are supported.");
184
+ }
185
+
186
+ const buffer = await readResponseBytes(response);
187
+ if (!hasAllowedSignature(mimeType, buffer)) {
188
+ throw new Error(
189
+ "The fetched bytes do not match the advertised image type.",
190
+ );
191
+ }
192
+
193
+ return { buffer, mimeType };
194
+ }
195
+
196
+ /**
197
+ * Same dedupe scope as the upload route: reference assets in this library
198
+ * with the same role. Returns the existing asset when the fetched bytes are
199
+ * already stored, so repeat imports are idempotent instead of duplicating
200
+ * the asset row and blob.
201
+ */
202
+ async function findDuplicateReferenceAsset(input: {
203
+ libraryId: string;
204
+ role: (typeof IMPORTABLE_REFERENCE_ROLES)[number];
205
+ buffer: Buffer;
206
+ mimeType: string;
207
+ contentHash: string;
208
+ filename: string | null;
209
+ }): Promise<typeof schema.assets.$inferSelect | null> {
210
+ const db = getDb();
211
+ const existingReferenceAssets = await db
212
+ .select({
213
+ id: schema.assets.id,
214
+ title: schema.assets.title,
215
+ mediaType: schema.assets.mediaType,
216
+ mimeType: schema.assets.mimeType,
217
+ sizeBytes: schema.assets.sizeBytes,
218
+ metadata: schema.assets.metadata,
219
+ objectKey: schema.assets.objectKey,
220
+ })
221
+ .from(schema.assets)
222
+ .where(
223
+ and(
224
+ eq(schema.assets.libraryId, input.libraryId),
225
+ eq(schema.assets.status, "reference"),
226
+ eq(schema.assets.role, input.role),
227
+ ),
228
+ );
229
+ const { skippedDuplicates } = await filterDuplicateAssetUploads({
230
+ files: [
231
+ {
232
+ altText: null,
233
+ buffer: input.buffer,
234
+ contentHash: input.contentHash,
235
+ filename: input.filename,
236
+ mediaType: "image",
237
+ metadata: {},
238
+ mimeType: input.mimeType,
239
+ title: "",
240
+ },
241
+ ],
242
+ existingAssets: existingReferenceAssets,
243
+ readExistingAssetBuffer: (asset) => getObject(asset.objectKey),
244
+ });
245
+ const duplicate = skippedDuplicates.find(
246
+ (skip) => skip.reason === "existing-asset" && skip.assetId,
247
+ );
248
+ if (!duplicate?.assetId) return null;
249
+ const [asset] = await db
250
+ .select()
251
+ .from(schema.assets)
252
+ .where(eq(schema.assets.id, duplicate.assetId))
253
+ .limit(1);
254
+ return asset ?? null;
255
+ }
256
+
257
+ export default defineAction({
258
+ description:
259
+ "Import an external image URL into a library as a reference asset (style, subject, product, background, logo, or diagram reference). Use for ingesting brand imagery found on the web — e.g. a blog hero or logo — so it can be pinned to preset reference boards or set as the canonical logo.",
260
+ schema: z.object({
261
+ libraryId: z.string(),
262
+ url: z.string().url(),
263
+ role: z.enum(IMPORTABLE_REFERENCE_ROLES).default("style_reference"),
264
+ category: z
265
+ .enum(IMAGE_CATEGORIES)
266
+ .optional()
267
+ .describe(
268
+ "Deliverable category for filtered views (e.g. hero, campaign). Defaults to the category matching the chosen role.",
269
+ ),
270
+ collectionId: z.string().nullable().optional(),
271
+ folderId: z.string().nullable().optional(),
272
+ title: z.string().max(200).optional(),
273
+ description: z.string().max(1000).optional(),
274
+ }),
275
+ run: async (args) => {
276
+ const { libraryId, url, role, category, title, description } = args;
277
+ // An empty-string id means "unassigned", never a real row — normalize to
278
+ // null so it can't skip membership validation yet still land in the row.
279
+ const collectionId = args.collectionId || null;
280
+ const folderId = args.folderId || null;
281
+ await assertAccess("asset-library", libraryId, "editor");
282
+ validateHttpsUrl(url);
283
+ if (collectionId) {
284
+ await assertCollectionBelongsToLibrary(collectionId, libraryId);
285
+ }
286
+ if (folderId) {
287
+ await assertFolderBelongsToLibrary(folderId, libraryId);
288
+ }
289
+
290
+ const { buffer, mimeType } = await fetchImageBytes(url);
291
+ const contentHash = hashAssetBuffer(buffer);
292
+ const duplicate = await findDuplicateReferenceAsset({
293
+ libraryId,
294
+ role,
295
+ buffer,
296
+ mimeType,
297
+ contentHash,
298
+ filename: new URL(url).pathname.split("/").pop() || null,
299
+ });
300
+ if (duplicate) {
301
+ return { ...serializeAsset(duplicate), deduplicated: true };
302
+ }
303
+
304
+ const provenanceUrl = sanitizeProvenanceUrl(url);
305
+ const asset = await createAssetFromBuffer({
306
+ libraryId,
307
+ collectionId,
308
+ folderId,
309
+ buffer,
310
+ mimeType,
311
+ mediaType: "image",
312
+ role,
313
+ category: category ?? DEFAULT_CATEGORY_BY_ROLE[role],
314
+ status: "reference",
315
+ title: title ?? null,
316
+ description: description ?? null,
317
+ sourceUrl: provenanceUrl,
318
+ metadata: { contentHash, importedFrom: provenanceUrl },
319
+ });
320
+
321
+ return serializeAsset(asset);
322
+ },
323
+ });
@@ -7,7 +7,7 @@ import { requireLibrary, serializeGenerationPreset } from "./_helpers.js";
7
7
 
8
8
  export default defineAction({
9
9
  description:
10
- "List reusable generation presets for a library, such as social images, blog heroes, and diagrams.",
10
+ "List reusable generation presets for a library, such as social images, blog heroes, and diagrams. This is the required first step for ad-hoc generation requests: if a preset matches, generate with its presetId instead of presetless settings. Results include settings.presetReferences, the reference board used to match named people, products, and backdrops in the request.",
11
11
  schema: z.object({
12
12
  libraryId: z.string(),
13
13
  collectionId: z.string().optional(),
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
8
8
  import { parseJson } from "../server/lib/json.js";
9
+ import { normalizePresetReferences } from "../server/lib/preset-references.js";
9
10
  import { requireGenerationSessionInLibrary } from "./_helpers.js";
10
11
  import generateImage from "./generate-image.js";
11
12
 
@@ -58,6 +59,7 @@ export default defineAction({
58
59
  subjectAssetId?: string;
59
60
  embeddedText?: string | null;
60
61
  textPlacement?: string | null;
62
+ boardAssignments?: Record<string, string[]>;
61
63
  };
62
64
  includeLogo?: boolean;
63
65
  categories?: string[];
@@ -71,6 +73,40 @@ export default defineAction({
71
73
  }>(run.metadata, {});
72
74
  const categories =
73
75
  metadata.settingsUsed?.categories ?? metadata.categories ?? undefined;
76
+ let presetReferenceFills:
77
+ | Array<{ referenceId: string; assetIds: string[] }>
78
+ | undefined;
79
+ // Reruns treat the CURRENT preset as authoritative by design: saved
80
+ // boardAssignments replay only onto entries that still exist and are
81
+ // still variable. Entries the designer has since removed, renamed, or
82
+ // converted to fixed re-resolve from today's preset instead of
83
+ // resurrecting the original run's images — a rerun must never bypass
84
+ // the designer's current board definition.
85
+ const boardAssignments = metadata.settingsUsed?.boardAssignments;
86
+ if (run.presetId && boardAssignments) {
87
+ const [preset] = await db
88
+ .select()
89
+ .from(schema.assetGenerationPresets)
90
+ .where(eq(schema.assetGenerationPresets.id, run.presetId))
91
+ .limit(1);
92
+ const presetSettings = parseJson<{ presetReferences?: unknown }>(
93
+ preset?.settings,
94
+ {},
95
+ );
96
+ presetReferenceFills = normalizePresetReferences(
97
+ presetSettings.presetReferences,
98
+ )
99
+ .filter((entry) => entry.variable)
100
+ .map((entry) => ({
101
+ referenceId: entry.id,
102
+ assetIds: Array.isArray(boardAssignments[entry.id])
103
+ ? boardAssignments[entry.id].filter(
104
+ (assetId): assetId is string => typeof assetId === "string",
105
+ )
106
+ : [],
107
+ }))
108
+ .filter((fill) => fill.assetIds.length > 0);
109
+ }
74
110
 
75
111
  return generateImage.run(
76
112
  {
@@ -100,6 +136,7 @@ export default defineAction({
100
136
  metadata.styleStrength ??
101
137
  "balanced") as any,
102
138
  categories: categories as any,
139
+ presetReferenceFills,
103
140
  includeLogo: Boolean(
104
141
  metadata.settingsUsed?.includeLogo ?? metadata.includeLogo,
105
142
  ),
@@ -14,7 +14,11 @@ import {
14
14
  } from "../shared/api.js";
15
15
  import { generationPresetSettingsSchema } from "./_generation-preset-settings.js";
16
16
  import { serializeGenerationPreset } from "./_helpers.js";
17
- import { assertPresetSkeletonAssetsValid } from "./_preset-skeleton-validation.js";
17
+ import {
18
+ assertPresetReferenceAssetsValid,
19
+ assertPresetReferenceModelCompatible,
20
+ assertPresetSkeletonAssetsValid,
21
+ } from "./_preset-skeleton-validation.js";
18
22
 
19
23
  export default defineAction({
20
24
  description:
@@ -88,7 +92,21 @@ export default defineAction({
88
92
  libraryId: preset.libraryId,
89
93
  settings: nextSettings,
90
94
  });
95
+ await assertPresetReferenceAssetsValid({
96
+ db,
97
+ libraryId: preset.libraryId,
98
+ settings: nextSettings,
99
+ });
100
+ assertPresetReferenceModelCompatible({
101
+ model: (args.model ?? preset.model) as string,
102
+ settings: nextSettings,
103
+ });
91
104
  updates.settings = stringifyJson(nextSettings);
105
+ } else if (args.model !== undefined) {
106
+ assertPresetReferenceModelCompatible({
107
+ model: args.model,
108
+ settings: parseJson<Record<string, unknown>>(preset.settings, {}),
109
+ });
92
110
  }
93
111
  await db
94
112
  .update(schema.assetGenerationPresets)
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["ar-SA"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["de-DE"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["es-ES"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["fr-FR"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["hi-IN"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["ja-JP"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["ko-KR"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["pt-BR"];
@@ -1,3 +1,4 @@
1
+ // TODO(l10n): reference board strings are English placeholders in ../i18n-data.ts until QA.
1
2
  import { messagesByLocale } from "../i18n-data";
2
3
 
3
4
  export default messagesByLocale["zh-CN"];
@@ -423,6 +423,47 @@ const messages = {
423
423
  lowerLeft: "左下",
424
424
  skeletonEnabled: "已啟用",
425
425
  skeletonOff: "關閉",
426
+ // TODO(l10n): translate after QA.
427
+ addReference: "Add reference",
428
+ referenceBoard: "Reference board",
429
+ referenceBoardEmptyDescription:
430
+ 'Add named visual slots like "Steve" so every run can use the right people, products, or style references.',
431
+ referenceDefaultLabel: "Reference",
432
+ referenceImageCount: "{{count}}/{{max}} images",
433
+ referenceSubjectImageCount: "{{count}}/{{max}} subject images",
434
+ moreImagesCount: "+{{count}}",
435
+ referenceLabel: "Label",
436
+ referenceRoleLabel: "Role",
437
+ referenceRole_subject: "Subject",
438
+ referenceRole_style: "Style",
439
+ referenceRole_product: "Product",
440
+ referenceRole_background: "Background",
441
+ referenceRole_composition: "Composition",
442
+ referenceDescription: "Description",
443
+ referenceDescriptionPlaceholder:
444
+ "e.g. This is Steve, our usual host. Render him faithfully.",
445
+ referenceMode: "Reference mode",
446
+ fixedReference: "Fixed",
447
+ swappableReference: "Swappable",
448
+ requiredPerRun: "Required",
449
+ required: "Required",
450
+ filledPerGeneration: "Filled per generation",
451
+ noReferenceImages: "No reference images",
452
+ chooseReferenceImage: "Choose from library",
453
+ searchLibraryImages: "Search library images",
454
+ editReference: "Edit reference",
455
+ uploadReferenceImage: "Upload image",
456
+ uploadingReferenceImage: "Uploading...",
457
+ referenceImageUploaded: "Reference image uploaded.",
458
+ couldNotUploadReferenceImage: "Could not upload reference image.",
459
+ removeReference: "Remove reference",
460
+ removeReferenceImage: "Remove reference image",
461
+ referenceBoardTotalLimitError:
462
+ "The reference board may attach at most 8 images total.",
463
+ referenceBoardSubjectLimitError:
464
+ "Subject reference entries may attach at most 4 images total.",
465
+ subjectReferenceModelTip:
466
+ "Tip: gemini-3-pro-image holds up to 5 people consistent — best for photo references.",
426
467
  openChat: "開啟聊天",
427
468
  defaultTextPolicy: "盡量不要嵌入文字。如需文字,請保持簡短且易讀。",
428
469
  generationPresetCreated: "生成預設已建立。",