@interware/content-generator 0.5.0 → 0.6.1

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 (32) hide show
  1. package/dist/_chunks/{App-DVTFrZvf.js → App-CJxSC8yt.js} +5 -4
  2. package/dist/_chunks/{App-DPVBu7QB.mjs → App-qbgUXlOZ.mjs} +4 -3
  3. package/dist/_chunks/{ImageStudioTabs-mlNL6aQ2.mjs → ImageStudioTabs-BLAH3KYv.mjs} +8 -36
  4. package/dist/_chunks/{ImageStudioTabs-B2UbKYq7.js → ImageStudioTabs-DtusqpQO.js} +7 -35
  5. package/dist/_chunks/PluginProviders-D79TeGSE.js +34 -0
  6. package/dist/_chunks/PluginProviders-DWMpcAIh.mjs +35 -0
  7. package/dist/_chunks/{Settings-DziRY70a.js → Settings-BQXxV3LO.js} +93 -6
  8. package/dist/_chunks/{Settings-RrBQdijj.mjs → Settings-o2GngLaW.mjs} +94 -7
  9. package/dist/_chunks/{StudioApp-Cswkzh7F.js → StudioApp-BaySMwrk.js} +4 -3
  10. package/dist/_chunks/{StudioApp-AUyPNhh2.mjs → StudioApp-C_znlQRo.mjs} +3 -2
  11. package/dist/_chunks/{getTranslation-DfxaP_TC.mjs → getTranslation-B6Bsu-uq.mjs} +1 -1
  12. package/dist/_chunks/{getTranslation-D88uLXGd.js → getTranslation-BRVMWSf8.js} +1 -1
  13. package/dist/_chunks/{index-Ddp0Qvqo.js → index-BKhe-Jyx.js} +3 -3
  14. package/dist/_chunks/{index-CqDdi211.mjs → index-BKigRXm3.mjs} +3 -3
  15. package/dist/admin/index.js +1 -1
  16. package/dist/admin/index.mjs +1 -1
  17. package/dist/admin/src/components/PluginProviders.d.ts +6 -2
  18. package/dist/admin/src/components/settings/VertexSettings.d.ts +18 -0
  19. package/dist/admin/src/hooks/useAdminTheme.d.ts +1 -1
  20. package/dist/admin/src/lib/use-cases/settings/index.d.ts +1 -0
  21. package/dist/server/index.js +95 -27
  22. package/dist/server/index.mjs +96 -28
  23. package/dist/server/src/controllers/config.d.ts +10 -2
  24. package/dist/server/src/controllers/index.d.ts +1 -0
  25. package/dist/server/src/image-studio/models.d.ts +2 -1
  26. package/dist/server/src/index.d.ts +4 -0
  27. package/dist/server/src/services/image-studio.d.ts +9 -5
  28. package/dist/server/src/services/index.d.ts +3 -0
  29. package/dist/server/src/services/service.d.ts +10 -0
  30. package/dist/server/src/utils/geminiClient.d.ts +24 -0
  31. package/dist/server/src/utils/serviceAccount.d.ts +29 -0
  32. package/package.json +2 -1
@@ -79,7 +79,7 @@ const index = {
79
79
  defaultMessage: "Generador de contenido"
80
80
  },
81
81
  Component: async () => {
82
- const { App } = await Promise.resolve().then(() => require("./App-DVTFrZvf.js"));
82
+ const { App } = await Promise.resolve().then(() => require("./App-CJxSC8yt.js"));
83
83
  return App;
84
84
  },
