transduck 0.7.0 → 0.9.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/dist/cli.js +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/handler.d.ts +9 -0
- package/dist/handler.js +70 -31
- package/dist/react/provider.d.ts +8 -3
- package/dist/react/provider.js +51 -22
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/config.ts +9 -0
- package/src/handler.ts +162 -117
- package/src/react/provider.tsx +64 -22
- package/tests/ait.test.ts +1 -0
- package/tests/backend.test.ts +1 -0
- package/tests/config.test.ts +26 -0
- package/tests/handler.test.ts +170 -7
- package/tests/hooks.test.ts +1 -0
- package/tests/providers.test.ts +1 -0
- package/tests/react-provider.test.tsx +204 -22
package/dist/cli.js
CHANGED
|
@@ -637,7 +637,7 @@ export async function runStats(opts) {
|
|
|
637
637
|
}
|
|
638
638
|
// CLI entry point
|
|
639
639
|
const program = new Command();
|
|
640
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
640
|
+
program.name('transduck').description('AI-native translation tool').version('0.9.0');
|
|
641
641
|
program.command('init')
|
|
642
642
|
.description('Initialize a new transduck project')
|
|
643
643
|
.action(async () => {
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -37,6 +37,10 @@ export function loadConfig(path) {
|
|
|
37
37
|
const backendModel = backend.model ?? 'gpt-4.1-mini';
|
|
38
38
|
const backendTimeout = backend.timeout_seconds ?? 10;
|
|
39
39
|
const backendMaxRetries = backend.max_retries ?? 2;
|
|
40
|
+
const backendMaxConcurrency = backend.max_concurrency ?? 10;
|
|
41
|
+
if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
|
|
42
|
+
throw new Error(`backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`);
|
|
43
|
+
}
|
|
40
44
|
const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
|
|
41
45
|
let readOnly = raw.runtime?.read_only ?? false;
|
|
42
46
|
if (provider === 'claude_code') {
|
|
@@ -55,6 +59,7 @@ export function loadConfig(path) {
|
|
|
55
59
|
backendModel,
|
|
56
60
|
backendTimeout,
|
|
57
61
|
backendMaxRetries,
|
|
62
|
+
backendMaxConcurrency,
|
|
58
63
|
readOnly,
|
|
59
64
|
};
|
|
60
65
|
}
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { type TranslationHook } from './hooks.js';
|
|
2
|
+
/**
|
|
3
|
+
* Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
|
|
4
|
+
* then `release()` when done. When the active count is at the limit,
|
|
5
|
+
* further `acquire()` calls queue until a slot frees up.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createSemaphore(limit: number): {
|
|
8
|
+
acquire(): Promise<void>;
|
|
9
|
+
release(): void;
|
|
10
|
+
};
|
|
2
11
|
interface TranslationRequestString {
|
|
3
12
|
text: string;
|
|
4
13
|
context?: string;
|
package/dist/handler.js
CHANGED
|
@@ -8,6 +8,46 @@ import { fireHooks } from './hooks.js';
|
|
|
8
8
|
function hash(text) {
|
|
9
9
|
return createHash('sha256').update(text).digest('hex');
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
|
|
13
|
+
* then `release()` when done. When the active count is at the limit,
|
|
14
|
+
* further `acquire()` calls queue until a slot frees up.
|
|
15
|
+
*/
|
|
16
|
+
export function createSemaphore(limit) {
|
|
17
|
+
let active = 0;
|
|
18
|
+
const queue = [];
|
|
19
|
+
return {
|
|
20
|
+
acquire() {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
if (active < limit) {
|
|
23
|
+
active++;
|
|
24
|
+
resolve();
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
queue.push(() => {
|
|
28
|
+
active++;
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
release() {
|
|
35
|
+
active--;
|
|
36
|
+
const next = queue.shift();
|
|
37
|
+
if (next)
|
|
38
|
+
next();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function withSlot(sem, fn) {
|
|
43
|
+
await sem.acquire();
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
sem.release();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
11
51
|
let _store = null;
|
|
12
52
|
let _sharedStore = null;
|
|
13
53
|
async function getStore(configPath) {
|
|
@@ -48,34 +88,30 @@ export async function handleTranslationRequest(body, configPath, opts) {
|
|
|
48
88
|
const targetLang = body.language.toUpperCase();
|
|
49
89
|
const bodySourceLang = body.sourceLang?.toUpperCase();
|
|
50
90
|
const projectContextHash = hash(cfg.projectContext);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
// Single semaphore shared across strings and plurals so total outbound
|
|
92
|
+
// concurrency to the backend is bounded across both categories.
|
|
93
|
+
const sem = createSemaphore(cfg.backendMaxConcurrency);
|
|
94
|
+
const stringWork = (body.strings ?? []).map(item => withSlot(sem, async () => {
|
|
55
95
|
const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
|
|
56
96
|
const stringContextHash = hash(item.context ?? '');
|
|
57
|
-
const key = `${item.text}||${item.context ?? ''}`;
|
|
97
|
+
const key = `${item.text}||${item.context ?? ''}||${sourceLang}`;
|
|
58
98
|
const lookupParams = {
|
|
59
99
|
sourceText: item.text, sourceLang, targetLang,
|
|
60
100
|
projectContextHash, stringContextHash,
|
|
61
101
|
};
|
|
62
102
|
// Tier 1: local cache
|
|
63
103
|
const cached = await store.lookup(lookupParams);
|
|
64
|
-
if (cached !== null)
|
|
65
|
-
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
104
|
+
if (cached !== null)
|
|
105
|
+
return [key, cached];
|
|
68
106
|
// Tier 2: shared store
|
|
69
107
|
if (shared) {
|
|
70
108
|
const sharedCached = await shared.lookup(lookupParams);
|
|
71
109
|
if (sharedCached !== null) {
|
|
72
|
-
// Propagate to local
|
|
73
110
|
await store.insert({
|
|
74
111
|
...lookupParams, stringContext: item.context ?? '',
|
|
75
112
|
translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
|
|
76
113
|
});
|
|
77
|
-
|
|
78
|
-
continue;
|
|
114
|
+
return [key, sharedCached];
|
|
79
115
|
}
|
|
80
116
|
}
|
|
81
117
|
// Backend call
|
|
@@ -98,37 +134,31 @@ export async function handleTranslationRequest(body, configPath, opts) {
|
|
|
98
134
|
};
|
|
99
135
|
await fireHooks([], event, opts.onTranslate);
|
|
100
136
|
}
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
translations[key] = item.text;
|
|
137
|
+
return [key, translated];
|
|
105
138
|
}
|
|
139
|
+
return [key, item.text];
|
|
106
140
|
}
|
|
107
141
|
catch {
|
|
108
|
-
|
|
142
|
+
return [key, item.text];
|
|
109
143
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
for (const item of body.plurals ?? []) {
|
|
144
|
+
}));
|
|
145
|
+
const pluralWork = (body.plurals ?? []).map(item => withSlot(sem, async () => {
|
|
113
146
|
const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
|
|
114
147
|
const stringContextHash = hash(item.context ?? '');
|
|
115
148
|
const sourceKey = item.one + '\x00' + item.other;
|
|
116
|
-
const responseKey = `${sourceKey}||${item.context ?? ''}`;
|
|
149
|
+
const responseKey = `${sourceKey}||${item.context ?? ''}||${sourceLang}`;
|
|
117
150
|
const lookupParams = {
|
|
118
151
|
sourceText: sourceKey, sourceLang, targetLang,
|
|
119
152
|
projectContextHash, stringContextHash,
|
|
120
153
|
};
|
|
121
154
|
// Tier 1: local cache
|
|
122
155
|
const cachedForms = await store.lookupPlural(lookupParams);
|
|
123
|
-
if (Object.keys(cachedForms).length > 0)
|
|
124
|
-
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
156
|
+
if (Object.keys(cachedForms).length > 0)
|
|
157
|
+
return [responseKey, cachedForms];
|
|
127
158
|
// Tier 2: shared store
|
|
128
159
|
if (shared) {
|
|
129
160
|
const sharedForms = await shared.lookupPlural(lookupParams);
|
|
130
161
|
if (Object.keys(sharedForms).length > 0) {
|
|
131
|
-
// Propagate to local
|
|
132
162
|
for (const [cat, text] of Object.entries(sharedForms)) {
|
|
133
163
|
await store.insertPlural({
|
|
134
164
|
...lookupParams, stringContext: item.context ?? '',
|
|
@@ -136,8 +166,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
|
|
|
136
166
|
model: cfg.backendModel, status: 'translated',
|
|
137
167
|
});
|
|
138
168
|
}
|
|
139
|
-
|
|
140
|
-
continue;
|
|
169
|
+
return [responseKey, sharedForms];
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
172
|
// Backend call
|
|
@@ -162,12 +191,22 @@ export async function handleTranslationRequest(body, configPath, opts) {
|
|
|
162
191
|
};
|
|
163
192
|
await fireHooks([], event, opts.onTranslate);
|
|
164
193
|
}
|
|
165
|
-
|
|
194
|
+
return [responseKey, forms];
|
|
166
195
|
}
|
|
167
196
|
catch {
|
|
168
|
-
|
|
197
|
+
return [responseKey, { one: item.one, other: item.other }];
|
|
169
198
|
}
|
|
170
|
-
}
|
|
199
|
+
}));
|
|
200
|
+
const [stringResults, pluralResults] = await Promise.all([
|
|
201
|
+
Promise.all(stringWork),
|
|
202
|
+
Promise.all(pluralWork),
|
|
203
|
+
]);
|
|
204
|
+
const translations = {};
|
|
205
|
+
for (const [key, value] of stringResults)
|
|
206
|
+
translations[key] = value;
|
|
207
|
+
const plurals = {};
|
|
208
|
+
for (const [key, value] of pluralResults)
|
|
209
|
+
plurals[key] = value;
|
|
171
210
|
return { translations, plurals };
|
|
172
211
|
}
|
|
173
212
|
export function createTransDuckHandler(configPath, opts) {
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -30,11 +30,16 @@ export interface UseTransDuckReturn {
|
|
|
30
30
|
export declare function useTransDuck(): UseTransDuckReturn;
|
|
31
31
|
export declare function _resetReactState(): void;
|
|
32
32
|
export declare function _getReactState(): ReactState;
|
|
33
|
-
export
|
|
34
|
-
export declare function tPlural(one: string, other: string, count: number, opts?: {
|
|
33
|
+
export interface TOptions {
|
|
35
34
|
context?: string;
|
|
36
35
|
vars?: Record<string, string | number>;
|
|
37
|
-
|
|
36
|
+
/** Override the provider's source language for this call. */
|
|
37
|
+
sourceLang?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface TPluralOptions extends TOptions {
|
|
40
|
+
}
|
|
41
|
+
export declare function t(sourceText: string, contextOrOpts?: string | TOptions, vars?: Record<string, string | number>): TranslationResult;
|
|
42
|
+
export declare function tPlural(one: string, other: string, count: number, opts?: TPluralOptions): TranslationResult;
|
|
38
43
|
export declare const ait: typeof t;
|
|
39
44
|
export declare const aitPlural: typeof tPlural;
|
|
40
45
|
interface TransDuckProviderProps {
|
package/dist/react/provider.js
CHANGED
|
@@ -77,24 +77,43 @@ function schedulePendingFlush() {
|
|
|
77
77
|
_state.triggerFetch?.();
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Build the client-side cache / pending-set key.
|
|
82
|
+
* Format: text || context || sourceLang (last two always present)
|
|
83
|
+
*/
|
|
84
|
+
function buildKey(text, context, sourceLang) {
|
|
85
|
+
return `${text}||${context ?? ''}||${sourceLang}`;
|
|
86
|
+
}
|
|
87
|
+
export function t(sourceText, contextOrOpts, vars) {
|
|
88
|
+
let context;
|
|
89
|
+
let resolvedVars;
|
|
90
|
+
let sourceLangOverride;
|
|
91
|
+
if (typeof contextOrOpts === 'string' || contextOrOpts === undefined) {
|
|
92
|
+
context = contextOrOpts;
|
|
93
|
+
resolvedVars = vars;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
context = contextOrOpts.context;
|
|
97
|
+
resolvedVars = contextOrOpts.vars;
|
|
98
|
+
sourceLangOverride = contextOrOpts.sourceLang;
|
|
99
|
+
}
|
|
100
|
+
const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
|
|
101
|
+
const key = buildKey(sourceText, context, effectiveSourceLang);
|
|
83
102
|
_state.knownKeys.add(key);
|
|
84
103
|
// Same language — return source
|
|
85
|
-
if (_state.language.toUpperCase() ===
|
|
86
|
-
return new TranslationResult(interpolateVars(sourceText,
|
|
104
|
+
if (_state.language.toUpperCase() === effectiveSourceLang) {
|
|
105
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
87
106
|
pending: false,
|
|
88
|
-
sourceLang:
|
|
107
|
+
sourceLang: effectiveSourceLang,
|
|
89
108
|
lang: _state.language,
|
|
90
109
|
});
|
|
91
110
|
}
|
|
92
111
|
// Cache hit
|
|
93
112
|
const cached = _state.cache.get(key);
|
|
94
113
|
if (cached !== undefined) {
|
|
95
|
-
return new TranslationResult(interpolateVars(cached,
|
|
114
|
+
return new TranslationResult(interpolateVars(cached, resolvedVars), {
|
|
96
115
|
pending: false,
|
|
97
|
-
sourceLang:
|
|
116
|
+
sourceLang: effectiveSourceLang,
|
|
98
117
|
lang: _state.language,
|
|
99
118
|
source: 'cache',
|
|
100
119
|
});
|
|
@@ -103,14 +122,16 @@ export function t(sourceText, context, vars) {
|
|
|
103
122
|
_state.pendingStrings.add(key);
|
|
104
123
|
schedulePendingFlush();
|
|
105
124
|
// Return source text as fallback, marked pending
|
|
106
|
-
return new TranslationResult(interpolateVars(sourceText,
|
|
125
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
107
126
|
pending: true,
|
|
108
|
-
sourceLang:
|
|
127
|
+
sourceLang: effectiveSourceLang,
|
|
109
128
|
lang: _state.language,
|
|
110
129
|
});
|
|
111
130
|
}
|
|
112
131
|
export function tPlural(one, other, count, opts) {
|
|
113
132
|
const context = opts?.context;
|
|
133
|
+
const sourceLangOverride = opts?.sourceLang;
|
|
134
|
+
const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
|
|
114
135
|
let vars;
|
|
115
136
|
if (!opts?.vars) {
|
|
116
137
|
vars = { count };
|
|
@@ -122,15 +143,15 @@ export function tPlural(one, other, count, opts) {
|
|
|
122
143
|
vars = { ...opts.vars };
|
|
123
144
|
}
|
|
124
145
|
const sourceKey = `${one}\x00${other}`;
|
|
125
|
-
const cacheKey =
|
|
146
|
+
const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
|
|
126
147
|
_state.knownPlurals.add(cacheKey);
|
|
127
148
|
// Same language
|
|
128
|
-
if (_state.language.toUpperCase() ===
|
|
129
|
-
const rules = new Intl.PluralRules(
|
|
149
|
+
if (_state.language.toUpperCase() === effectiveSourceLang) {
|
|
150
|
+
const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
|
|
130
151
|
const form = rules.select(count) === 'one' ? one : other;
|
|
131
152
|
return new TranslationResult(interpolateVars(form, vars), {
|
|
132
153
|
pending: false,
|
|
133
|
-
sourceLang:
|
|
154
|
+
sourceLang: effectiveSourceLang,
|
|
134
155
|
lang: _state.language,
|
|
135
156
|
});
|
|
136
157
|
}
|
|
@@ -142,7 +163,7 @@ export function tPlural(one, other, count, opts) {
|
|
|
142
163
|
const form = cachedForms[category] ?? cachedForms['other'] ?? other;
|
|
143
164
|
return new TranslationResult(interpolateVars(form, vars), {
|
|
144
165
|
pending: false,
|
|
145
|
-
sourceLang:
|
|
166
|
+
sourceLang: effectiveSourceLang,
|
|
146
167
|
lang: _state.language,
|
|
147
168
|
source: 'cache',
|
|
148
169
|
});
|
|
@@ -151,11 +172,11 @@ export function tPlural(one, other, count, opts) {
|
|
|
151
172
|
_state.pendingPlurals.add(cacheKey);
|
|
152
173
|
schedulePendingFlush();
|
|
153
174
|
// Fallback to source form, marked pending
|
|
154
|
-
const rules = new Intl.PluralRules(
|
|
175
|
+
const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
|
|
155
176
|
const form = rules.select(count) === 'one' ? one : other;
|
|
156
177
|
return new TranslationResult(interpolateVars(form, vars), {
|
|
157
178
|
pending: true,
|
|
158
|
-
sourceLang:
|
|
179
|
+
sourceLang: effectiveSourceLang,
|
|
159
180
|
lang: _state.language,
|
|
160
181
|
});
|
|
161
182
|
}
|
|
@@ -266,15 +287,23 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
|
|
|
266
287
|
}
|
|
267
288
|
return;
|
|
268
289
|
}
|
|
269
|
-
// Build request body
|
|
290
|
+
// Build request body.
|
|
291
|
+
// Key format: text || context || sourceLang. Pop the tail segments so text
|
|
292
|
+
// can contain "||" safely.
|
|
270
293
|
const strings = Array.from(stringsToFetch).map(key => {
|
|
271
|
-
const
|
|
272
|
-
|
|
294
|
+
const parts = key.split('||');
|
|
295
|
+
const sourceLang = parts.pop();
|
|
296
|
+
const context = parts.pop();
|
|
297
|
+
const text = parts.join('||');
|
|
298
|
+
return { text, context: context || undefined, sourceLang };
|
|
273
299
|
});
|
|
274
300
|
const plurals = Array.from(pluralsToFetch).map(key => {
|
|
275
|
-
const
|
|
301
|
+
const parts = key.split('||');
|
|
302
|
+
const sourceLang = parts.pop();
|
|
303
|
+
const context = parts.pop();
|
|
304
|
+
const sourceKey = parts.join('||');
|
|
276
305
|
const [one, other] = sourceKey.split('\x00');
|
|
277
|
-
return { one, other, context: context || undefined };
|
|
306
|
+
return { one, other, context: context || undefined, sourceLang };
|
|
278
307
|
});
|
|
279
308
|
try {
|
|
280
309
|
const response = await fetch(endpoint, {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -743,7 +743,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
|
|
|
743
743
|
// CLI entry point
|
|
744
744
|
const program = new Command();
|
|
745
745
|
|
|
746
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
746
|
+
program.name('transduck').description('AI-native translation tool').version('0.9.0');
|
|
747
747
|
|
|
748
748
|
program.command('init')
|
|
749
749
|
.description('Initialize a new transduck project')
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface TransduckConfig {
|
|
|
17
17
|
backendModel: string;
|
|
18
18
|
backendTimeout: number;
|
|
19
19
|
backendMaxRetries: number;
|
|
20
|
+
backendMaxConcurrency: number;
|
|
20
21
|
sharedUrl: string | null;
|
|
21
22
|
readOnly: boolean;
|
|
22
23
|
}
|
|
@@ -61,6 +62,13 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
61
62
|
const backendTimeout = backend.timeout_seconds ?? 10;
|
|
62
63
|
const backendMaxRetries = backend.max_retries ?? 2;
|
|
63
64
|
|
|
65
|
+
const backendMaxConcurrency = backend.max_concurrency ?? 10;
|
|
66
|
+
if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
|
|
65
73
|
|
|
66
74
|
let readOnly = raw.runtime?.read_only ?? false;
|
|
@@ -81,6 +89,7 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
81
89
|
backendModel,
|
|
82
90
|
backendTimeout,
|
|
83
91
|
backendMaxRetries,
|
|
92
|
+
backendMaxConcurrency,
|
|
84
93
|
readOnly,
|
|
85
94
|
};
|
|
86
95
|
}
|