@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 +13 -2
- package/dist/components/LockTranslation/index.js +1 -3
- package/dist/components/LockTranslation/index.js.map +1 -1
- package/dist/components/OpenAiModelField.d.ts +2 -0
- package/dist/components/OpenAiModelField.js +93 -0
- package/dist/components/OpenAiModelField.js.map +1 -0
- package/dist/endpoints/listOpenAiModels.d.ts +5 -0
- package/dist/endpoints/listOpenAiModels.js +93 -0
- package/dist/endpoints/listOpenAiModels.js.map +1 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +1 -0
- package/dist/exports/client.js.map +1 -1
- package/dist/globals/translationSettings.js +14 -3
- package/dist/globals/translationSettings.js.map +1 -1
- package/dist/index.js +122 -1
- package/dist/index.js.map +1 -1
- package/dist/services/translationService.d.ts +6 -0
- package/dist/services/translationService.js +50 -15
- package/dist/services/translationService.js.map +1 -1
- package/dist/types/index.d.ts +33 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utilities/fieldHelpers.d.ts +26 -0
- package/dist/utilities/fieldHelpers.js +119 -0
- package/dist/utilities/fieldHelpers.js.map +1 -1
- package/package.json +58 -28
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**:
|
|
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
|
-
|
|
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
|
-
//
|
|
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 //
|
|
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,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":"AAWA,MAAMA,6BAA6B,KAAK,KAAK;AAE7C,MAAMC,iBACJ;AAEF,IAAIC,cAAmE;AACvE,IAAIC,iBAAgD;AAEpD,OAAO,SAASC,mBAAmBC,EAAU;IAC3C,IAAI,CAACA,MAAMJ,eAAeK,IAAI,CAACD,KAAK,OAAO;IAC3C,OAAO,2BAA2BC,IAAI,CAACD;AACzC;AAEA,iEAAiE,GACjE,OAAO,SAASE,0BAA0BC,KAAa;IACrD,IAAI,CAACA,OAAO,OAAO;IACnB,IAAI,WAAWF,IAAI,CAACE,QAAQ,OAAO;IACnC,IAAI,UAAUF,IAAI,CAACE,QAAQ,OAAO;IAClC,OAAO;AACT;AAEA,eAAeC,uBAAuBC,MAAc;IAClD,MAAMC,YAAY,MAAMC,MAAM,oCAAoC;QAChEC,SAAS;YAAEC,eAAe,CAAC,OAAO,EAAEJ,QAAQ;QAAC;IAC/C;IAEA,IAAI,CAACC,UAAUI,EAAE,EAAE;QACjB,MAAMC,UAAU,MAAML,UAAUM,IAAI;QACpC,MAAM,IAAIC,MAAM,CAAC,WAAW,EAAEP,UAAUQ,MAAM,CAAC,EAAE,EAAEH,QAAQI,KAAK,CAAC,GAAG,MAAM;IAC5E;IAEA,MAAMC,OAAQ,MAAMV,UAAUW,IAAI;IAClC,OAAO,AAACD,CAAAA,KAAKE,IAAI,IAAI,EAAE,AAAD,EACnBC,MAAM,CAAC,CAAChB,QAAU,OAAOA,OAAOH,OAAO,YAAYD,mBAAmBI,MAAMH,EAAE,GAC9EoB,IAAI,CAAC,CAACC,GAAGC,IAAM,AAACA,CAAAA,EAAEC,OAAO,IAAI,CAAA,IAAMF,CAAAA,EAAEE,OAAO,IAAI,CAAA,KAAMF,EAAErB,EAAE,CAACwB,aAAa,CAACF,EAAEtB,EAAE,GAC7EyB,GAAG,CAAC,CAACtB,QAAW,CAAA;YACfuB,OAAOvB,MAAMH,EAAE;YACf2B,OAAOxB,MAAMH,EAAE;QACjB,CAAA;AACJ;AAEA,eAAe4B,2BAA2BvB,MAAc;IACtD,MAAMwB,MAAMC,KAAKD,GAAG;IACpB,IAAIhC,eAAeA,YAAYkC,SAAS,GAAGF,KAAK;QAC9C,OAAOhC,YAAYmC,MAAM;IAC3B;IAEA,IAAIlC,gBAAgB,OAAOA;IAE3BA,iBAAiBM,uBAAuBC,QACrC4B,IAAI,CAAC,CAACD;QACLnC,cAAc;YAAEmC;YAAQD,WAAWD,KAAKD,GAAG,KAAKlC;QAA2B;QAC3E,OAAOqC;IACT,GACCE,OAAO,CAAC;QACPpC,iBAAiB;IACnB;IAEF,OAAOA;AACT;AAEA,OAAO,MAAMqC,2BAAqC;IAChDC,MAAM;IACNC,QAAQ;IACRC,SAAS,OAAOC;QACd,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASxB,IAAI,CAAC;gBAAEyB,OAAO;YAAe,GAAG;gBAAE5B,QAAQ;YAAI;QAChE;QAEA,MAAMT,SAASsC,QAAQC,GAAG,CAACC,cAAc;QACzC,IAAI,CAACxC,QAAQ;YACX,OAAOoC,SAASxB,IAAI,CAAC;gBAAEyB,OAAO;YAA4B,GAAG;gBAAE5B,QAAQ;YAAI;QAC7E;QAEA,IAAI;YACF,MAAMkB,SAAS,MAAMJ,2BAA2BvB;YAChD,OAAOoC,SAASxB,IAAI,CAAC;gBAAEe;YAAO;QAChC,EAAE,OAAOc,KAAK;YACZ,MAAMC,UAAUD,eAAejC,QAAQiC,IAAIC,OAAO,GAAGC,OAAOF;YAC5D,IAAIC,QAAQE,UAAU,CAAC,gBAAgB;gBACrC,OAAOR,SAASxB,IAAI,CAAC;oBAAEyB,OAAOK;gBAAQ,GAAG;oBAAEjC,QAAQ;gBAAI;YACzD;YACAyB,IAAIW,OAAO,CAACC,MAAM,CAACT,KAAK,CAAC;gBAAEI;YAAI,GAAG;YAClC,OAAOL,SAASxB,IAAI,CAAC;gBAAEyB,OAAOK;YAAQ,GAAG;gBAAEjC,QAAQ;YAAI;QACzD;IACF;AACF,EAAC"}
|
package/dist/exports/client.d.ts
CHANGED
package/dist/exports/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|