@pigment/auto-translate 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ A powerful auto-translation plugin for [Payload CMS](https://payloadcms.com) tha
9
9
  - 🎯 **Smart Translation**: Preserves excluded fields when updating default language content
10
10
  - 🔧 **Flexible Configuration**: Configure per-collection settings and global exclusions
11
11
  - 🔐 **Protected Settings**: Lock/unlock translation settings to prevent accidental changes
12
- - 🤖 **OpenAI Integration**: Uses GPT-4o for high-quality translations (with custom provider support)
12
+ - 🤖 **OpenAI Integration**: Translate with OpenAI (model selectable in Translation Settings)
13
13
  - 📦 **Zero UI Overhead**: Seamlessly integrates with Payload's admin panel
14
14
  - ⚡ **Performance Optimized**: 10-15x faster translation with smart extraction and deduplication
15
15
 
@@ -75,6 +75,16 @@ OPENAI_BASE_URL=https://api.openai.com/v1
75
75
 
76
76
  > **⚠️ Important:** Restart your server after updating `.env` or plugin settings.
77
77
 
78
+ ### Translation Settings (Admin)
79
+
80
+ After install, open **Auto-Translate Settings → Translation Settings** in the Payload admin:
81
+
82
+ - **Model** — live OpenAI model dropdown (requires `OPENAI_API_KEY`). This value is what translation calls use.
83
+ - **Temperature** — hidden for GPT-5+ / o-series (those models reject custom temperature).
84
+ - **System prompt / rules / max tokens** — editable after unlocking settings.
85
+
86
+ `provider.model` in plugin config is only a fallback when the global has no model set.
87
+
78
88
  ---
79
89
 
80
90
  ## 🔧 Advanced Configuration
@@ -103,7 +113,8 @@ export default buildConfig({
103
113
  // Optional: Translation provider settings
104
114
  provider: {
105
115
  type: 'openai', // or 'custom'
106
- model: 'gpt-4o', // OpenAI model to use
116
+ // Fallback only — prefer Admin → Translation Settings → Model
117
+ model: 'gpt-4o',
107
118
  apiKey: process.env.OPENAI_API_KEY,
108
119
  baseURL: process.env.OPENAI_BASE_URL,
109
120
 
@@ -15,17 +15,15 @@ import './style.css';
15
15
  const { dispatchFields } = useForm();
16
16
  const isLocked = Boolean(lockField?.value ?? true);
17
17
  const [isLoading, setIsLoading] = useState(false);
18
- // Apply lock state to fields by directly manipulating their disabled state
18
+ // Model select uses form lock state in OpenAiModelField (react-select).
19
19
  const applyLockStateToFields = useCallback((locked)=>{
20
20
  const fieldsToLock = [
21
21
  'systemPrompt',
22
22
  'translationRules',
23
- 'model',
24
23
  'temperature',
25
24
  'maxTokens'
26
25
  ];
27
26
  fieldsToLock.forEach((fieldPath)=>{
28
- // Find the input/textarea elements for this field
29
27
  const inputs = document.querySelectorAll(`[name="${fieldPath}"], textarea[id*="${fieldPath}"], input[id*="${fieldPath}"]`);
30
28
  inputs.forEach((input)=>{
31
29
  if (locked) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/LockTranslation/index.tsx"],"sourcesContent":["'use client'\n\nimport { useForm, useFormFields } from '@payloadcms/ui'\nimport React, { useCallback, useEffect, useState } from 'react'\n\nimport { updateLockTranslationSettingsField } from './actions/lockTranslations.js'\nimport './style.css'\n\n/**\n * Component that provides lock/unlock functionality for translation settings\n * - Fields are locked by default (read-only)\n * - User must click \"Unlock\" to edit\n * - After saving, fields automatically lock again\n * - Updates the hidden lockTranslationSettings checkbox field to enable server-side access control\n */\nexport const LockTranslation: React.FC = () => {\n const lockField = useFormFields(([fields]) => fields?.lockTranslationSettings)\n const { dispatchFields } = useForm()\n const isLocked = Boolean(lockField?.value ?? true)\n const [isLoading, setIsLoading] = useState(false)\n\n // Apply lock state to fields by directly manipulating their disabled state\n const applyLockStateToFields = useCallback((locked: boolean) => {\n const fieldsToLock = ['systemPrompt', 'translationRules', 'model', 'temperature', 'maxTokens']\n\n fieldsToLock.forEach((fieldPath) => {\n // Find the input/textarea elements for this field\n const inputs = document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(\n `[name=\"${fieldPath}\"], textarea[id*=\"${fieldPath}\"], input[id*=\"${fieldPath}\"]`,\n )\n\n inputs.forEach((input) => {\n if (locked) {\n input.setAttribute('disabled', 'true')\n } else {\n input.removeAttribute('disabled')\n }\n })\n })\n }, [])\n\n // Apply initial lock state when component mounts or lock state changes\n useEffect(() => {\n // Small delay to ensure form fields are rendered\n const timer = setTimeout(() => {\n applyLockStateToFields(isLocked)\n }, 100)\n\n return () => clearTimeout(timer)\n }, [isLocked, applyLockStateToFields])\n\n const toggleLock = useCallback(async () => {\n const newLockState = !isLocked\n setIsLoading(true)\n\n try {\n const result = await updateLockTranslationSettingsField(newLockState)\n\n if (result.success) {\n // Update the lockTranslationSettings field value\n dispatchFields({\n type: 'UPDATE',\n path: 'lockTranslationSettings',\n value: result.isLocked,\n })\n\n // Apply lock state to fields\n applyLockStateToFields(result.isLocked)\n }\n } catch (error) {\n // eslint-disable-next-line no-console\n console.error('[LockTranslation] Error:', error)\n } finally {\n setIsLoading(false)\n }\n }, [isLocked, dispatchFields, applyLockStateToFields])\n\n return (\n <div className=\"translation-settings-lock-container\">\n <button\n className={`translation-settings-lock-button ${isLocked ? 'locked' : 'unlocked'}`}\n disabled={isLoading}\n onClick={toggleLock}\n title={\n isLocked\n ? '🔒 Settings are locked to prevent accidental changes. Click to unlock and edit.'\n : '🔓 Settings are unlocked. Click to lock after saving your changes.'\n }\n type=\"button\"\n >\n <span className=\"translation-settings-lock-icon\">\n {isLoading ? '⏳' : isLocked ? '🔒' : '🔓'}\n </span>\n <span className=\"translation-settings-lock-label\">\n {isLoading ? 'Updating...' : isLocked ? 'Unlock Settings' : 'Lock Settings'}\n </span>\n </button>\n </div>\n )\n}\n"],"names":["useForm","useFormFields","React","useCallback","useEffect","useState","updateLockTranslationSettingsField","LockTranslation","lockField","fields","lockTranslationSettings","dispatchFields","isLocked","Boolean","value","isLoading","setIsLoading","applyLockStateToFields","locked","fieldsToLock","forEach","fieldPath","inputs","document","querySelectorAll","input","setAttribute","removeAttribute","timer","setTimeout","clearTimeout","toggleLock","newLockState","result","success","type","path","error","console","div","className","button","disabled","onClick","title","span"],"mappings":"AAAA;;AAEA,SAASA,OAAO,EAAEC,aAAa,QAAQ,iBAAgB;AACvD,OAAOC,SAASC,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAE/D,SAASC,kCAAkC,QAAQ,gCAA+B;AAClF,OAAO,cAAa;AAEpB;;;;;;CAMC,GACD,OAAO,MAAMC,kBAA4B;IACvC,MAAMC,YAAYP,cAAc,CAAC,CAACQ,OAAO,GAAKA,QAAQC;IACtD,MAAM,EAAEC,cAAc,EAAE,GAAGX;IAC3B,MAAMY,WAAWC,QAAQL,WAAWM,SAAS;IAC7C,MAAM,CAACC,WAAWC,aAAa,GAAGX,SAAS;IAE3C,2EAA2E;IAC3E,MAAMY,yBAAyBd,YAAY,CAACe;QAC1C,MAAMC,eAAe;YAAC;YAAgB;YAAoB;YAAS;YAAe;SAAY;QAE9FA,aAAaC,OAAO,CAAC,CAACC;YACpB,kDAAkD;YAClD,MAAMC,SAASC,SAASC,gBAAgB,CACtC,CAAC,OAAO,EAAEH,UAAU,kBAAkB,EAAEA,UAAU,eAAe,EAAEA,UAAU,EAAE,CAAC;YAGlFC,OAAOF,OAAO,CAAC,CAACK;gBACd,IAAIP,QAAQ;oBACVO,MAAMC,YAAY,CAAC,YAAY;gBACjC,OAAO;oBACLD,MAAME,eAAe,CAAC;gBACxB;YACF;QACF;IACF,GAAG,EAAE;IAEL,uEAAuE;IACvEvB,UAAU;QACR,iDAAiD;QACjD,MAAMwB,QAAQC,WAAW;YACvBZ,uBAAuBL;QACzB,GAAG;QAEH,OAAO,IAAMkB,aAAaF;IAC5B,GAAG;QAAChB;QAAUK;KAAuB;IAErC,MAAMc,aAAa5B,YAAY;QAC7B,MAAM6B,eAAe,CAACpB;QACtBI,aAAa;QAEb,IAAI;YACF,MAAMiB,SAAS,MAAM3B,mCAAmC0B;YAExD,IAAIC,OAAOC,OAAO,EAAE;gBAClB,iDAAiD;gBACjDvB,eAAe;oBACbwB,MAAM;oBACNC,MAAM;oBACNtB,OAAOmB,OAAOrB,QAAQ;gBACxB;gBAEA,6BAA6B;gBAC7BK,uBAAuBgB,OAAOrB,QAAQ;YACxC;QACF,EAAE,OAAOyB,OAAO;YACd,sCAAsC;YACtCC,QAAQD,KAAK,CAAC,4BAA4BA;QAC5C,SAAU;YACRrB,aAAa;QACf;IACF,GAAG;QAACJ;QAAUD;QAAgBM;KAAuB;IAErD,qBACE,KAACsB;QAAIC,WAAU;kBACb,cAAA,MAACC;YACCD,WAAW,CAAC,iCAAiC,EAAE5B,WAAW,WAAW,YAAY;YACjF8B,UAAU3B;YACV4B,SAASZ;YACTa,OACEhC,WACI,oFACA;YAENuB,MAAK;;8BAEL,KAACU;oBAAKL,WAAU;8BACbzB,YAAY,MAAMH,WAAW,OAAO;;8BAEvC,KAACiC;oBAAKL,WAAU;8BACbzB,YAAY,gBAAgBH,WAAW,oBAAoB;;;;;AAKtE,EAAC"}
1
+ {"version":3,"sources":["../../../src/components/LockTranslation/index.tsx"],"sourcesContent":["'use client'\n\nimport { useForm, useFormFields } from '@payloadcms/ui'\nimport React, { useCallback, useEffect, useState } from 'react'\n\nimport { updateLockTranslationSettingsField } from './actions/lockTranslations.js'\nimport './style.css'\n\n/**\n * Component that provides lock/unlock functionality for translation settings\n * - Fields are locked by default (read-only)\n * - User must click \"Unlock\" to edit\n * - After saving, fields automatically lock again\n * - Updates the hidden lockTranslationSettings checkbox field to enable server-side access control\n */\nexport const LockTranslation: React.FC = () => {\n const lockField = useFormFields(([fields]) => fields?.lockTranslationSettings)\n const { dispatchFields } = useForm()\n const isLocked = Boolean(lockField?.value ?? true)\n const [isLoading, setIsLoading] = useState(false)\n\n // Model select uses form lock state in OpenAiModelField (react-select).\n const applyLockStateToFields = useCallback((locked: boolean) => {\n const fieldsToLock = ['systemPrompt', 'translationRules', 'temperature', 'maxTokens']\n\n fieldsToLock.forEach((fieldPath) => {\n const inputs = document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(\n `[name=\"${fieldPath}\"], textarea[id*=\"${fieldPath}\"], input[id*=\"${fieldPath}\"]`,\n )\n\n inputs.forEach((input) => {\n if (locked) {\n input.setAttribute('disabled', 'true')\n } else {\n input.removeAttribute('disabled')\n }\n })\n })\n }, [])\n\n // Apply initial lock state when component mounts or lock state changes\n useEffect(() => {\n // Small delay to ensure form fields are rendered\n const timer = setTimeout(() => {\n applyLockStateToFields(isLocked)\n }, 100)\n\n return () => clearTimeout(timer)\n }, [isLocked, applyLockStateToFields])\n\n const toggleLock = useCallback(async () => {\n const newLockState = !isLocked\n setIsLoading(true)\n\n try {\n const result = await updateLockTranslationSettingsField(newLockState)\n\n if (result.success) {\n // Update the lockTranslationSettings field value\n dispatchFields({\n type: 'UPDATE',\n path: 'lockTranslationSettings',\n value: result.isLocked,\n })\n\n // Apply lock state to fields\n applyLockStateToFields(result.isLocked)\n }\n } catch (error) {\n // eslint-disable-next-line no-console\n console.error('[LockTranslation] Error:', error)\n } finally {\n setIsLoading(false)\n }\n }, [isLocked, dispatchFields, applyLockStateToFields])\n\n return (\n <div className=\"translation-settings-lock-container\">\n <button\n className={`translation-settings-lock-button ${isLocked ? 'locked' : 'unlocked'}`}\n disabled={isLoading}\n onClick={toggleLock}\n title={\n isLocked\n ? '🔒 Settings are locked to prevent accidental changes. Click to unlock and edit.'\n : '🔓 Settings are unlocked. Click to lock after saving your changes.'\n }\n type=\"button\"\n >\n <span className=\"translation-settings-lock-icon\">\n {isLoading ? '⏳' : isLocked ? '🔒' : '🔓'}\n </span>\n <span className=\"translation-settings-lock-label\">\n {isLoading ? 'Updating...' : isLocked ? 'Unlock Settings' : 'Lock Settings'}\n </span>\n </button>\n </div>\n )\n}\n"],"names":["useForm","useFormFields","React","useCallback","useEffect","useState","updateLockTranslationSettingsField","LockTranslation","lockField","fields","lockTranslationSettings","dispatchFields","isLocked","Boolean","value","isLoading","setIsLoading","applyLockStateToFields","locked","fieldsToLock","forEach","fieldPath","inputs","document","querySelectorAll","input","setAttribute","removeAttribute","timer","setTimeout","clearTimeout","toggleLock","newLockState","result","success","type","path","error","console","div","className","button","disabled","onClick","title","span"],"mappings":"AAAA;;AAEA,SAASA,OAAO,EAAEC,aAAa,QAAQ,iBAAgB;AACvD,OAAOC,SAASC,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAE/D,SAASC,kCAAkC,QAAQ,gCAA+B;AAClF,OAAO,cAAa;AAEpB;;;;;;CAMC,GACD,OAAO,MAAMC,kBAA4B;IACvC,MAAMC,YAAYP,cAAc,CAAC,CAACQ,OAAO,GAAKA,QAAQC;IACtD,MAAM,EAAEC,cAAc,EAAE,GAAGX;IAC3B,MAAMY,WAAWC,QAAQL,WAAWM,SAAS;IAC7C,MAAM,CAACC,WAAWC,aAAa,GAAGX,SAAS;IAE3C,wEAAwE;IACxE,MAAMY,yBAAyBd,YAAY,CAACe;QAC1C,MAAMC,eAAe;YAAC;YAAgB;YAAoB;YAAe;SAAY;QAErFA,aAAaC,OAAO,CAAC,CAACC;YACpB,MAAMC,SAASC,SAASC,gBAAgB,CACtC,CAAC,OAAO,EAAEH,UAAU,kBAAkB,EAAEA,UAAU,eAAe,EAAEA,UAAU,EAAE,CAAC;YAGlFC,OAAOF,OAAO,CAAC,CAACK;gBACd,IAAIP,QAAQ;oBACVO,MAAMC,YAAY,CAAC,YAAY;gBACjC,OAAO;oBACLD,MAAME,eAAe,CAAC;gBACxB;YACF;QACF;IACF,GAAG,EAAE;IAEL,uEAAuE;IACvEvB,UAAU;QACR,iDAAiD;QACjD,MAAMwB,QAAQC,WAAW;YACvBZ,uBAAuBL;QACzB,GAAG;QAEH,OAAO,IAAMkB,aAAaF;IAC5B,GAAG;QAAChB;QAAUK;KAAuB;IAErC,MAAMc,aAAa5B,YAAY;QAC7B,MAAM6B,eAAe,CAACpB;QACtBI,aAAa;QAEb,IAAI;YACF,MAAMiB,SAAS,MAAM3B,mCAAmC0B;YAExD,IAAIC,OAAOC,OAAO,EAAE;gBAClB,iDAAiD;gBACjDvB,eAAe;oBACbwB,MAAM;oBACNC,MAAM;oBACNtB,OAAOmB,OAAOrB,QAAQ;gBACxB;gBAEA,6BAA6B;gBAC7BK,uBAAuBgB,OAAOrB,QAAQ;YACxC;QACF,EAAE,OAAOyB,OAAO;YACd,sCAAsC;YACtCC,QAAQD,KAAK,CAAC,4BAA4BA;QAC5C,SAAU;YACRrB,aAAa;QACf;IACF,GAAG;QAACJ;QAAUD;QAAgBM;KAAuB;IAErD,qBACE,KAACsB;QAAIC,WAAU;kBACb,cAAA,MAACC;YACCD,WAAW,CAAC,iCAAiC,EAAE5B,WAAW,WAAW,YAAY;YACjF8B,UAAU3B;YACV4B,SAASZ;YACTa,OACEhC,WACI,oFACA;YAENuB,MAAK;;8BAEL,KAACU;oBAAKL,WAAU;8BACbzB,YAAY,MAAMH,WAAW,OAAO;;8BAEvC,KAACiC;oBAAKL,WAAU;8BACbzB,YAAY,gBAAgBH,WAAW,oBAAoB;;;;;AAKtE,EAAC"}
@@ -0,0 +1,2 @@
1
+ import type { TextFieldClientComponent } from 'payload';
2
+ export declare const OpenAiModelField: TextFieldClientComponent;
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { FieldDescription, FieldLabel, SelectInput, useField, useFormFields } from '@payloadcms/ui';
4
+ import React, { useEffect, useMemo, useState } from 'react';
5
+ const MODELS_URL = '/payload/api/globals/translation-settings/openai-models';
6
+ export const OpenAiModelField = ({ field, path, readOnly })=>{
7
+ const { value, setValue, showError, errorMessage } = useField({
8
+ path
9
+ });
10
+ const lockField = useFormFields(([fields])=>fields?.lockTranslationSettings);
11
+ const isLocked = lockField === undefined ? Boolean(readOnly) : Boolean(lockField.value);
12
+ const [options, setOptions] = useState([]);
13
+ const [loading, setLoading] = useState(true);
14
+ const [loadError, setLoadError] = useState(null);
15
+ useEffect(()=>{
16
+ const controller = new AbortController();
17
+ const load = async ()=>{
18
+ setLoading(true);
19
+ setLoadError(null);
20
+ try {
21
+ const res = await fetch(MODELS_URL, {
22
+ credentials: 'include',
23
+ signal: controller.signal
24
+ });
25
+ const data = await res.json();
26
+ if (!res.ok) {
27
+ throw new Error(data.error || `Failed to load models (${res.status})`);
28
+ }
29
+ setOptions(Array.isArray(data.models) ? data.models : []);
30
+ } catch (err) {
31
+ if (err instanceof Error && err.name === 'AbortError') return;
32
+ setLoadError(err instanceof Error ? err.message : 'Failed to load models');
33
+ } finally{
34
+ if (!controller.signal.aborted) setLoading(false);
35
+ }
36
+ };
37
+ void load();
38
+ return ()=>controller.abort();
39
+ }, []);
40
+ const optionsWithCurrent = useMemo(()=>{
41
+ if (!value || options.some((option)=>option.value === value)) return options;
42
+ return [
43
+ {
44
+ label: value,
45
+ value
46
+ },
47
+ ...options
48
+ ];
49
+ }, [
50
+ options,
51
+ value
52
+ ]);
53
+ const description = typeof field.admin?.description === 'string' ? field.admin.description : undefined;
54
+ const disabled = isLocked || loading;
55
+ return /*#__PURE__*/ _jsxs("div", {
56
+ className: "field-type select",
57
+ children: [
58
+ /*#__PURE__*/ _jsx(FieldLabel, {
59
+ label: field.label,
60
+ path: path,
61
+ required: field.required
62
+ }),
63
+ /*#__PURE__*/ _jsx(SelectInput, {
64
+ description: description,
65
+ Error: showError && errorMessage ? /*#__PURE__*/ _jsx("div", {
66
+ className: "field-error",
67
+ children: errorMessage
68
+ }) : null,
69
+ isClearable: false,
70
+ name: path,
71
+ onChange: (option)=>{
72
+ if (disabled || !option || Array.isArray(option)) return;
73
+ if ('value' in option && typeof option.value === 'string') {
74
+ setValue(option.value);
75
+ }
76
+ },
77
+ options: optionsWithCurrent,
78
+ path: path,
79
+ placeholder: loading ? 'Loading models…' : 'Select a model',
80
+ readOnly: disabled,
81
+ required: field.required,
82
+ showError: showError,
83
+ value: value ?? ''
84
+ }),
85
+ loadError ? /*#__PURE__*/ _jsx(FieldDescription, {
86
+ description: `Could not load OpenAI models: ${loadError}`,
87
+ path: path
88
+ }) : null
89
+ ]
90
+ });
91
+ };
92
+
93
+ //# sourceMappingURL=OpenAiModelField.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/OpenAiModelField.tsx"],"sourcesContent":["'use client'\n\nimport type { TextFieldClientComponent, OptionObject } from 'payload'\nimport { FieldDescription, FieldLabel, SelectInput, useField, useFormFields } from '@payloadcms/ui'\nimport React, { useEffect, useMemo, useState } from 'react'\n\nconst MODELS_URL = '/payload/api/globals/translation-settings/openai-models'\n\nexport const OpenAiModelField: TextFieldClientComponent = ({ field, path, readOnly }) => {\n const { value, setValue, showError, errorMessage } = useField<string>({ path })\n const lockField = useFormFields(([fields]) => fields?.lockTranslationSettings)\n const isLocked = lockField === undefined ? Boolean(readOnly) : Boolean(lockField.value)\n const [options, setOptions] = useState<OptionObject[]>([])\n const [loading, setLoading] = useState(true)\n const [loadError, setLoadError] = useState<string | null>(null)\n\n useEffect(() => {\n const controller = new AbortController()\n\n const load = async () => {\n setLoading(true)\n setLoadError(null)\n try {\n const res = await fetch(MODELS_URL, {\n credentials: 'include',\n signal: controller.signal,\n })\n const data = (await res.json()) as { models?: OptionObject[]; error?: string }\n if (!res.ok) {\n throw new Error(data.error || `Failed to load models (${res.status})`)\n }\n setOptions(Array.isArray(data.models) ? data.models : [])\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') return\n setLoadError(err instanceof Error ? err.message : 'Failed to load models')\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }\n\n void load()\n return () => controller.abort()\n }, [])\n\n const optionsWithCurrent = useMemo(() => {\n if (!value || options.some((option) => option.value === value)) return options\n return [{ label: value, value }, ...options]\n }, [options, value])\n\n const description =\n typeof field.admin?.description === 'string' ? field.admin.description : undefined\n\n const disabled = isLocked || loading\n\n return (\n <div className=\"field-type select\">\n <FieldLabel label={field.label} path={path} required={field.required} />\n <SelectInput\n description={description}\n Error={showError && errorMessage ? <div className=\"field-error\">{errorMessage}</div> : null}\n isClearable={false}\n name={path}\n onChange={(option) => {\n if (disabled || !option || Array.isArray(option)) return\n if ('value' in option && typeof option.value === 'string') {\n setValue(option.value)\n }\n }}\n options={optionsWithCurrent}\n path={path}\n placeholder={loading ? 'Loading models…' : 'Select a model'}\n readOnly={disabled}\n required={field.required}\n showError={showError}\n value={value ?? ''}\n />\n {loadError ? (\n <FieldDescription description={`Could not load OpenAI models: ${loadError}`} path={path} />\n ) : null}\n </div>\n )\n}\n"],"names":["FieldDescription","FieldLabel","SelectInput","useField","useFormFields","React","useEffect","useMemo","useState","MODELS_URL","OpenAiModelField","field","path","readOnly","value","setValue","showError","errorMessage","lockField","fields","lockTranslationSettings","isLocked","undefined","Boolean","options","setOptions","loading","setLoading","loadError","setLoadError","controller","AbortController","load","res","fetch","credentials","signal","data","json","ok","Error","error","status","Array","isArray","models","err","name","message","aborted","abort","optionsWithCurrent","some","option","label","description","admin","disabled","div","className","required","isClearable","onChange","placeholder"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { Endpoint } from 'payload';
2
+ export declare function isChatCapableModel(id: string): boolean;
3
+ /** GPT-5+ and o-series only accept the default temperature (1). */
4
+ export declare function supportsCustomTemperature(model: string): boolean;
5
+ export declare const listOpenAiModelsEndpoint: Endpoint;
@@ -0,0 +1,93 @@
1
+ const OPENAI_MODELS_CACHE_TTL_MS = 10 * 60 * 1000;
2
+ const NON_CHAT_MODEL = /(embedding|whisper|tts|dall-e|realtime|audio|image|moderation|transcribe|search-preview|codex|sora|babbage|davinci|computer-use|deep-research|gpt-oss|omni-moderation)/i;
3
+ let modelsCache = null;
4
+ let modelsInflight = null;
5
+ export function isChatCapableModel(id) {
6
+ if (!id || NON_CHAT_MODEL.test(id)) return false;
7
+ return /^(gpt-|o[1-9]|chatgpt-)/i.test(id);
8
+ }
9
+ /** GPT-5+ and o-series only accept the default temperature (1). */ export function supportsCustomTemperature(model) {
10
+ if (!model) return true;
11
+ if (/^o[1-9]/i.test(model)) return false;
12
+ if (/^gpt-5/i.test(model)) return false;
13
+ return true;
14
+ }
15
+ async function fetchChatCapableModels(apiKey) {
16
+ const openaiRes = await fetch('https://api.openai.com/v1/models', {
17
+ headers: {
18
+ Authorization: `Bearer ${apiKey}`
19
+ }
20
+ });
21
+ if (!openaiRes.ok) {
22
+ const errText = await openaiRes.text();
23
+ throw new Error(`OpenAI API ${openaiRes.status}: ${errText.slice(0, 200)}`);
24
+ }
25
+ const body = await openaiRes.json();
26
+ return (body.data ?? []).filter((model)=>typeof model?.id === 'string' && isChatCapableModel(model.id)).sort((a, b)=>(b.created ?? 0) - (a.created ?? 0) || a.id.localeCompare(b.id)).map((model)=>({
27
+ label: model.id,
28
+ value: model.id
29
+ }));
30
+ }
31
+ async function getCachedChatCapableModels(apiKey) {
32
+ const now = Date.now();
33
+ if (modelsCache && modelsCache.expiresAt > now) {
34
+ return modelsCache.models;
35
+ }
36
+ if (modelsInflight) return modelsInflight;
37
+ modelsInflight = fetchChatCapableModels(apiKey).then((models)=>{
38
+ modelsCache = {
39
+ models,
40
+ expiresAt: Date.now() + OPENAI_MODELS_CACHE_TTL_MS
41
+ };
42
+ return models;
43
+ }).finally(()=>{
44
+ modelsInflight = null;
45
+ });
46
+ return modelsInflight;
47
+ }
48
+ export const listOpenAiModelsEndpoint = {
49
+ path: '/openai-models',
50
+ method: 'get',
51
+ handler: async (req)=>{
52
+ if (!req.user) {
53
+ return Response.json({
54
+ error: 'Unauthorized'
55
+ }, {
56
+ status: 401
57
+ });
58
+ }
59
+ const apiKey = process.env.OPENAI_API_KEY;
60
+ if (!apiKey) {
61
+ return Response.json({
62
+ error: 'OPENAI_API_KEY is not set'
63
+ }, {
64
+ status: 500
65
+ });
66
+ }
67
+ try {
68
+ const models = await getCachedChatCapableModels(apiKey);
69
+ return Response.json({
70
+ models
71
+ });
72
+ } catch (err) {
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ if (message.startsWith('OpenAI API ')) {
75
+ return Response.json({
76
+ error: message
77
+ }, {
78
+ status: 502
79
+ });
80
+ }
81
+ req.payload.logger.error({
82
+ err
83
+ }, '[auto-translate] Failed to list OpenAI models');
84
+ return Response.json({
85
+ error: message
86
+ }, {
87
+ status: 500
88
+ });
89
+ }
90
+ }
91
+ };
92
+
93
+ //# sourceMappingURL=listOpenAiModels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/endpoints/listOpenAiModels.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\ntype OpenAIModel = {\n id: string\n created?: number\n object?: string\n owned_by?: string\n}\n\ntype ModelOption = { label: string; value: string }\n\nconst OPENAI_MODELS_CACHE_TTL_MS = 10 * 60 * 1000\n\nconst NON_CHAT_MODEL =\n /(embedding|whisper|tts|dall-e|realtime|audio|image|moderation|transcribe|search-preview|codex|sora|babbage|davinci|computer-use|deep-research|gpt-oss|omni-moderation)/i\n\nlet modelsCache: { models: ModelOption[]; expiresAt: number } | null = null\nlet modelsInflight: Promise<ModelOption[]> | null = null\n\nexport function isChatCapableModel(id: string): boolean {\n if (!id || NON_CHAT_MODEL.test(id)) return false\n return /^(gpt-|o[1-9]|chatgpt-)/i.test(id)\n}\n\n/** GPT-5+ and o-series only accept the default temperature (1). */\nexport function supportsCustomTemperature(model: string): boolean {\n if (!model) return true\n if (/^o[1-9]/i.test(model)) return false\n if (/^gpt-5/i.test(model)) return false\n return true\n}\n\nasync function fetchChatCapableModels(apiKey: string): Promise<ModelOption[]> {\n const openaiRes = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n\n if (!openaiRes.ok) {\n const errText = await openaiRes.text()\n throw new Error(`OpenAI API ${openaiRes.status}: ${errText.slice(0, 200)}`)\n }\n\n const body = (await openaiRes.json()) as { data?: OpenAIModel[] }\n return (body.data ?? [])\n .filter((model) => typeof model?.id === 'string' && isChatCapableModel(model.id))\n .sort((a, b) => (b.created ?? 0) - (a.created ?? 0) || a.id.localeCompare(b.id))\n .map((model) => ({\n label: model.id,\n value: model.id,\n }))\n}\n\nasync function getCachedChatCapableModels(apiKey: string): Promise<ModelOption[]> {\n const now = Date.now()\n if (modelsCache && modelsCache.expiresAt > now) {\n return modelsCache.models\n }\n\n if (modelsInflight) return modelsInflight\n\n modelsInflight = fetchChatCapableModels(apiKey)\n .then((models) => {\n modelsCache = { models, expiresAt: Date.now() + OPENAI_MODELS_CACHE_TTL_MS }\n return models\n })\n .finally(() => {\n modelsInflight = null\n })\n\n return modelsInflight\n}\n\nexport const listOpenAiModelsEndpoint: Endpoint = {\n path: '/openai-models',\n method: 'get',\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const apiKey = process.env.OPENAI_API_KEY\n if (!apiKey) {\n return Response.json({ error: 'OPENAI_API_KEY is not set' }, { status: 500 })\n }\n\n try {\n const models = await getCachedChatCapableModels(apiKey)\n return Response.json({ models })\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n if (message.startsWith('OpenAI API ')) {\n return Response.json({ error: message }, { status: 502 })\n }\n req.payload.logger.error({ err }, '[auto-translate] Failed to list OpenAI models')\n return Response.json({ error: message }, { status: 500 })\n }\n },\n}\n"],"names":["OPENAI_MODELS_CACHE_TTL_MS","NON_CHAT_MODEL","modelsCache","modelsInflight","isChatCapableModel","id","test","supportsCustomTemperature","model","fetchChatCapableModels","apiKey","openaiRes","fetch","headers","Authorization","ok","errText","text","Error","status","slice","body","json","data","filter","sort","a","b","created","localeCompare","map","label","value","getCachedChatCapableModels","now","Date","expiresAt","models","then","finally","listOpenAiModelsEndpoint","path","method","handler","req","user","Response","error","process","env","OPENAI_API_KEY","err","message","String","startsWith","payload","logger"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
1
  export { LockTranslation } from '../components/LockTranslation/index.js';
2
+ export { OpenAiModelField } from '../components/OpenAiModelField.js';
2
3
  export { TranslationControl } from '../components/TranslationControl.js';
3
4
  export type * from '../types/index.js';
@@ -1,4 +1,5 @@
1
1
  export { LockTranslation } from '../components/LockTranslation/index.js';
2
+ export { OpenAiModelField } from '../components/OpenAiModelField.js';
2
3
  export { TranslationControl } from '../components/TranslationControl.js';
3
4
 
4
5
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { LockTranslation } from '../components/LockTranslation/index.js'\nexport { TranslationControl } from '../components/TranslationControl.js'\nexport type * from '../types/index.js'\n"],"names":["LockTranslation","TranslationControl"],"mappings":"AAAA,SAASA,eAAe,QAAQ,yCAAwC;AACxE,SAASC,kBAAkB,QAAQ,sCAAqC"}
1
+ {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { LockTranslation } from '../components/LockTranslation/index.js'\nexport { OpenAiModelField } from '../components/OpenAiModelField.js'\nexport { TranslationControl } from '../components/TranslationControl.js'\nexport type * from '../types/index.js'\n"],"names":["LockTranslation","OpenAiModelField","TranslationControl"],"mappings":"AAAA,SAASA,eAAe,QAAQ,yCAAwC;AACxE,SAASC,gBAAgB,QAAQ,oCAAmC;AACpE,SAASC,kBAAkB,QAAQ,sCAAqC"}
@@ -1,9 +1,13 @@
1
+ import { listOpenAiModelsEndpoint, supportsCustomTemperature } from '../endpoints/listOpenAiModels.js';
1
2
  export const getTranslationSettingsGlobal = (slug = 'translation-settings')=>({
2
3
  slug,
3
4
  admin: {
4
5
  description: 'Configure translation settings including the system prompt and model parameters',
5
6
  group: 'Auto-Translate Settings'
6
7
  },
8
+ endpoints: [
9
+ listOpenAiModelsEndpoint
10
+ ],
7
11
  fields: [
8
12
  {
9
13
  name: 'settingsLock',
@@ -73,7 +77,10 @@ export const getTranslationSettingsGlobal = (slug = 'translation-settings')=>({
73
77
  }
74
78
  },
75
79
  admin: {
76
- description: 'The OpenAI model to use for translations (e.g., gpt-4o, gpt-4o-mini)'
80
+ components: {
81
+ Field: '@pigment/auto-translate/client#OpenAiModelField'
82
+ },
83
+ description: 'The OpenAI model to use for translations'
77
84
  },
78
85
  defaultValue: 'gpt-4o',
79
86
  label: 'Model',
@@ -89,8 +96,12 @@ export const getTranslationSettingsGlobal = (slug = 'translation-settings')=>({
89
96
  }
90
97
  },
91
98
  admin: {
92
- description: 'Controls randomness in translation (0.0-2.0). Lower values are more deterministic.',
93
- step: 0.1
99
+ description: 'Controls randomness in translation (0.0-2.0). Lower values are more deterministic. Not applied for GPT-5+ or o-series models.',
100
+ step: 0.1,
101
+ condition: (_data, siblingData)=>{
102
+ const model = typeof siblingData?.model === 'string' ? siblingData.model : '';
103
+ return supportsCustomTemperature(model);
104
+ }
94
105
  },
95
106
  defaultValue: 0.3,
96
107
  label: 'Temperature',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/globals/translationSettings.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\n\nexport const getTranslationSettingsGlobal = (\n slug: string = 'translation-settings',\n): GlobalConfig => ({\n slug,\n admin: {\n description: 'Configure translation settings including the system prompt and model parameters',\n group: 'Auto-Translate Settings',\n },\n fields: [\n {\n name: 'settingsLock',\n type: 'ui',\n admin: {\n components: {\n Field: '@pigment/auto-translate/client#LockTranslation',\n },\n position: 'sidebar',\n },\n },\n {\n name: 'lockTranslationSettings',\n type: 'checkbox',\n admin: {\n hidden: true,\n },\n defaultValue: true,\n },\n {\n name: 'systemPrompt',\n type: 'textarea',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n 'The main instruction for the AI translator. Use {fromLocale} and {toLocale} as placeholders.',\n rows: 3,\n },\n defaultValue: `You are a professional translator. Translate the JSON object values from {fromLocale} to {toLocale}.`,\n label: 'System Prompt',\n required: true,\n },\n {\n name: 'translationRules',\n type: 'textarea',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n \"⚠️ Do not edit if you don't know what you are doing. These rules ensure proper JSON translation behavior.\",\n rows: 8,\n },\n defaultValue: `Rules:\n- Only translate the values, never the keys\n- Preserve the exact JSON structure\n- Do not translate field names like 'id', 'createdAt', 'updatedAt', etc.\n- Maintain formatting, HTML tags, and special characters\n- Return only valid JSON without any markdown formatting or code blocks\n- If a value is already in the target language or is a proper noun, keep it as is`,\n label: 'Translation Rules',\n required: true,\n },\n {\n name: 'model',\n type: 'text',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description: 'The OpenAI model to use for translations (e.g., gpt-4o, gpt-4o-mini)',\n },\n defaultValue: 'gpt-4o',\n label: 'Model',\n required: true,\n },\n {\n name: 'temperature',\n type: 'number',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n 'Controls randomness in translation (0.0-2.0). Lower values are more deterministic.',\n step: 0.1,\n },\n defaultValue: 0.3,\n label: 'Temperature',\n max: 2,\n min: 0,\n required: true,\n },\n {\n name: 'maxTokens',\n type: 'number',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description: 'Maximum tokens for the response. Leave empty for automatic.',\n },\n label: 'Max Tokens',\n min: 1,\n },\n ],\n hooks: {\n afterChange: [\n async ({ data, req }) => {\n if (!data?.lockTranslationSettings) {\n const result = await req.payload.updateGlobal({\n slug: 'translation-settings',\n data: { lockTranslationSettings: true },\n req,\n })\n\n return result\n }\n },\n ],\n },\n label: 'Translation Settings',\n})\n"],"names":["getTranslationSettingsGlobal","slug","admin","description","group","fields","name","type","components","Field","position","hidden","defaultValue","access","read","update","data","lockTranslationSettings","rows","label","required","step","max","min","hooks","afterChange","req","result","payload","updateGlobal"],"mappings":"AAEA,OAAO,MAAMA,+BAA+B,CAC1CC,OAAe,sBAAsB,GACnB,CAAA;QAClBA;QACAC,OAAO;YACLC,aAAa;YACbC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;gBACNL,OAAO;oBACLM,YAAY;wBACVC,OAAO;oBACT;oBACAC,UAAU;gBACZ;YACF;YACA;gBACEJ,MAAM;gBACNC,MAAM;gBACNL,OAAO;oBACLS,QAAQ;gBACV;gBACAC,cAAc;YAChB;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAf,OAAO;oBACLC,aACE;oBACFe,MAAM;gBACR;gBACAN,cAAc,CAAC,oGAAoG,CAAC;gBACpHO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAf,OAAO;oBACLC,aACE;oBACFe,MAAM;gBACR;gBACAN,cAAc,CAAC;;;;;;iFAM4D,CAAC;gBAC5EO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAf,OAAO;oBACLC,aAAa;gBACf;gBACAS,cAAc;gBACdO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAf,OAAO;oBACLC,aACE;oBACFkB,MAAM;gBACR;gBACAT,cAAc;gBACdO,OAAO;gBACPG,KAAK;gBACLC,KAAK;gBACLH,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAf,OAAO;oBACLC,aAAa;gBACf;gBACAgB,OAAO;gBACPI,KAAK;YACP;SACD;QACDC,OAAO;YACLC,aAAa;gBACX,OAAO,EAAET,IAAI,EAAEU,GAAG,EAAE;oBAClB,IAAI,CAACV,MAAMC,yBAAyB;wBAClC,MAAMU,SAAS,MAAMD,IAAIE,OAAO,CAACC,YAAY,CAAC;4BAC5C5B,MAAM;4BACNe,MAAM;gCAAEC,yBAAyB;4BAAK;4BACtCS;wBACF;wBAEA,OAAOC;oBACT;gBACF;aACD;QACH;QACAR,OAAO;IACT,CAAA,EAAE"}
1
+ {"version":3,"sources":["../../src/globals/translationSettings.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\n\nimport { listOpenAiModelsEndpoint, supportsCustomTemperature } from '../endpoints/listOpenAiModels.js'\n\nexport const getTranslationSettingsGlobal = (\n slug: string = 'translation-settings',\n): GlobalConfig => ({\n slug,\n admin: {\n description: 'Configure translation settings including the system prompt and model parameters',\n group: 'Auto-Translate Settings',\n },\n endpoints: [listOpenAiModelsEndpoint],\n fields: [\n {\n name: 'settingsLock',\n type: 'ui',\n admin: {\n components: {\n Field: '@pigment/auto-translate/client#LockTranslation',\n },\n position: 'sidebar',\n },\n },\n {\n name: 'lockTranslationSettings',\n type: 'checkbox',\n admin: {\n hidden: true,\n },\n defaultValue: true,\n },\n {\n name: 'systemPrompt',\n type: 'textarea',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n 'The main instruction for the AI translator. Use {fromLocale} and {toLocale} as placeholders.',\n rows: 3,\n },\n defaultValue: `You are a professional translator. Translate the JSON object values from {fromLocale} to {toLocale}.`,\n label: 'System Prompt',\n required: true,\n },\n {\n name: 'translationRules',\n type: 'textarea',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n \"⚠️ Do not edit if you don't know what you are doing. These rules ensure proper JSON translation behavior.\",\n rows: 8,\n },\n defaultValue: `Rules:\n- Only translate the values, never the keys\n- Preserve the exact JSON structure\n- Do not translate field names like 'id', 'createdAt', 'updatedAt', etc.\n- Maintain formatting, HTML tags, and special characters\n- Return only valid JSON without any markdown formatting or code blocks\n- If a value is already in the target language or is a proper noun, keep it as is`,\n label: 'Translation Rules',\n required: true,\n },\n {\n name: 'model',\n type: 'text',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n components: {\n Field: '@pigment/auto-translate/client#OpenAiModelField',\n },\n description: 'The OpenAI model to use for translations',\n },\n defaultValue: 'gpt-4o',\n label: 'Model',\n required: true,\n },\n {\n name: 'temperature',\n type: 'number',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description:\n 'Controls randomness in translation (0.0-2.0). Lower values are more deterministic. Not applied for GPT-5+ or o-series models.',\n step: 0.1,\n condition: (_data, siblingData) => {\n const model = typeof siblingData?.model === 'string' ? siblingData.model : ''\n return supportsCustomTemperature(model)\n },\n },\n defaultValue: 0.3,\n label: 'Temperature',\n max: 2,\n min: 0,\n required: true,\n },\n {\n name: 'maxTokens',\n type: 'number',\n access: {\n read: () => true,\n update: ({ data }) => {\n return !data?.lockTranslationSettings\n },\n },\n admin: {\n description: 'Maximum tokens for the response. Leave empty for automatic.',\n },\n label: 'Max Tokens',\n min: 1,\n },\n ],\n hooks: {\n afterChange: [\n async ({ data, req }) => {\n if (!data?.lockTranslationSettings) {\n const result = await req.payload.updateGlobal({\n slug: 'translation-settings',\n data: { lockTranslationSettings: true },\n req,\n })\n\n return result\n }\n },\n ],\n },\n label: 'Translation Settings',\n})\n"],"names":["listOpenAiModelsEndpoint","supportsCustomTemperature","getTranslationSettingsGlobal","slug","admin","description","group","endpoints","fields","name","type","components","Field","position","hidden","defaultValue","access","read","update","data","lockTranslationSettings","rows","label","required","step","condition","_data","siblingData","model","max","min","hooks","afterChange","req","result","payload","updateGlobal"],"mappings":"AAEA,SAASA,wBAAwB,EAAEC,yBAAyB,QAAQ,mCAAkC;AAEtG,OAAO,MAAMC,+BAA+B,CAC1CC,OAAe,sBAAsB,GACnB,CAAA;QAClBA;QACAC,OAAO;YACLC,aAAa;YACbC,OAAO;QACT;QACAC,WAAW;YAACP;SAAyB;QACrCQ,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;gBACNN,OAAO;oBACLO,YAAY;wBACVC,OAAO;oBACT;oBACAC,UAAU;gBACZ;YACF;YACA;gBACEJ,MAAM;gBACNC,MAAM;gBACNN,OAAO;oBACLU,QAAQ;gBACV;gBACAC,cAAc;YAChB;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAhB,OAAO;oBACLC,aACE;oBACFgB,MAAM;gBACR;gBACAN,cAAc,CAAC,oGAAoG,CAAC;gBACpHO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAhB,OAAO;oBACLC,aACE;oBACFgB,MAAM;gBACR;gBACAN,cAAc,CAAC;;;;;;iFAM4D,CAAC;gBAC5EO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAhB,OAAO;oBACLO,YAAY;wBACVC,OAAO;oBACT;oBACAP,aAAa;gBACf;gBACAU,cAAc;gBACdO,OAAO;gBACPC,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAhB,OAAO;oBACLC,aACE;oBACFmB,MAAM;oBACNC,WAAW,CAACC,OAAOC;wBACjB,MAAMC,QAAQ,OAAOD,aAAaC,UAAU,WAAWD,YAAYC,KAAK,GAAG;wBAC3E,OAAO3B,0BAA0B2B;oBACnC;gBACF;gBACAb,cAAc;gBACdO,OAAO;gBACPO,KAAK;gBACLC,KAAK;gBACLP,UAAU;YACZ;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNM,QAAQ;oBACNC,MAAM,IAAM;oBACZC,QAAQ,CAAC,EAAEC,IAAI,EAAE;wBACf,OAAO,CAACA,MAAMC;oBAChB;gBACF;gBACAhB,OAAO;oBACLC,aAAa;gBACf;gBACAiB,OAAO;gBACPQ,KAAK;YACP;SACD;QACDC,OAAO;YACLC,aAAa;gBACX,OAAO,EAAEb,IAAI,EAAEc,GAAG,EAAE;oBAClB,IAAI,CAACd,MAAMC,yBAAyB;wBAClC,MAAMc,SAAS,MAAMD,IAAIE,OAAO,CAACC,YAAY,CAAC;4BAC5CjC,MAAM;4BACNgB,MAAM;gCAAEC,yBAAyB;4BAAK;4BACtCa;wBACF;wBAEA,OAAOC;oBACT;gBACF;aACD;QACH;QACAZ,OAAO;IACT,CAAA,EAAE"}
package/dist/index.js CHANGED
@@ -152,6 +152,54 @@ export const autoTranslate = (pluginOptions)=>(incomingConfig)=>{
152
152
  if (!collection.hooks.afterOperation) {
153
153
  collection.hooks.afterOperation = [];
154
154
  }
155
+ // ---------------------------------------------------------------
156
+ // Nested-docs compatibility
157
+ // ---------------------------------------------------------------
158
+ // Resolve nested-docs field slugs once, shared by both the
159
+ // beforeChange guard and the afterOperation translation hook below.
160
+ const nestedDocsFieldSlugs = resolveNestedDocsFieldSlugs(pluginOptions);
161
+ // Determine whether this collection actually has a breadcrumbs array
162
+ // field (added by nestedDocsPlugin or manually).
163
+ const hasBreadcrumbsField = nestedDocsFieldSlugs !== null && collection.fields.some((f)=>'name' in f && f.name === nestedDocsFieldSlugs.breadcrumbsSlug && f.type === 'array');
164
+ if (hasBreadcrumbsField && nestedDocsFieldSlugs) {
165
+ const { breadcrumbsSlug } = nestedDocsFieldSlugs;
166
+ // Guard: strip `id` from breadcrumb array items on non-default-locale writes.
167
+ //
168
+ // Root cause: nested-docs' `resaveChildren` afterChange hook re-saves each
169
+ // child document when a parent is updated. For locales where the child has no
170
+ // row yet, `payload.find(child, locale)` falls back to the default locale,
171
+ // returning breadcrumbs that carry the default-locale array-item `id`s.
172
+ // `formatBreadcrumb` preserves those ids via `{ ...breadcrumb, doc, label, url }`.
173
+ // When Payload then writes the child in the secondary locale, Drizzle attempts
174
+ // an INSERT with the same `id` — colliding on the `breadcrumbs.id` PRIMARY KEY
175
+ // (shared across locales) and producing `ValidationError: Value must be unique: id`.
176
+ //
177
+ // Fix: remove `id` from every breadcrumb item in incoming data for any
178
+ // non-default-locale write. Payload will assign fresh per-locale ids on INSERT.
179
+ // This hook fires AFTER nested-docs' `populateBreadcrumbsBeforeChange` (because
180
+ // autoTranslate is registered later), so breadcrumbs are already fully populated
181
+ // before we strip the stale ids.
182
+ if (!collection.hooks.beforeChange) {
183
+ collection.hooks.beforeChange = [];
184
+ }
185
+ collection.hooks.beforeChange.push(async ({ data, req })=>{
186
+ if (!req.locale || req.locale === defaultLocale) return data;
187
+ if (!data[breadcrumbsSlug] || !Array.isArray(data[breadcrumbsSlug])) return data;
188
+ return {
189
+ ...data,
190
+ [breadcrumbsSlug]: data[breadcrumbsSlug].map((item)=>{
191
+ if (item && typeof item === 'object') {
192
+ const { id: _id, ...rest } = item;
193
+ return rest;
194
+ }
195
+ return item;
196
+ })
197
+ };
198
+ });
199
+ if (pluginOptions.debugging) {
200
+ console.log(`[Auto-Translate Plugin] Nested-docs beforeChange guard added for: ${collectionSlug}`);
201
+ }
202
+ }
155
203
  // Main translation hook
156
204
  const translationHook = async ({ operation, req, result })=>{
157
205
  // Only process create and updateByID operations
@@ -209,9 +257,20 @@ export const autoTranslate = (pluginOptions)=>(incomingConfig)=>{
209
257
  }
210
258
  // Get global/collection-level excluded fields
211
259
  const configExcludedFields = translationService.getConfigExcludedFields(collectionSlug);
260
+ // Exclude nested-docs-managed fields from the AI translation payload.
261
+ // `parent` is locale-invariant (the same relationship across all locales)
262
+ // and must never be overwritten with an AI-translated value.
263
+ // `breadcrumbs` are computed and managed entirely by nested-docs; sending
264
+ // them through the AI would produce garbled data and would be overwritten
265
+ // by nested-docs anyway.
266
+ const nestedDocsExcludedFields = nestedDocsFieldSlugs ? [
267
+ nestedDocsFieldSlugs.parentSlug,
268
+ nestedDocsFieldSlugs.breadcrumbsSlug
269
+ ] : [];
212
270
  const allExcludedPaths = [
213
271
  ...excludedPaths,
214
- ...configExcludedFields
272
+ ...configExcludedFields,
273
+ ...nestedDocsExcludedFields
215
274
  ];
216
275
  if (pluginOptions.debugging && allExcludedPaths.length > 0) {
217
276
  req.payload.logger.info(`[Auto-Translate Plugin] Excluded paths for ${targetLocale}: ${allExcludedPaths.join(', ')}`);
@@ -268,6 +327,13 @@ export const autoTranslate = (pluginOptions)=>(incomingConfig)=>{
268
327
  // a Postgres 23505 unique-constraint violation.
269
328
  const strippedArrayIds = stripArrayItemIds(finalData);
270
329
  const updateData = stripSystemFields(strippedArrayIds);
330
+ // Remove nested-docs-managed fields from the update payload entirely.
331
+ // They were already excluded from translation, but defensively delete them
332
+ // here too so a future refactor cannot accidentally re-introduce them.
333
+ if (nestedDocsFieldSlugs) {
334
+ delete updateData[nestedDocsFieldSlugs.parentSlug];
335
+ delete updateData[nestedDocsFieldSlugs.breadcrumbsSlug];
336
+ }
271
337
  // Update the document in the target locale
272
338
  await req.payload.update({
273
339
  id: doc.id,
@@ -284,6 +350,21 @@ export const autoTranslate = (pluginOptions)=>(incomingConfig)=>{
284
350
  req.payload.logger.info(`[Auto-Translate Plugin] Successfully translated ${collectionSlug}:${doc.id} to ${targetLocale}`);
285
351
  }
286
352
  } catch (error) {
353
+ // When @payloadcms/plugin-nested-docs `resaveChildren` re-saves a child
354
+ // document that has already been translated, Drizzle's locale-table upsert
355
+ // uses `ON CONFLICT (id)` as the conflict target. Because we pass a freshly
356
+ // generated UUID for `id`, there is no conflict on `id` — but the existing
357
+ // row's `(_parent_id, _locale)` unique constraint fires instead. Postgres
358
+ // surfaces this as a unique-constraint violation, and Payload/Drizzle maps
359
+ // it to a ValidationError with path "id". In this case the locale row that
360
+ // already exists is valid (it was written by an earlier translation pass),
361
+ // so we skip the write and continue rather than surfacing a false failure.
362
+ if (isLocaleRowAlreadyExistsError(error)) {
363
+ if (pluginOptions.debugging) {
364
+ req.payload.logger.info(`[Auto-Translate Plugin] Skipping ${collectionSlug}:${doc.id} → ${targetLocale}: locale row already exists (Drizzle upsert conflict on _parent_id/_locale). Existing translation is kept.`);
365
+ }
366
+ continue;
367
+ }
287
368
  // Log detailed error information
288
369
  const errorMessage = error instanceof Error ? error.message : String(error);
289
370
  const errorStack = error instanceof Error ? error.stack : undefined;
@@ -332,6 +413,46 @@ export const autoTranslate = (pluginOptions)=>(incomingConfig)=>{
332
413
  }
333
414
  return config;
334
415
  };
416
+ /**
417
+ * Detects the specific error pattern produced when Drizzle's locale-table upsert
418
+ * encounters an already-existing row for (_parent_id, _locale).
419
+ *
420
+ * Root cause: Drizzle issues `INSERT … ON CONFLICT (id) DO UPDATE`, generating a
421
+ * fresh UUID for `id`. Because that UUID is new there is no conflict on `id`, but
422
+ * Postgres fires the unique constraint on `(_parent_id, _locale)` instead. Payload
423
+ * maps this constraint violation to a ValidationError with `{ path: "id", message:
424
+ * "Value must be unique" }`.
425
+ *
426
+ * This happens when a plugin such as `@payloadcms/plugin-nested-docs` re-saves child
427
+ * documents (via its `resaveChildren` afterChange hook) that were already translated
428
+ * in an earlier pass. The existing locale data is valid, so we can safely skip the
429
+ * redundant write.
430
+ */ function isLocaleRowAlreadyExistsError(error) {
431
+ if (!error || typeof error !== 'object') return false;
432
+ const err = error;
433
+ if (err['name'] !== 'ValidationError') return false;
434
+ const data = err['data'];
435
+ if (!data || !Array.isArray(data['errors'])) return false;
436
+ return data['errors'].some((e)=>e['path'] === 'id' && e['message'] === 'Value must be unique');
437
+ }
438
+ /**
439
+ * Resolves the breadcrumbs/parent field slugs used by @payloadcms/plugin-nested-docs.
440
+ *
441
+ * Returns null when nested-docs compat is explicitly disabled (`nestedDocs: false`).
442
+ * Otherwise returns the configured or default slugs so the caller can:
443
+ * 1. Exclude those fields from the AI translation payload.
444
+ * 2. Strip stale default-locale ids from breadcrumb array items before non-default
445
+ * locale writes (preventing the "Value must be unique: id" Postgres PK collision
446
+ * caused by nested-docs' resaveChildren hook).
447
+ */ function resolveNestedDocsFieldSlugs(pluginOptions) {
448
+ const opt = pluginOptions.nestedDocs;
449
+ // Explicit opt-out
450
+ if (opt === false) return null;
451
+ return {
452
+ breadcrumbsSlug: typeof opt === 'object' && opt.breadcrumbsFieldSlug ? opt.breadcrumbsFieldSlug : 'breadcrumbs',
453
+ parentSlug: typeof opt === 'object' && opt.parentFieldSlug ? opt.parentFieldSlug : 'parent'
454
+ };
455
+ }
335
456
  /**
336
457
  * Helper function to get nested value from object using dot notation
337
458
  */ function getNestedValue(obj, path) {