85
85
  permissions: [
@@ -94,7 +94,7 @@ const index = {
94
94
  defaultMessage: "Estudio de imágenes"
95
95
  },
96
96
  Component: async () => {
97
- const { StudioApp } = await Promise.resolve().then(() => require("./StudioApp-Cswkzh7F.js"));
97
+ const { StudioApp } = await Promise.resolve().then(() => require("./StudioApp-BaySMwrk.js"));
98
98
  return StudioApp;
99
99
  },
100
100
  permissions: [
@@ -115,7 +115,7 @@ const index = {
115
115
  defaultMessage: PLUGIN_ID
116
116
  },
117
117
  Component: async () => {
118
- const component = await Promise.resolve().then(() => require("./Settings-DziRY70a.js"));
118
+ const component = await Promise.resolve().then(() => require("./Settings-BQXxV3LO.js"));
119
119
  return component.default;
120
120
  },
121
121
  to: `plugins/${PLUGIN_ID}/settings`,
@@ -78,7 +78,7 @@ const index = {
78
78
  defaultMessage: "Generador de contenido"
79
79
  },
80
80
  Component: async () => {
81
- const { App } = await import("./App-DPVBu7QB.mjs");
81
+ const { App } = await import("./App-qbgUXlOZ.mjs");
82
82
  return App;
83
83
  },
84
84
  permissions: [
@@ -93,7 +93,7 @@ const index = {
93
93
  defaultMessage: "Estudio de imágenes"
94
94
  },
95
95
  Component: async () => {
96
- const { StudioApp } = await import("./StudioApp-AUyPNhh2.mjs");
96
+ const { StudioApp } = await import("./StudioApp-C_znlQRo.mjs");
97
97
  return StudioApp;
98
98
  },
99
99
  permissions: [
@@ -114,7 +114,7 @@ const index = {
114
114
  defaultMessage: PLUGIN_ID
115
115
  },
116
116
  Component: async () => {
117
- const component = await import("./Settings-RrBQdijj.mjs");
117
+ const component = await import("./Settings-o2GngLaW.mjs");
118
118
  return component.default;
119
119
  },
120
120
  to: `plugins/${PLUGIN_ID}/settings`,
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-Ddp0Qvqo.js");
2
+ const index = require("../_chunks/index-BKhe-Jyx.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CqDdi211.mjs";
1
+ import { i } from "../_chunks/index-BKigRXm3.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -1,7 +1,11 @@
1
1
  import type { ReactNode } from 'react';
2
2
  /**
3
- * Proveedores del plugin. El plugin tiene su propia copia del Design System, así que necesita su
4
- * propio `DesignSystemProvider` (el del panel no le llega) con el tema del panel (S4.10).
3
+ * Proveedores del plugin. El plugin trae su propia copia del Design System (2.2), así que sus componentes
4
+ * necesitan su tema (claro u oscuro según el panel, S4.10) y el proveedor de tooltips.
5
+ *
6
+ * NO se usa `DesignSystemProvider`: monta un `GlobalStyle` con el mismo id que el del panel y, al desmontarse
7
+ * (al salir del plugin), styled-components borraba también los estilos globales del panel: todo Strapi quedaba
8
+ * en Times hasta recargar. Los estilos globales y las regiones `aria-live` ya los pone el panel.
5
9
  */
6
10
  export declare const PluginProviders: ({ children }: {
7
11
  children: ReactNode;
@@ -0,0 +1,18 @@
1
+ /** Estado público de la cuenta de servicio que devuelve `GET /config`: nunca la llave (S5.1). */
2
+ export interface VertexStatus {
3
+ configured: boolean;
4
+ projectId: string | null;
5
+ clientEmail: string | null;
6
+ }
7
+ interface VertexSettingsProps {
8
+ status: VertexStatus;
9
+ /** Guarda el JSON pegado. Rechaza con el mensaje del servidor si no es válido. */
10
+ onSave: (json: string) => Promise<void>;
11
+ onRemove: () => Promise<void>;
12
+ }
13
+ /**
14
+ * Cuenta de servicio de Vertex AI en Ajustes (S5.1, decisión de Israel). Se pega el JSON completo; al guardarse,
15
+ * el campo se limpia y la llave no vuelve a mostrarse: solo el proyecto y el correo de la cuenta.
16
+ */
17
+ export declare const VertexSettings: ({ status, onSave, onRemove }: VertexSettingsProps) => import("react/jsx-runtime").JSX.Element;
18
+ export {};
@@ -1,6 +1,6 @@
1
1
  type Scheme = 'light' | 'dark';
2
2
  /**
3
- * Tema del panel de Strapi para el `DesignSystemProvider` propio del plugin (S4.10). Sin esto el
3
+ * Tema del panel de Strapi para el `ThemeProvider` del plugin (S4.10; `PluginProviders`). Sin esto el
4
4
  * plugin se pintaba siempre claro aunque el panel estuviera en oscuro. Con «system» sigue los
5
5
  * cambios del sistema operativo.
6
6
  */
@@ -13,5 +13,6 @@ declare const useSettings: () => {
13
13
  <TData = unknown>(url: string, config?: FetchOptions | undefined): Promise<import("@strapi/strapi/admin").FetchResponse<TData>>;
14
14
  }>>;
15
15
  setSettings: (data: any, config?: FetchOptions) => Promise<import("@strapi/strapi/admin").FetchResponse<(<TData_1 = unknown, TSend = unknown>(url: string, data?: TSend | undefined, config?: FetchOptions | undefined) => Promise<import("@strapi/strapi/admin").FetchResponse<TData_1>>)>>;
16
+ removeVertex: () => Promise<import("@strapi/strapi/admin").FetchResponse<unknown>>;
16
17
  };
17
18
  export { useSettings };
@@ -180,6 +180,34 @@ const maskSecret = (value) => {
180
180
  }
181
181
  return { configured: true, last4: value.slice(-4) };
182
182
  };
183
+ const VERTEX_KEY = "VERTEX_SERVICE_ACCOUNT";
184
+ const REQUIRED = ["project_id", "client_email", "private_key"];
185
+ const parseServiceAccount = (value) => {
186
+ if (typeof value !== "string") return { ok: false, message: "El texto no es un JSON válido." };
187
+ let data;
188
+ try {
189
+ const parsed = JSON.parse(value);
190
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error();
191
+ data = parsed;
192
+ } catch {
193
+ return { ok: false, message: "El texto no es un JSON válido." };
194
+ }
195
+ if (data.type !== "service_account") {
196
+ return { ok: false, message: "El JSON no es de una cuenta de servicio (type debe ser service_account)." };
197
+ }
198
+ const missing = REQUIRED.filter((k) => typeof data[k] !== "string" || data[k].trim() === "");
199
+ if (missing.length) return { ok: false, message: `El JSON no es de una cuenta de servicio: falta ${missing.join(", ")}.` };
200
+ return {
201
+ ok: true,
202
+ account: { projectId: data.project_id, clientEmail: data.client_email, privateKey: data.private_key }
203
+ };
204
+ };
205
+ const storedServiceAccount = (account) => JSON.stringify({ type: "service_account", project_id: account.projectId, client_email: account.clientEmail, private_key: account.privateKey });
206
+ const publicServiceAccount = (stored) => {
207
+ const parsed = parseServiceAccount(stored);
208
+ if (!("account" in parsed)) return { configured: false, projectId: null, clientEmail: null };
209
+ return { configured: true, projectId: parsed.account.projectId, clientEmail: parsed.account.clientEmail };
210
+ };
183
211
  const CREDENTIALS = {
184
212
  googleGenAI: "GOOGLE_GEN_AI",
185
213
  linkedInAccessToken: "LINKEDIN_ACCESS_TOKEN",
@@ -194,18 +222,31 @@ const config$1 = ({ strapi }) => {
194
222
  name: "strapi-content-generator-gemini"
195
223
  });
196
224
  return {
197
- /** Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. */
225
+ /**
226
+ * Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. Para Vertex,
227
+ * el proyecto y el correo de la cuenta de servicio, nunca la llave privada.
228
+ */
198
229
  async get(ctx) {
199
230
  const store = pluginStore();
200
231
  const entries = await Promise.all(
201
232
  FIELDS$1.map(async (field) => [field, maskSecret(await store.get({ key: CREDENTIALS[field] }))])
202
233
  );
203
- ctx.body = Object.fromEntries(entries);
234
+ ctx.body = { ...Object.fromEntries(entries), vertex: publicServiceAccount(await store.get({ key: VERTEX_KEY })) };
204
235
  },
205
- /** Escribe solo las credenciales conocidas que llegan como texto no vacío. */
236
+ /**
237
+ * Escribe solo las credenciales conocidas que llegan como texto no vacío. Una cuenta de servicio
238
+ * inválida rechaza la petición completa (400) antes de escribir nada.
239
+ */
206
240
  async set(ctx) {
207
241
  const body = ctx.request.body ?? {};
208
242
  const store = pluginStore();
243
+ const vertex = typeof body.vertexServiceAccount === "string" && body.vertexServiceAccount.trim() !== "" ? parseServiceAccount(body.vertexServiceAccount) : null;
244
+ if (vertex && "message" in vertex) {
245
+ ctx.status = 400;
246
+ ctx.body = { data: null, error: { status: 400, name: "ValidationError", message: vertex.message } };
247
+ return;
248
+ }
249
+ if (vertex && "account" in vertex) await store.set({ key: VERTEX_KEY, value: storedServiceAccount(vertex.account) });
209
250
  for (const field of FIELDS$1) {
210
251
  const value = body[field];
211
252
  if (typeof value === "string" && value.length > 0) {
@@ -213,6 +254,11 @@ const config$1 = ({ strapi }) => {
213
254
  }
214
255
  }
215
256
  ctx.body = { ok: true };
257
+ },
258
+ /** Quita la cuenta de servicio: el plugin vuelve a la llave de la Gemini API (S5.1). */
259
+ async removeVertex(ctx) {
260
+ await pluginStore().delete({ key: VERTEX_KEY });
261
+ ctx.body = { ok: true };
216
262
  }
217
263
  };
218
264
  };
@@ -256,7 +302,7 @@ const inlinePart = (bytes, mimeType) => ({ inlineData: { mimeType, data: bytes.t
256
302
  const RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
257
303
  const FLASH_RATIOS = [...RATIOS, "1:4", "4:1", "1:8", "8:1"];
258
304
  const FLASH = { maxReferences: 6, sizes: ["512", "1K", "2K", "4K"], aspectRatios: FLASH_RATIOS, canEdit: true, grounding: true };
259
- const FLASH_LITE = { maxReferences: 6, sizes: ["512", "1K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
305
+ const FLASH_LITE = { maxReferences: 6, sizes: ["1K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
260
306
  const PRO = { maxReferences: 6, sizes: ["1K", "2K", "4K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
261
307
  const IMAGE_MODELS = [
262
308
  { id: "gemini-3.1-flash-image", label: "Nano Banana 2", ...FLASH },
@@ -270,7 +316,7 @@ const IMAGE_MODELS = [
270
316
  const findModel = (id) => IMAGE_MODELS.find((m) => m.id === id);
271
317
  const availableModels = (listed) => {
272
318
  if (!listed) return { models: IMAGE_MODELS, verified: false };
273
- const ids = new Set(listed.map((n) => n.replace(/^models\//, "")));
319
+ const ids = new Set(listed.map((n) => n.replace(/^(publishers\/google\/)?models\//, "")));
274
320
  const models = IMAGE_MODELS.filter((m) => ids.has(m.id) && !(m.id.endsWith("-preview") && ids.has(m.id.replace(/-preview$/, ""))));
275
321
  return models.length ? { models, verified: true } : { models: IMAGE_MODELS, verified: false };
276
322
  };
@@ -330,10 +376,10 @@ const imageFrom = (res, model) => {
330
376
  };
331
377
  const inline = async (file, read) => inlinePart(await read(file.filepath), file.mimetype ?? "image/png");
332
378
  const userTurn = (parts) => [{ role: "user", parts }];
333
- const createImageStudio = (getModels, read = (p) => fs.promises.readFile(p), backend = "gemini-api") => ({
379
+ const createImageStudio = (getClient, read = (p) => fs.promises.readFile(p)) => ({
334
380
  async models() {
335
381
  try {
336
- const api = await getModels();
382
+ const { models: api } = await getClient();
337
383
  const names = [];
338
384
  for await (const m of await api.list({ config: { pageSize: 100 } })) if (m.name) names.push(m.name);
339
385
  return availableModels(names);
@@ -361,7 +407,7 @@ const createImageStudio = (getModels, read = (p) => fs.promises.readFile(p), bac
361
407
  return image;
362
408
  },
363
409
  async generate(input) {
364
- const api = await getModels();
410
+ const { models: api, backend } = await getClient();
365
411
  const text2 = cleanPrompt(input.prompt);
366
412
  const { model } = input;
367
413
  const refs = await Promise.all(input.references.map((f) => inline(f, read)));
@@ -376,7 +422,7 @@ const createImageStudio = (getModels, read = (p) => fs.promises.readFile(p), bac
376
422
  return grounding ? { ...image, grounding } : image;
377
423
  },
378
424
  async edit(input) {
379
- const api = await getModels();
425
+ const { models: api, backend } = await getClient();
380
426
  const parts = [
381
427
  await inline(input.image, read),
382
428
  ...await Promise.all(input.references.map((f) => inline(f, read))),
@@ -387,14 +433,17 @@ const createImageStudio = (getModels, read = (p) => fs.promises.readFile(p), bac
387
433
  return imageFrom(res, input.model.id);
388
434
  },
389
435
  async expand(prompt) {
390
- const api = await getModels();
436
+ const { models: api } = await getClient();
391
437
  const res = await api.generateContent({ model: EXPAND_MODEL, contents: EXPAND_INSTRUCTION(cleanPrompt(prompt)) });
392
438
  const text2 = (res.text ?? "").trim().replace(/^["'«“]+|["'»”]+$/g, "").trim();
393
439
  if (!text2) throw new BlockedError("No se pudo expandir la idea. Intenta de nuevo.");
394
440
  return text2;
395
441
  }
396
442
  });
397
- const imageStudio$1 = ({ strapi }) => createImageStudio(async () => (await strapi.plugin("strapi-content-generator-gemini").service("service").geminiConfig()).models);
443
+ const imageStudio$1 = ({ strapi }) => createImageStudio(async () => {
444
+ const { ai, backend } = await strapi.plugin("strapi-content-generator-gemini").service("service").geminiClient();
445
+ return { models: ai.models, backend };
446
+ });
398
447
  class InputError extends Error {
399
448
  }
400
449
  const ALLOWED = ["image/png", "image/jpeg", "image/webp"];
@@ -881,7 +930,8 @@ const admin = [
881
930
  { method: "POST", path: "/template-folders/rename", handler: "templates.renameFolder", config },
882
931
  { method: "POST", path: "/template-folders/delete", handler: "templates.deleteFolder", config },
883
932
  { method: "GET", path: "/config", handler: "config.get", config },
884
- { method: "POST", path: "/config", handler: "config.set", config }
933
+ { method: "POST", path: "/config", handler: "config.set", config },
934
+ { method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config }
885
935
  ];
886
936
  const contentApi = [];
887
937
  const routes = {
@@ -4120,25 +4170,43 @@ const {
4120
4170
  mergeConfig,
4121
4171
  create
4122
4172
  } = axios;
4173
+ const VERTEX_LOCATION = "global";
4174
+ const SCOPES = ["https://www.googleapis.com/auth/cloud-platform"];
4175
+ const createGeminiClient = ({ serviceAccount, apiKey, allowVertex = true }, Ctor = genai.GoogleGenAI) => {
4176
+ const parsed = allowVertex && serviceAccount ? parseServiceAccount(serviceAccount) : null;
4177
+ if (parsed && "account" in parsed) {
4178
+ const { projectId, clientEmail, privateKey } = parsed.account;
4179
+ const ai = new Ctor({
4180
+ vertexai: true,
4181
+ project: projectId,
4182
+ location: VERTEX_LOCATION,
4183
+ googleAuthOptions: { credentials: { client_email: clientEmail, private_key: privateKey }, scopes: SCOPES }
4184
+ });
4185
+ return { ai, backend: "vertex" };
4186
+ }
4187
+ if (typeof apiKey === "string" && apiKey) return { ai: new Ctor({ apiKey }), backend: "gemini-api" };
4188
+ throw new Error(
4189
+ allowVertex ? "Configura la cuenta de servicio de Vertex AI o la llave de Gemini en Ajustes del plugin." : "El video necesita la llave de Gemini en Ajustes del plugin."
4190
+ );
4191
+ };
4123
4192
  const service = ({ strapi }) => {
4124
4193
  return {
4125
4194
  // ───────────────────────────────
4126
4195
  // 🤖 AI Configuration
4127
4196
  // ───────────────────────────────
4197
+ /**
4198
+ * Cliente de Google y su backend (S5.1): con la cuenta de servicio de Ajustes, Vertex AI en `global`; si
4199
+ * no, la llave (`GOOGLE_API_KEY` del entorno gana a la del store, como antes; D10). El video pide
4200
+ * `allowVertex: false` (D7).
4201
+ */
4202
+ async geminiClient({ allowVertex = true } = {}) {
4203
+ const pluginStore = strapi.store({ type: "plugin", name: "strapi-content-generator-gemini" });
4204
+ const [serviceAccount, storedKey] = await Promise.all([pluginStore.get({ key: VERTEX_KEY }), pluginStore.get({ key: "GOOGLE_GEN_AI" })]);
4205
+ return createGeminiClient({ serviceAccount, apiKey: process.env.GOOGLE_API_KEY || storedKey, allowVertex });
4206
+ },
4207
+ /** Atajo para los usos que solo necesitan el cliente (artículos). */
4128
4208
  async geminiConfig() {
4129
- const pluginStore = strapi.store({
4130
- type: "plugin",
4131
- name: "strapi-content-generator-gemini"
4132
- });
4133
- const googleGenAI = await pluginStore.get({ key: "GOOGLE_GEN_AI" });
4134
- const geminiApiKey = process.env.GOOGLE_API_KEY || googleGenAI;
4135
- if (!geminiApiKey) {
4136
- throw new Error(
4137
- "La variable de entorno GOOGLE_API_KEY o GOOGLE_GEN_AI no está configurada."
4138
- );
4139
- }
4140
- const ai = new genai.GoogleGenAI({ apiKey: geminiApiKey });
4141
- return ai;
4209
+ return (await this.geminiClient()).ai;
4142
4210
  },
4143
4211
  async getArticulos(sanitizedQueryParams) {
4144
4212
  return await strapi.entityService.findMany("api::articulo.articulo", sanitizedQueryParams);
@@ -4446,7 +4514,7 @@ const service = ({ strapi }) => {
4446
4514
  },
4447
4515
  // 🎞️ Internal Veo Video Generation / Extension Helper
4448
4516
  async _callVeoVideoGeneration(promptText, aspectRatio, durationSeconds, sceneRefs, previousVideo) {
4449
- const ai = await this.geminiConfig();
4517
+ const { ai } = await this.geminiClient({ allowVertex: false });
4450
4518
  const cleanPrompt2 = promptText.replace(/\s+/g, " ").trim();
4451
4519
  const referenceImages = sceneRefs.map((img) => ({
4452
4520
  image: {
@@ -4516,7 +4584,7 @@ const service = ({ strapi }) => {
4516
4584
  throw new Error("No se generó ningún video.");
4517
4585
  }
4518
4586
  const finalVideoPath = `/tmp/video-${Date.now()}.mp4`;
4519
- const ai = await this.geminiConfig();
4587
+ const { ai } = await this.geminiClient({ allowVertex: false });
4520
4588
  await ai.files.download({ file: previousVideo, downloadPath: finalVideoPath });
4521
4589
  const uploadedFile = await this._uploadGeneratedFile(
4522
4590
  finalVideoPath,
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import { promises } from "fs";
3
- import { HarmCategory, PersonGeneration, Modality, HarmBlockThreshold, VideoGenerationReferenceType, GoogleGenAI } from "@google/genai";
3
+ import { HarmCategory, PersonGeneration, Modality, HarmBlockThreshold, GoogleGenAI, VideoGenerationReferenceType } from "@google/genai";
4
4
  const PLUGIN_ID = "strapi-content-generator-gemini";
5
5
  const FOLDER_UID = "plugin::upload.folder";
6
6
  const hasValidPath = (path) => /^(\/\d+)+$/.test(path);
@@ -162,6 +162,34 @@ const maskSecret = (value) => {
162
162
  }
163
163
  return { configured: true, last4: value.slice(-4) };
164
164
  };
165
+ const VERTEX_KEY = "VERTEX_SERVICE_ACCOUNT";
166
+ const REQUIRED = ["project_id", "client_email", "private_key"];
167
+ const parseServiceAccount = (value) => {
168
+ if (typeof value !== "string") return { ok: false, message: "El texto no es un JSON válido." };
169
+ let data;
170
+ try {
171
+ const parsed = JSON.parse(value);
172
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error();
173
+ data = parsed;
174
+ } catch {
175
+ return { ok: false, message: "El texto no es un JSON válido." };
176
+ }
177
+ if (data.type !== "service_account") {
178
+ return { ok: false, message: "El JSON no es de una cuenta de servicio (type debe ser service_account)." };
179
+ }
180
+ const missing = REQUIRED.filter((k) => typeof data[k] !== "string" || data[k].trim() === "");
181
+ if (missing.length) return { ok: false, message: `El JSON no es de una cuenta de servicio: falta ${missing.join(", ")}.` };
182
+ return {
183
+ ok: true,
184
+ account: { projectId: data.project_id, clientEmail: data.client_email, privateKey: data.private_key }
185
+ };
186
+ };
187
+ const storedServiceAccount = (account) => JSON.stringify({ type: "service_account", project_id: account.projectId, client_email: account.clientEmail, private_key: account.privateKey });
188
+ const publicServiceAccount = (stored) => {
189
+ const parsed = parseServiceAccount(stored);
190
+ if (!("account" in parsed)) return { configured: false, projectId: null, clientEmail: null };
191
+ return { configured: true, projectId: parsed.account.projectId, clientEmail: parsed.account.clientEmail };
192
+ };
165
193
  const CREDENTIALS = {
166
194
  googleGenAI: "GOOGLE_GEN_AI",
167
195
  linkedInAccessToken: "LINKEDIN_ACCESS_TOKEN",
@@ -176,18 +204,31 @@ const config$1 = ({ strapi }) => {
176
204
  name: "strapi-content-generator-gemini"
177
205
  });
178
206
  return {
179
- /** Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. */
207
+ /**
208
+ * Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. Para Vertex,
209
+ * el proyecto y el correo de la cuenta de servicio, nunca la llave privada.
210
+ */
180
211
  async get(ctx) {
181
212
  const store = pluginStore();
182
213
  const entries = await Promise.all(
183
214
  FIELDS$1.map(async (field) => [field, maskSecret(await store.get({ key: CREDENTIALS[field] }))])
184
215
  );
185
- ctx.body = Object.fromEntries(entries);
216
+ ctx.body = { ...Object.fromEntries(entries), vertex: publicServiceAccount(await store.get({ key: VERTEX_KEY })) };
186
217
  },
187
- /** Escribe solo las credenciales conocidas que llegan como texto no vacío. */
218
+ /**
219
+ * Escribe solo las credenciales conocidas que llegan como texto no vacío. Una cuenta de servicio
220
+ * inválida rechaza la petición completa (400) antes de escribir nada.
221
+ */
188
222
  async set(ctx) {
189
223
  const body = ctx.request.body ?? {};
190
224
  const store = pluginStore();
225
+ const vertex = typeof body.vertexServiceAccount === "string" && body.vertexServiceAccount.trim() !== "" ? parseServiceAccount(body.vertexServiceAccount) : null;
226
+ if (vertex && "message" in vertex) {
227
+ ctx.status = 400;
228
+ ctx.body = { data: null, error: { status: 400, name: "ValidationError", message: vertex.message } };
229
+ return;
230
+ }
231
+ if (vertex && "account" in vertex) await store.set({ key: VERTEX_KEY, value: storedServiceAccount(vertex.account) });
191
232
  for (const field of FIELDS$1) {
192
233
  const value = body[field];
193
234
  if (typeof value === "string" && value.length > 0) {
@@ -195,6 +236,11 @@ const config$1 = ({ strapi }) => {
195
236
  }
196
237
  }
197
238
  ctx.body = { ok: true };
239
+ },
240
+ /** Quita la cuenta de servicio: el plugin vuelve a la llave de la Gemini API (S5.1). */
241
+ async removeVertex(ctx) {
242
+ await pluginStore().delete({ key: VERTEX_KEY });
243
+ ctx.body = { ok: true };
198
244
  }
199
245
  };
200
246
  };
@@ -238,7 +284,7 @@ const inlinePart = (bytes, mimeType) => ({ inlineData: { mimeType, data: bytes.t
238
284
  const RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
239
285
  const FLASH_RATIOS = [...RATIOS, "1:4", "4:1", "1:8", "8:1"];
240
286
  const FLASH = { maxReferences: 6, sizes: ["512", "1K", "2K", "4K"], aspectRatios: FLASH_RATIOS, canEdit: true, grounding: true };
241
- const FLASH_LITE = { maxReferences: 6, sizes: ["512", "1K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
287
+ const FLASH_LITE = { maxReferences: 6, sizes: ["1K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
242
288
  const PRO = { maxReferences: 6, sizes: ["1K", "2K", "4K"], aspectRatios: RATIOS, canEdit: true, grounding: false };
243
289
  const IMAGE_MODELS = [
244
290
  { id: "gemini-3.1-flash-image", label: "Nano Banana 2", ...FLASH },
@@ -252,7 +298,7 @@ const IMAGE_MODELS = [
252
298
  const findModel = (id) => IMAGE_MODELS.find((m) => m.id === id);
253
299
  const availableModels = (listed) => {
254
300
  if (!listed) return { models: IMAGE_MODELS, verified: false };
255
- const ids = new Set(listed.map((n) => n.replace(/^models\//, "")));
301
+ const ids = new Set(listed.map((n) => n.replace(/^(publishers\/google\/)?models\//, "")));
256
302
  const models = IMAGE_MODELS.filter((m) => ids.has(m.id) && !(m.id.endsWith("-preview") && ids.has(m.id.replace(/-preview$/, ""))));
257
303
  return models.length ? { models, verified: true } : { models: IMAGE_MODELS, verified: false };
258
304
  };
@@ -312,10 +358,10 @@ const imageFrom = (res, model) => {
312
358
  };
313
359
  const inline = async (file, read) => inlinePart(await read(file.filepath), file.mimetype ?? "image/png");
314
360
  const userTurn = (parts) => [{ role: "user", parts }];
315
- const createImageStudio = (getModels, read = (p) => promises.readFile(p), backend = "gemini-api") => ({
361
+ const createImageStudio = (getClient, read = (p) => promises.readFile(p)) => ({
316
362
  async models() {
317
363
  try {
318
- const api = await getModels();
364
+ const { models: api } = await getClient();
319
365
  const names = [];
320
366
  for await (const m of await api.list({ config: { pageSize: 100 } })) if (m.name) names.push(m.name);
321
367
  return availableModels(names);
@@ -343,7 +389,7 @@ const createImageStudio = (getModels, read = (p) => promises.readFile(p), backen
343
389
  return image;
344
390
  },
345
391
  async generate(input) {
346
- const api = await getModels();
392
+ const { models: api, backend } = await getClient();
347
393
  const text2 = cleanPrompt(input.prompt);
348
394
  const { model } = input;
349
395
  const refs = await Promise.all(input.references.map((f) => inline(f, read)));
@@ -358,7 +404,7 @@ const createImageStudio = (getModels, read = (p) => promises.readFile(p), backen
358
404
  return grounding ? { ...image, grounding } : image;
359
405
  },
360
406
  async edit(input) {
361
- const api = await getModels();
407
+ const { models: api, backend } = await getClient();
362
408
  const parts = [
363
409
  await inline(input.image, read),
364
410
  ...await Promise.all(input.references.map((f) => inline(f, read))),
@@ -369,14 +415,17 @@ const createImageStudio = (getModels, read = (p) => promises.readFile(p), backen
369
415
  return imageFrom(res, input.model.id);
370
416
  },
371
417
  async expand(prompt) {
372
- const api = await getModels();
418
+ const { models: api } = await getClient();
373
419
  const res = await api.generateContent({ model: EXPAND_MODEL, contents: EXPAND_INSTRUCTION(cleanPrompt(prompt)) });
374
420
  const text2 = (res.text ?? "").trim().replace(/^["'«“]+|["'»”]+$/g, "").trim();
375
421
  if (!text2) throw new BlockedError("No se pudo expandir la idea. Intenta de nuevo.");
376
422
  return text2;
377
423
  }
378
424
  });
379
- const imageStudio$1 = ({ strapi }) => createImageStudio(async () => (await strapi.plugin("strapi-content-generator-gemini").service("service").geminiConfig()).models);
425
+ const imageStudio$1 = ({ strapi }) => createImageStudio(async () => {
426
+ const { ai, backend } = await strapi.plugin("strapi-content-generator-gemini").service("service").geminiClient();
427
+ return { models: ai.models, backend };
428
+ });
380
429
  class InputError extends Error {
381
430
  }
382
431
  const ALLOWED = ["image/png", "image/jpeg", "image/webp"];
@@ -863,7 +912,8 @@ const admin = [
863
912
  { method: "POST", path: "/template-folders/rename", handler: "templates.renameFolder", config },
864
913
  { method: "POST", path: "/template-folders/delete", handler: "templates.deleteFolder", config },
865
914
  { method: "GET", path: "/config", handler: "config.get", config },
866
- { method: "POST", path: "/config", handler: "config.set", config }
915
+ { method: "POST", path: "/config", handler: "config.set", config },
916
+ { method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config }
867
917
  ];
868
918
  const contentApi = [];
869
919
  const routes = {
@@ -4102,25 +4152,43 @@ const {
4102
4152
  mergeConfig,
4103
4153
  create
4104
4154
  } = axios;
4155
+ const VERTEX_LOCATION = "global";
4156
+ const SCOPES = ["https://www.googleapis.com/auth/cloud-platform"];
4157
+ const createGeminiClient = ({ serviceAccount, apiKey, allowVertex = true }, Ctor = GoogleGenAI) => {
4158
+ const parsed = allowVertex && serviceAccount ? parseServiceAccount(serviceAccount) : null;
4159
+ if (parsed && "account" in parsed) {
4160
+ const { projectId, clientEmail, privateKey } = parsed.account;
4161
+ const ai = new Ctor({
4162
+ vertexai: true,
4163
+ project: projectId,
4164
+ location: VERTEX_LOCATION,
4165
+ googleAuthOptions: { credentials: { client_email: clientEmail, private_key: privateKey }, scopes: SCOPES }
4166
+ });
4167
+ return { ai, backend: "vertex" };
4168
+ }
4169
+ if (typeof apiKey === "string" && apiKey) return { ai: new Ctor({ apiKey }), backend: "gemini-api" };
4170
+ throw new Error(
4171
+ allowVertex ? "Configura la cuenta de servicio de Vertex AI o la llave de Gemini en Ajustes del plugin." : "El video necesita la llave de Gemini en Ajustes del plugin."
4172
+ );
4173
+ };
4105
4174
  const service = ({ strapi }) => {
4106
4175
  return {
4107
4176
  // ───────────────────────────────
4108
4177
  // 🤖 AI Configuration
4109
4178
  // ───────────────────────────────
4179
+ /**
4180
+ * Cliente de Google y su backend (S5.1): con la cuenta de servicio de Ajustes, Vertex AI en `global`; si
4181
+ * no, la llave (`GOOGLE_API_KEY` del entorno gana a la del store, como antes; D10). El video pide
4182
+ * `allowVertex: false` (D7).
4183
+ */
4184
+ async geminiClient({ allowVertex = true } = {}) {
4185
+ const pluginStore = strapi.store({ type: "plugin", name: "strapi-content-generator-gemini" });
4186
+ const [serviceAccount, storedKey] = await Promise.all([pluginStore.get({ key: VERTEX_KEY }), pluginStore.get({ key: "GOOGLE_GEN_AI" })]);
4187
+ return createGeminiClient({ serviceAccount, apiKey: process.env.GOOGLE_API_KEY || storedKey, allowVertex });
4188
+ },
4189
+ /** Atajo para los usos que solo necesitan el cliente (artículos). */
4110
4190
  async geminiConfig() {
4111
- const pluginStore = strapi.store({
4112
- type: "plugin",
4113
- name: "strapi-content-generator-gemini"
4114
- });
4115
- const googleGenAI = await pluginStore.get({ key: "GOOGLE_GEN_AI" });
4116
- const geminiApiKey = process.env.GOOGLE_API_KEY || googleGenAI;
4117
- if (!geminiApiKey) {
4118
- throw new Error(
4119
- "La variable de entorno GOOGLE_API_KEY o GOOGLE_GEN_AI no está configurada."
4120
- );
4121
- }
4122
- const ai = new GoogleGenAI({ apiKey: geminiApiKey });
4123
- return ai;
4191
+ return (await this.geminiClient()).ai;
4124
4192
  },
4125
4193
  async getArticulos(sanitizedQueryParams) {
4126
4194
  return await strapi.entityService.findMany("api::articulo.articulo", sanitizedQueryParams);
@@ -4428,7 +4496,7 @@ const service = ({ strapi }) => {
4428
4496
  },
4429
4497
  // 🎞️ Internal Veo Video Generation / Extension Helper
4430
4498
  async _callVeoVideoGeneration(promptText, aspectRatio, durationSeconds, sceneRefs, previousVideo) {
4431
- const ai = await this.geminiConfig();
4499
+ const { ai } = await this.geminiClient({ allowVertex: false });
4432
4500
  const cleanPrompt2 = promptText.replace(/\s+/g, " ").trim();
4433
4501
  const referenceImages = sceneRefs.map((img) => ({
4434
4502
  image: {
@@ -4498,7 +4566,7 @@ const service = ({ strapi }) => {
4498
4566
  throw new Error("No se generó ningún video.");
4499
4567
  }
4500
4568
  const finalVideoPath = `/tmp/video-${Date.now()}.mp4`;
4501
- const ai = await this.geminiConfig();
4569
+ const { ai } = await this.geminiClient({ allowVertex: false });
4502
4570
  await ai.files.download({ file: previousVideo, downloadPath: finalVideoPath });
4503
4571
  const uploadedFile = await this._uploadGeneratedFile(
4504
4572
  finalVideoPath,
@@ -2,9 +2,17 @@ import type { Core } from '@strapi/strapi';
2
2
  declare const config: ({ strapi }: {
3
3
  strapi: Core.Strapi;
4
4
  }) => {
5
- /** Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. */
5
+ /**
6
+ * Devuelve si cada credencial está guardada y sus últimos 4 caracteres, nunca el valor. Para Vertex,
7
+ * el proyecto y el correo de la cuenta de servicio, nunca la llave privada.
8
+ */
6
9
  get(ctx: any): Promise<void>;
7
- /** Escribe solo las credenciales conocidas que llegan como texto no vacío. */
10
+ /**
11
+ * Escribe solo las credenciales conocidas que llegan como texto no vacío. Una cuenta de servicio
12
+ * inválida rechaza la petición completa (400) antes de escribir nada.
13
+ */
8
14
  set(ctx: any): Promise<void>;
15
+ /** Quita la cuenta de servicio: el plugin vuelve a la llave de la Gemini API (S5.1). */
16
+ removeVertex(ctx: any): Promise<void>;
9
17
  };
10
18
  export default config;
@@ -17,6 +17,7 @@ declare const _default: {
17
17
  }) => {
18
18
  get(ctx: any): Promise<void>;
19
19
  set(ctx: any): Promise<void>;
20
+ removeVertex(ctx: any): Promise<void>;
20
21
  };
21
22
  'image-studio': ({ strapi }: {
22
23
  strapi: import("@strapi/types/dist/core").Strapi;
@@ -26,7 +26,8 @@ export interface ImageModelInfo {
26
26
  export declare const IMAGE_MODELS: ImageModelInfo[];
27
27
  export declare const findModel: (id: string | undefined) => ImageModelInfo;
28
28
  /**
29
- * Catálogo filtrado por los nombres que devuelve `ai.models.list()` («models/<id>»). Sin lista, o si
29
+ * Catálogo filtrado por los nombres que devuelve `ai.models.list()`: «models/<id>» en la Gemini API y
30
+ * «publishers/google/models/<id>» en Vertex AI (S5.1). Sin lista, o si
30
31
  * ninguno coincide (la API cambió de formato), se devuelve el catálogo completo sin verificar para
31
32
  * no dejar el Generador vacío.
32
33
  */
@@ -33,6 +33,7 @@ declare const _default: {
33
33
  }) => {
34
34
  get(ctx: any): Promise<void>;
35
35
  set(ctx: any): Promise<void>;
36
+ removeVertex(ctx: any): Promise<void>;
36
37
  };
37
38
  'image-studio': ({ strapi }: {
38
39
  strapi: import("@strapi/types/dist/core").Strapi;
@@ -189,6 +190,9 @@ declare const _default: {
189
190
  service: ({ strapi }: {
190
191
  strapi: import("@strapi/types/dist/core").Strapi;
191
192
  }) => {
193
+ geminiClient({ allowVertex }?: {
194
+ allowVertex?: boolean;
195
+ }): Promise<import("./utils/geminiClient").GeminiClient>;
192
196
  geminiConfig(): Promise<import("@google/genai").GoogleGenAI>;
193
197
  getArticulos(sanitizedQueryParams: any): Promise<{
194
198
  id: import("@strapi/types/dist/data").ID;