locai 2.1.3 → 2.3.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.
@@ -0,0 +1,9 @@
1
+ export declare class InteractiveReviewer {
2
+ private queue;
3
+ private decisions;
4
+ constructor();
5
+ loadQueue(): boolean;
6
+ start(): Promise<void>;
7
+ private handleAction;
8
+ private saveDecisions;
9
+ }
@@ -0,0 +1,109 @@
1
+ import prompts from "prompts";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import ConfidenceScorer from "../utils/confidence-scorer.js";
5
+ export class InteractiveReviewer {
6
+ queue;
7
+ decisions;
8
+ constructor() {
9
+ this.queue = [];
10
+ this.decisions = {
11
+ accepted: [],
12
+ edited: [],
13
+ rejected: [],
14
+ skipped: [],
15
+ };
16
+ }
17
+ loadQueue() {
18
+ const reviewFile = path.join(process.cwd(), ".localize-cache", "review-queue.json");
19
+ if (!fs.existsSync(reviewFile))
20
+ return false;
21
+ try {
22
+ const data = JSON.parse(fs.readFileSync(reviewFile, "utf8"));
23
+ this.queue = data.items || [];
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ return false;
28
+ }
29
+ }
30
+ async start() {
31
+ if (!this.loadQueue() || this.queue.length === 0) {
32
+ console.log("No items to review.");
33
+ return;
34
+ }
35
+ console.clear();
36
+ console.log(`\nšŸ” Interactive Review Mode (${this.queue.length} items)\n`);
37
+ for (let i = 0; i < this.queue.length; i++) {
38
+ const item = this.queue[i];
39
+ const progress = `[${i + 1}/${this.queue.length}]`;
40
+ console.log(`\n${progress} ${item.language.toUpperCase()} | Key: ${item.key}`);
41
+ console.log(`Confidence: ${ConfidenceScorer.formatConfidence(item.confidence.score)} (${item.confidence.level})`);
42
+ const response = await prompts({
43
+ type: "select",
44
+ name: "action",
45
+ message: `Translation: "${item.translation}"\nSource: "${item.source}"\nAction:`,
46
+ choices: [
47
+ { title: "āœ… Approve", value: "approve" },
48
+ { title: "āœļø Edit", value: "edit" },
49
+ { title: "ā­ļø Skip", value: "skip" },
50
+ { title: "🚪 Exit", value: "exit" },
51
+ ],
52
+ initial: 0,
53
+ });
54
+ if (response.action === "exit")
55
+ break;
56
+ await this.handleAction(response.action, item);
57
+ }
58
+ this.saveDecisions();
59
+ }
60
+ async handleAction(action, item) {
61
+ switch (action) {
62
+ case "approve":
63
+ this.decisions.accepted.push(item);
64
+ break;
65
+ case "edit":
66
+ const editResponse = await prompts({
67
+ type: "text",
68
+ name: "newTranslation",
69
+ message: "Enter new translation:",
70
+ initial: item.translation,
71
+ });
72
+ if (editResponse.newTranslation) {
73
+ item.translation = editResponse.newTranslation;
74
+ item.edited = true;
75
+ this.decisions.edited.push(item);
76
+ }
77
+ else {
78
+ this.decisions.skipped.push(item);
79
+ }
80
+ break;
81
+ case "skip":
82
+ this.decisions.skipped.push(item);
83
+ break;
84
+ }
85
+ }
86
+ saveDecisions() {
87
+ // Reuse existing ReviewCommand logic or implement save/apply logic here
88
+ // For now, let's save to a decision file that ReviewCommand can pick up or apply directly
89
+ const cacheDir = path.join(process.cwd(), ".localize-cache");
90
+ if (!fs.existsSync(cacheDir))
91
+ fs.mkdirSync(cacheDir, { recursive: true });
92
+ const decisionsFile = path.join(cacheDir, "review-decisions.json");
93
+ const data = {
94
+ timestamp: new Date().toISOString(),
95
+ decisions: this.decisions,
96
+ stats: {
97
+ accepted: this.decisions.accepted.length,
98
+ edited: this.decisions.edited.length,
99
+ rejected: this.decisions.rejected.length,
100
+ skipped: this.decisions.skipped.length,
101
+ },
102
+ };
103
+ fs.writeFileSync(decisionsFile, JSON.stringify(data, null, 2));
104
+ console.log(`\nāœ… Review complete! Decisions saved to ${decisionsFile}`);
105
+ console.log(`Run 'locai review --apply' to apply changes to files.`);
106
+ // Note: The existing CLI 'review' command applies immediately in its class.
107
+ // We might want to trigger apply here or ensuring ReviewCommand is used.
108
+ }
109
+ }
@@ -1,7 +1,3 @@
1
- /**
2
- * Interactive Review Command.
3
- * Allows manual review of low-confidence translations.
4
- */
5
1
  interface ReviewItem {
6
2
  key: string;
7
3
  language: string;
@@ -28,7 +24,6 @@ interface ReviewCommandConfig {
28
24
  declare class ReviewCommand {
29
25
  private config;
30
26
  private reviewQueue;
31
- private currentIndex;
32
27
  private decisions;
33
28
  /**
34
29
  * Initialize ReviewCommand.
@@ -44,35 +39,10 @@ declare class ReviewCommand {
44
39
  * Start interactive review session.
45
40
  */
46
41
  startReview(): Promise<void>;
47
- /**
48
- * Review a single translation item.
49
- * @param {ReviewItem} item - Translation item to review.
50
- * @param {number} current - Current item index (1-based).
51
- * @param {number} total - Total items.
52
- * @returns {Promise<string>} - Decision result.
53
- */
54
- reviewItem(item: ReviewItem, current: number, total: number): Promise<string>;
55
42
  /**
56
43
  * Handle user action input.
57
- * @param {string} action - User action code.
58
- * @param {ReviewItem} item - Current item.
59
- * @returns {Promise<string>} - Result status.
60
- */
61
- handleAction(action: string, item: ReviewItem): Promise<string>;
62
- /**
63
- * Edit translation interactively.
64
- * @param {ReviewItem} item - Item to edit.
65
- * @returns {Promise<ReviewItem>} - Edited item.
66
- */
67
- editTranslation(item: ReviewItem): Promise<ReviewItem>;
68
- /**
69
- * Show help information to the user.
70
- */
71
- showHelp(): void;
72
- /**
73
- * Show summary of review session.
74
44
  */
75
- showSummary(): void;
45
+ private handleAction;
76
46
  /**
77
47
  * Save review decisions to disk and apply them.
78
48
  */
@@ -88,12 +58,6 @@ declare class ReviewCommand {
88
58
  * @param {any} value - Value to set.
89
59
  */
90
60
  setNestedValue(obj: any, path: string, value: any): void;
91
- /**
92
- * Get user input from terminal.
93
- * @param {string} prompt - Prompt to display.
94
- * @returns {Promise<string>} - User input.
95
- */
96
- getUserInput(prompt: string): Promise<string>;
97
61
  /**
98
62
  * Export review queue to JSON.
99
63
  * @param {string} format - Export format (json or csv).
@@ -1,15 +1,10 @@
1
- /**
2
- * Interactive Review Command.
3
- * Allows manual review of low-confidence translations.
4
- */
5
1
  import fs from "fs";
6
2
  import path from "path";
7
- import readline from "readline";
3
+ import prompts from "prompts";
8
4
  import ConfidenceScorer from "../utils/confidence-scorer.js";
9
5
  class ReviewCommand {
10
6
  config;
11
7
  reviewQueue;
12
- currentIndex;
13
8
  decisions;
14
9
  /**
15
10
  * Initialize ReviewCommand.
@@ -18,7 +13,6 @@ class ReviewCommand {
18
13
  constructor(config) {
19
14
  this.config = config;
20
15
  this.reviewQueue = [];
21
- this.currentIndex = 0;
22
16
  this.decisions = {
23
17
  accepted: [],
24
18
  edited: [],
@@ -40,7 +34,6 @@ class ReviewCommand {
40
34
  try {
41
35
  const data = JSON.parse(fs.readFileSync(reviewFile, "utf8"));
42
36
  this.reviewQueue = data.items || [];
43
- console.log(`\nLoaded ${this.reviewQueue.length} items for review\n`);
44
37
  return true;
45
38
  }
46
39
  catch (error) {
@@ -56,159 +49,63 @@ class ReviewCommand {
56
49
  return;
57
50
  }
58
51
  if (this.reviewQueue.length === 0) {
59
- console.log("\nNo items need review. All translations meet quality threshold!\n");
52
+ console.log("No items to review.");
60
53
  return;
61
54
  }
62
- console.log("━".repeat(70));
63
- console.log(`Review Queue: ${this.reviewQueue.length} translations need attention`);
64
- console.log("━".repeat(70));
55
+ console.clear();
56
+ console.log(`\nInteractive Review Mode (${this.reviewQueue.length} items)\n`);
65
57
  for (let i = 0; i < this.reviewQueue.length; i++) {
66
- this.currentIndex = i;
67
58
  const item = this.reviewQueue[i];
68
- const decision = await this.reviewItem(item, i + 1, this.reviewQueue.length);
69
- if (decision === "quit") {
59
+ const progress = `[${i + 1}/${this.reviewQueue.length}]`;
60
+ console.log(`\n${progress} ${item.language.toUpperCase()} | Key: ${item.key}`);
61
+ console.log(`Confidence: ${ConfidenceScorer.formatConfidence(item.confidence.score)} (${item.confidence.level})`);
62
+ const response = await prompts({
63
+ type: "select",
64
+ name: "action",
65
+ message: `Translation: "${item.translation}"\nSource: "${item.source}"\nAction:`,
66
+ choices: [
67
+ { title: "[Approve]", value: "approve" },
68
+ { title: "[Edit]", value: "edit" },
69
+ { title: "[Skip]", value: "skip" },
70
+ { title: "[Exit]", value: "exit" },
71
+ ],
72
+ initial: 0,
73
+ });
74
+ if (response.action === "exit")
70
75
  break;
71
- }
76
+ await this.handleAction(response.action, item);
72
77
  }
73
- this.showSummary();
74
78
  this.saveDecisions();
75
79
  }
76
- /**
77
- * Review a single translation item.
78
- * @param {ReviewItem} item - Translation item to review.
79
- * @param {number} current - Current item index (1-based).
80
- * @param {number} total - Total items.
81
- * @returns {Promise<string>} - Decision result.
82
- */
83
- async reviewItem(item, current, total) {
84
- console.log(`\n[${current}/${total}] Translation Review`);
85
- console.log("━".repeat(70));
86
- console.log(`Key: ${item.key}`);
87
- console.log(`Language: ${item.language}`);
88
- const score = item.confidence?.score || 0;
89
- const level = item.confidence?.level || "unknown";
90
- console.log(`Confidence: ${ConfidenceScorer.formatConfidence(score)} (${level})`);
91
- console.log(`Category: ${item.category || "general"}`);
92
- console.log(`\nSource:`);
93
- console.log(` "${item.source}"`);
94
- console.log(`\nTranslation:`);
95
- console.log(` "${item.translation}"`);
96
- // Show issues if any
97
- if (item.confidence?.issues && item.confidence.issues.length > 0) {
98
- console.log(`\nIssues Detected:`);
99
- item.confidence.issues.forEach((issue) => {
100
- const icon = issue.severity === "critical" ? "[!]" : "[*]";
101
- console.log(` ${icon} ${issue.message}`);
102
- });
103
- }
104
- console.log(`\nActions:`);
105
- console.log(` [A] Accept [E] Edit [R] Reject [S] Skip`);
106
- console.log(` [N] Next [Q] Quit [?] Help`);
107
- const action = await this.getUserInput("\nYour choice: ");
108
- return this.handleAction(action.toLowerCase(), item);
109
- }
110
80
  /**
111
81
  * Handle user action input.
112
- * @param {string} action - User action code.
113
- * @param {ReviewItem} item - Current item.
114
- * @returns {Promise<string>} - Result status.
115
82
  */
116
83
  async handleAction(action, item) {
117
84
  switch (action) {
118
- case "a":
119
- case "accept":
85
+ case "approve":
120
86
  this.decisions.accepted.push(item);
121
- console.log("Accepted");
122
- return "continue";
87
+ break;
123
88
  case "edit": {
124
- const edited = await this.editTranslation(item);
125
- this.decisions.edited.push(edited);
126
- console.log("Edited and saved");
127
- return "continue";
89
+ const editResponse = await prompts({
90
+ type: "text",
91
+ name: "newTranslation",
92
+ message: "Enter new translation:",
93
+ initial: item.translation,
94
+ });
95
+ if (editResponse.newTranslation) {
96
+ item.translation = editResponse.newTranslation;
97
+ item.edited = true;
98
+ this.decisions.edited.push(item);
99
+ }
100
+ else {
101
+ this.decisions.skipped.push(item);
102
+ }
103
+ break;
128
104
  }
129
- case "r":
130
- case "reject":
131
- this.decisions.rejected.push(item);
132
- console.log("Rejected - will be retranslated");
133
- return "continue";
134
- case "s":
135
105
  case "skip":
136
106
  this.decisions.skipped.push(item);
137
- console.log("Skipped");
138
- return "continue";
139
- case "n":
140
- case "next":
141
- return "continue";
142
- case "q":
143
- case "quit":
144
- console.log("\nšŸ‘‹ Exiting review session...");
145
- return "quit";
146
- case "?":
147
- case "help":
148
- this.showHelp();
149
- return this.handleAction(await this.getUserInput("\nYour choice: "), item);
150
- default:
151
- console.log("Invalid action. Try again.");
152
- return this.handleAction(await this.getUserInput("\nYour choice: "), item);
153
- }
154
- }
155
- /**
156
- * Edit translation interactively.
157
- * @param {ReviewItem} item - Item to edit.
158
- * @returns {Promise<ReviewItem>} - Edited item.
159
- */
160
- async editTranslation(item) {
161
- console.log(`\nCurrent: "${item.translation}"`);
162
- const newTranslation = await this.getUserInput("New translation: ");
163
- if (newTranslation.trim() === "") {
164
- console.log("Warning: Empty translation, keeping original");
165
- return item;
107
+ break;
166
108
  }
167
- // Recalculate confidence for edited translation
168
- const newConfidence = ConfidenceScorer.calculateConfidence({
169
- aiConfidence: 1.0, // Manual edit gets high confidence
170
- sourceText: item.source,
171
- translation: newTranslation,
172
- sourceLang: item.sourceLang || this.config.source,
173
- targetLang: item.language,
174
- provider: "manual",
175
- category: item.category,
176
- });
177
- return {
178
- ...item,
179
- translation: newTranslation,
180
- confidence: newConfidence,
181
- edited: true,
182
- editedAt: new Date().toISOString(),
183
- };
184
- }
185
- /**
186
- * Show help information to the user.
187
- */
188
- showHelp() {
189
- console.log("\n━━━ Help ━━━");
190
- console.log("A (Accept): Approve translation as-is");
191
- console.log("E (Edit): Modify the translation manually");
192
- console.log("R (Reject): Mark for retranslation");
193
- console.log("S (Skip): Skip for now, review later");
194
- console.log("N (Next): Skip to next item");
195
- console.log("Q (Quit): Exit review session");
196
- console.log("? (Help): Show this help message");
197
- console.log("━".repeat(70));
198
- }
199
- /**
200
- * Show summary of review session.
201
- */
202
- showSummary() {
203
- console.log("\n━".repeat(70));
204
- console.log("Review Summary");
205
- console.log("━".repeat(70));
206
- console.log(`Accepted: ${this.decisions.accepted.length}`);
207
- console.log(`Edited: ${this.decisions.edited.length}`);
208
- console.log(`Rejected: ${this.decisions.rejected.length}`);
209
- console.log(`Skipped: ${this.decisions.skipped.length}`);
210
- console.log(`Total: ${this.decisions.accepted.length + this.decisions.edited.length + this.decisions.rejected.length + this.decisions.skipped.length}/${this.reviewQueue.length}`);
211
- console.log("━".repeat(70));
212
109
  }
213
110
  /**
214
111
  * Save review decisions to disk and apply them.
@@ -230,7 +127,7 @@ class ReviewCommand {
230
127
  },
231
128
  };
232
129
  fs.writeFileSync(decisionsFile, JSON.stringify(data, null, 2));
233
- console.log(`\nDecisions saved to: ${decisionsFile}`);
130
+ console.log(`\nReview complete! Decisions saved to ${decisionsFile}`);
234
131
  // Apply accepted and edited translations
235
132
  this.applyDecisions();
236
133
  }
@@ -284,23 +181,6 @@ class ReviewCommand {
284
181
  }
285
182
  current[keys[keys.length - 1]] = value;
286
183
  }
287
- /**
288
- * Get user input from terminal.
289
- * @param {string} prompt - Prompt to display.
290
- * @returns {Promise<string>} - User input.
291
- */
292
- getUserInput(prompt) {
293
- return new Promise((resolve) => {
294
- const rl = readline.createInterface({
295
- input: process.stdin,
296
- output: process.stdout,
297
- });
298
- rl.question(prompt, (answer) => {
299
- rl.close();
300
- resolve(answer.trim());
301
- });
302
- });
303
- }
304
184
  /**
305
185
  * Export review queue to JSON.
306
186
  * @param {string} format - Export format (json or csv).
@@ -0,0 +1,12 @@
1
+ import { FileFormatAdapter } from "./base-adapter.js";
2
+ /**
3
+ * Adapter for Android XML resources files (strings.xml)
4
+ */
5
+ export declare class AndroidAdapter implements FileFormatAdapter {
6
+ extensions: string[];
7
+ private parser;
8
+ private builder;
9
+ constructor();
10
+ parse(content: string): Promise<Record<string, any>>;
11
+ serialize(data: Record<string, any>): Promise<string>;
12
+ }
@@ -0,0 +1,107 @@
1
+ import { XMLParser, XMLBuilder } from "fast-xml-parser";
2
+ /**
3
+ * Adapter for Android XML resources files (strings.xml)
4
+ */
5
+ export class AndroidAdapter {
6
+ extensions = [".xml"];
7
+ parser;
8
+ builder;
9
+ constructor() {
10
+ this.parser = new XMLParser({
11
+ ignoreAttributes: false,
12
+ attributeNamePrefix: "@_",
13
+ textNodeName: "#text",
14
+ isArray: (name) => name === "string" || name === "string-array" || name === "item",
15
+ });
16
+ this.builder = new XMLBuilder({
17
+ ignoreAttributes: false,
18
+ attributeNamePrefix: "@_",
19
+ textNodeName: "#text",
20
+ format: true,
21
+ indentBy: " ",
22
+ });
23
+ }
24
+ async parse(content) {
25
+ try {
26
+ const parsed = this.parser.parse(content);
27
+ // Extract strings resources
28
+ const resources = parsed.resources || {};
29
+ const result = {};
30
+ // Handle regular strings
31
+ if (resources.string) {
32
+ resources.string.forEach((item) => {
33
+ const key = item["@_name"];
34
+ const value = item["#text"];
35
+ if (key) {
36
+ result[key] = value || "";
37
+ }
38
+ });
39
+ }
40
+ // Handle string arrays (flattened as array_name.index)
41
+ if (resources["string-array"]) {
42
+ resources["string-array"].forEach((array) => {
43
+ const arrayName = array["@_name"];
44
+ if (array.item) {
45
+ array.item.forEach((value, index) => {
46
+ result[`${arrayName}.${index}`] = value;
47
+ });
48
+ }
49
+ });
50
+ }
51
+ return result;
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Failed to parse Android XML: ${error.message}`);
55
+ }
56
+ }
57
+ async serialize(data) {
58
+ const resources = {
59
+ string: [],
60
+ "string-array": [],
61
+ };
62
+ // Collect keys to handle arrays properly
63
+ const arrays = {};
64
+ const strings = {};
65
+ for (const [key, value] of Object.entries(data)) {
66
+ if (key.includes(".")) {
67
+ const [arrayName, indexStr] = key.split(".");
68
+ if (!isNaN(Number(indexStr))) {
69
+ if (!arrays[arrayName])
70
+ arrays[arrayName] = [];
71
+ arrays[arrayName][Number(indexStr)] = value;
72
+ continue;
73
+ }
74
+ }
75
+ strings[key] = value;
76
+ }
77
+ // Add strings
78
+ for (const [key, value] of Object.entries(strings)) {
79
+ resources.string.push({
80
+ "@_name": key,
81
+ "#text": value,
82
+ });
83
+ }
84
+ // Add arrays
85
+ for (const [key, items] of Object.entries(arrays)) {
86
+ resources["string-array"].push({
87
+ "@_name": key,
88
+ item: items.filter((i) => i !== undefined),
89
+ });
90
+ }
91
+ // Sort by name for consistency
92
+ resources.string.sort((a, b) => a["@_name"].localeCompare(b["@_name"]));
93
+ resources["string-array"].sort((a, b) => a["@_name"].localeCompare(b["@_name"]));
94
+ if (resources["string-array"].length === 0)
95
+ delete resources["string-array"];
96
+ if (resources.string.length === 0)
97
+ delete resources.string;
98
+ const xmlObj = {
99
+ "?xml": {
100
+ "@_version": "1.0",
101
+ "@_encoding": "utf-8",
102
+ },
103
+ resources: resources,
104
+ };
105
+ return this.builder.build(xmlObj);
106
+ }
107
+ }
@@ -3,6 +3,8 @@ import { JsonAdapter } from "./json-adapter.js";
3
3
  import { YamlAdapter } from "./yaml-adapter.js";
4
4
  import { PoAdapter } from "./po-adapter.js";
5
5
  import { PropertiesAdapter } from "./properties-adapter.js";
6
+ import { AndroidAdapter } from "./android-adapter.js";
7
+ import { IosAdapter } from "./ios-adapter.js";
6
8
  /**
7
9
  * Factory class to manage file format adapters
8
10
  */
@@ -12,6 +14,8 @@ export class FormatFactory {
12
14
  new YamlAdapter(),
13
15
  new PoAdapter(),
14
16
  new PropertiesAdapter(),
17
+ new AndroidAdapter(),
18
+ new IosAdapter(),
15
19
  ];
16
20
  /**
17
21
  * Get adapter for a specific file path
@@ -0,0 +1,10 @@
1
+ import { FileFormatAdapter } from "./base-adapter.js";
2
+ /**
3
+ * Adapter for iOS .strings files
4
+ * Format: "KEY" = "VALUE";
5
+ */
6
+ export declare class IosAdapter implements FileFormatAdapter {
7
+ extensions: string[];
8
+ parse(content: string): Promise<Record<string, any>>;
9
+ serialize(data: Record<string, any>): Promise<string>;
10
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Adapter for iOS .strings files
3
+ * Format: "KEY" = "VALUE";
4
+ */
5
+ export class IosAdapter {
6
+ extensions = [".strings"];
7
+ async parse(content) {
8
+ const result = {};
9
+ const lines = content.split(/\r?\n/);
10
+ for (const line of lines) {
11
+ const trimmed = line.trim();
12
+ // Skip comments and empty lines
13
+ if (!trimmed || trimmed.startsWith("/*") || trimmed.startsWith("//")) {
14
+ continue;
15
+ }
16
+ // Parse "key" = "value";
17
+ // Handle escaped quotes in value
18
+ const match = trimmed.match(/^"(.+)"\s*=\s*"(.*)";$/);
19
+ if (match) {
20
+ const key = match[1];
21
+ let value = match[2];
22
+ // Unescape quotes and newlines if needed
23
+ value = value.replace(/\\"/g, '"').replace(/\\n/g, "\n");
24
+ result[key] = value;
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ async serialize(data) {
30
+ let output = "";
31
+ // Sort keys for deterministic output
32
+ const keys = Object.keys(data).sort();
33
+ for (const key of keys) {
34
+ let value = data[key];
35
+ if (typeof value !== "string") {
36
+ value = String(value);
37
+ }
38
+ // Escape quotes and newlines
39
+ const safeKey = key; // Keys usually don't have quotes, but simplistic handling
40
+ const safeValue = value.replace(/"/g, '\\"').replace(/\n/g, "\\n");
41
+ output += `"${safeKey}" = "${safeValue}";\n`;
42
+ }
43
+ return output;
44
+ }
45
+ }
@@ -1,6 +1,7 @@
1
1
  import * as deepseekProvider from "../providers/deepseek.js";
2
2
  import * as geminiProvider from "../providers/gemini.js";
3
3
  import * as openaiProvider from "../providers/openai.js";
4
+ import * as anthropicProvider from "../providers/anthropic.js";
4
5
  import * as dashscopeProvider from "../providers/dashscope.js";
5
6
  import * as xaiProvider from "../providers/xai.js";
6
7
  import FallbackProvider from "./fallback-provider.js";
@@ -23,6 +24,7 @@ class ProviderFactory {
23
24
  dashscope: dashscopeProvider,
24
25
  xai: xaiProvider,
25
26
  openai: openaiProvider,
27
+ anthropic: anthropicProvider,
26
28
  deepseek: deepseekProvider,
27
29
  gemini: geminiProvider,
28
30
  };
@@ -100,6 +102,7 @@ class ProviderFactory {
100
102
  dashscope: process.env.DASHSCOPE_API_KEY,
101
103
  xai: process.env.XAI_API_KEY,
102
104
  openai: process.env.OPENAI_API_KEY,
105
+ anthropic: process.env.ANTHROPIC_API_KEY,
103
106
  deepseek: process.env.DEEPSEEK_API_KEY,
104
107
  gemini: process.env.GEMINI_API_KEY,
105
108
  };
@@ -129,6 +132,7 @@ class ProviderFactory {
129
132
  dashscope: "DASHSCOPE_API_KEY",
130
133
  xai: "XAI_API_KEY",
131
134
  openai: "OPENAI_API_KEY",
135
+ anthropic: "ANTHROPIC_API_KEY",
132
136
  deepseek: "DEEPSEEK_API_KEY",
133
137
  gemini: "GEMINI_API_KEY",
134
138
  };
@@ -0,0 +1,25 @@
1
+ import BaseProvider, { ProviderConfig, TranslateOptions } from "./base-provider.js";
2
+ /**
3
+ * Provider implementation for Anthropic (Claude) models.
4
+ */
5
+ declare class AnthropicProvider extends BaseProvider {
6
+ private client;
7
+ /**
8
+ * Create a new AnthropicProvider instance.
9
+ * @param {ProviderConfig} config - Provider configuration.
10
+ */
11
+ constructor(config?: ProviderConfig);
12
+ getApiKey(): string | undefined;
13
+ getEndpoint(): string;
14
+ /**
15
+ * Translate text using Anthropic.
16
+ */
17
+ translate(text: string, sourceLang: string, targetLang: string, options?: TranslateOptions): Promise<string>;
18
+ analyze(prompt: string, options?: ProviderConfig): Promise<string>;
19
+ chat(messages: any[], options?: ProviderConfig): Promise<string>;
20
+ extractTranslation(response: any, providerName: string): string;
21
+ }
22
+ declare function translate(text: string, sourceLang: string, targetLang: string, options?: TranslateOptions): Promise<string>;
23
+ declare function analyze(prompt: string, options?: ProviderConfig): Promise<string>;
24
+ declare function chat(messages: any[], options?: ProviderConfig): Promise<string>;
25
+ export { translate, analyze, chat, AnthropicProvider };
@@ -0,0 +1,178 @@
1
+ import axios from "axios";
2
+ import BaseProvider from "./base-provider.js";
3
+ import { getPrompt, getAnalysisPrompt } from "../utils/prompt-templates.js";
4
+ import RetryHelper from "../utils/retry-helper.js";
5
+ /**
6
+ * Provider implementation for Anthropic (Claude) models.
7
+ */
8
+ class AnthropicProvider extends BaseProvider {
9
+ client;
10
+ /**
11
+ * Create a new AnthropicProvider instance.
12
+ * @param {ProviderConfig} config - Provider configuration.
13
+ */
14
+ constructor(config = {}) {
15
+ super("anthropic", config);
16
+ this.client = axios.create({
17
+ baseURL: "https://api.anthropic.com/v1",
18
+ headers: {
19
+ ...this.commonHeaders,
20
+ "x-api-key": this.getApiKey(),
21
+ "anthropic-version": "2023-06-01",
22
+ },
23
+ timeout: 30000,
24
+ maxRedirects: 0,
25
+ validateStatus: (status) => status < 500,
26
+ });
27
+ }
28
+ getApiKey() {
29
+ return process.env.ANTHROPIC_API_KEY;
30
+ }
31
+ getEndpoint() {
32
+ return "/messages";
33
+ }
34
+ /**
35
+ * Translate text using Anthropic.
36
+ */
37
+ async translate(text, sourceLang, targetLang, options = {}) {
38
+ this.validateRequest(text, sourceLang, targetLang);
39
+ // Prioritize user config > options config > default model
40
+ // Anthropic models: claude-3-5-sonnet-latest, claude-3-5-haiku-latest, claude-3-opus-latest
41
+ const config = this.getConfig(options.apiConfig?.anthropic);
42
+ const model = config.model || "claude-haiku-4-5-20251001";
43
+ const promptData = getPrompt("anthropic", sourceLang, targetLang, text, options);
44
+ return RetryHelper.withRetry(async () => {
45
+ try {
46
+ const response = await this.client.post(this.getEndpoint(), {
47
+ model: model,
48
+ ...promptData, // Contains { system: "...", messages: [...] }
49
+ max_tokens: config.maxTokens || 4096,
50
+ temperature: config.temperature,
51
+ });
52
+ this.validateResponse(response, this.name);
53
+ const translation = this.extractTranslation(response.data, this.name);
54
+ return this.sanitizeTranslation(translation);
55
+ }
56
+ catch (error) {
57
+ this.handleApiError(error, this.name);
58
+ throw error;
59
+ }
60
+ }, {
61
+ maxRetries: options.retryOptions?.maxRetries || 2,
62
+ initialDelay: options.retryOptions?.initialDelay || 1000,
63
+ context: "Anthropic Provider",
64
+ logContext: {
65
+ source: sourceLang,
66
+ target: targetLang,
67
+ },
68
+ });
69
+ }
70
+ async analyze(prompt, options = {}) {
71
+ const config = this.getConfig({
72
+ model: options.model || "claude-3-5-sonnet-latest",
73
+ temperature: options.temperature || 0.2,
74
+ maxTokens: options.maxTokens || 1000,
75
+ });
76
+ const promptData = getAnalysisPrompt("anthropic", prompt, options);
77
+ return RetryHelper.withRetry(async () => {
78
+ try {
79
+ const response = await this.client.post(this.getEndpoint(), {
80
+ model: config.model,
81
+ ...promptData,
82
+ max_tokens: config.maxTokens,
83
+ temperature: config.temperature,
84
+ });
85
+ this.validateResponse(response, this.name);
86
+ const result = this.extractTranslation(response.data, this.name);
87
+ return this.sanitizeTranslation(result);
88
+ }
89
+ catch (error) {
90
+ this.handleApiError(error, this.name);
91
+ throw error;
92
+ }
93
+ }, {
94
+ maxRetries: options.maxRetries || 2,
95
+ initialDelay: options.initialDelay || 1000,
96
+ context: "Anthropic Provider Analysis",
97
+ });
98
+ }
99
+ async chat(messages, options = {}) {
100
+ const config = this.getConfig({
101
+ model: options.model || "claude-3-5-sonnet-latest",
102
+ temperature: options.temperature || 0.3,
103
+ maxTokens: options.maxTokens || 4096,
104
+ });
105
+ // Anthropic requires system prompt to be top-level, separate from messages array
106
+ // Note: This simple implementation assumes messages passed in might be mixed.
107
+ // Ideally, the caller should separate system prompt, but we can do a quick check here.
108
+ let systemPrompt = undefined;
109
+ const cleanMessages = messages.filter((msg) => {
110
+ if (msg.role === "system") {
111
+ systemPrompt = msg.content;
112
+ return false;
113
+ }
114
+ return true;
115
+ });
116
+ return RetryHelper.withRetry(async () => {
117
+ try {
118
+ const payload = {
119
+ model: config.model,
120
+ messages: cleanMessages,
121
+ max_tokens: config.maxTokens,
122
+ temperature: config.temperature,
123
+ };
124
+ if (systemPrompt) {
125
+ payload.system = systemPrompt;
126
+ }
127
+ const response = await this.client.post(this.getEndpoint(), payload);
128
+ this.validateResponse(response, this.name);
129
+ const result = this.extractTranslation(response.data, this.name);
130
+ return this.sanitizeTranslation(result);
131
+ }
132
+ catch (error) {
133
+ this.handleApiError(error, this.name);
134
+ throw error;
135
+ }
136
+ }, {
137
+ maxRetries: options.maxRetries || 2,
138
+ initialDelay: options.initialDelay || 1000,
139
+ context: "Anthropic Provider Chat",
140
+ });
141
+ }
142
+ // Override extractTranslation to handle Anthropic's specific response structure
143
+ extractTranslation(response, providerName) {
144
+ // Check for error response first
145
+ if (response.error) {
146
+ const errorMsg = response.error.message || response.error.error || JSON.stringify(response.error);
147
+ throw new Error(`api: ${providerName} - ${errorMsg}`);
148
+ }
149
+ // Anthropic format: { content: [{ type: 'text', text: '...' }] }
150
+ if (response.content && Array.isArray(response.content) && response.content.length > 0) {
151
+ const textBlock = response.content.find((block) => block.type === "text");
152
+ if (textBlock && textBlock.text) {
153
+ return textBlock.text.trim();
154
+ }
155
+ }
156
+ // Fallback to base implementation for other structures (though unlikely for Anthropic)
157
+ return super.extractTranslation(response, providerName);
158
+ }
159
+ }
160
+ // Lazy singleton - created on first use
161
+ let anthropicProvider = null;
162
+ function getProvider() {
163
+ if (!anthropicProvider) {
164
+ anthropicProvider = new AnthropicProvider();
165
+ }
166
+ return anthropicProvider;
167
+ }
168
+ // Export both class and legacy functions
169
+ async function translate(text, sourceLang, targetLang, options = {}) {
170
+ return getProvider().translate(text, sourceLang, targetLang, options);
171
+ }
172
+ async function analyze(prompt, options = {}) {
173
+ return getProvider().analyze(prompt, options);
174
+ }
175
+ async function chat(messages, options = {}) {
176
+ return getProvider().chat(messages, options);
177
+ }
178
+ export { translate, analyze, chat, AnthropicProvider };
@@ -27,7 +27,7 @@ class GeminiProvider extends BaseProvider {
27
27
  return process.env.GEMINI_API_KEY;
28
28
  }
29
29
  getEndpoint(model) {
30
- return `/models/${model || "gemini-2.0-flash-exp"}:generateContent`;
30
+ return `/models/${model || "gemini-3-flash"}:generateContent`;
31
31
  }
32
32
  /**
33
33
  * Translate text using Gemini.
@@ -35,7 +35,7 @@ class GeminiProvider extends BaseProvider {
35
35
  async translate(text, sourceLang, targetLang, options = {}) {
36
36
  this.validateRequest(text, sourceLang, targetLang);
37
37
  const config = this.getConfig(options.apiConfig?.gemini);
38
- const model = config.model || "gemini-2.0-flash-exp";
38
+ const model = config.model || "gemini-3-flash";
39
39
  const apiKey = this.getApiKey();
40
40
  if (!apiKey) {
41
41
  throw new Error("GEMINI_API_KEY environment variable not found");
@@ -77,7 +77,7 @@ class GeminiProvider extends BaseProvider {
77
77
  }
78
78
  async analyze(prompt, options = {}) {
79
79
  const config = this.getConfig({
80
- model: options.model || "gemini-2.0-flash-exp",
80
+ model: options.model || "gemini-3-flash",
81
81
  temperature: options.temperature || 0.2,
82
82
  maxTokens: options.maxTokens || 1000,
83
83
  });
@@ -321,6 +321,7 @@ class ErrorHelper {
321
321
  deepseek: "DEEPSEEK_API_KEY",
322
322
  dashscope: "DASHSCOPE_API_KEY",
323
323
  xai: "XAI_API_KEY",
324
+ anthropic: "ANTHROPIC_API_KEY",
324
325
  };
325
326
  return envMap[provider?.toLowerCase()] || `${provider?.toUpperCase()}_API_KEY`;
326
327
  }
@@ -334,6 +335,7 @@ class ErrorHelper {
334
335
  deepseek: "https://platform.deepseek.com/api-keys",
335
336
  dashscope: "https://dashscope.console.aliyun.com/",
336
337
  xai: "https://console.x.ai/",
338
+ anthropic: "https://console.anthropic.com/settings/keys",
337
339
  };
338
340
  return urlMap[provider?.toLowerCase()] || "provider console";
339
341
  }
@@ -341,7 +343,7 @@ class ErrorHelper {
341
343
  * Suggest fallback provider
342
344
  */
343
345
  static _suggestFallbackProvider(currentProvider) {
344
- const fallbackOrder = ["openai", "gemini", "deepseek", "dashscope", "xai"];
346
+ const fallbackOrder = ["openai", "anthropic", "gemini", "deepseek", "dashscope", "xai"];
345
347
  const current = currentProvider?.toLowerCase();
346
348
  const index = fallbackOrder.indexOf(current);
347
349
  if (index === -1 || index === fallbackOrder.length - 1) {
@@ -5,7 +5,14 @@
5
5
  import path from "path";
6
6
  class InputValidator {
7
7
  static LANGUAGE_CODE_PATTERN = /^[a-z]{2}(-[a-z]{2})?$/;
8
- static VALID_PROVIDERS = ["dashscope", "xai", "openai", "deepseek", "gemini"];
8
+ static VALID_PROVIDERS = [
9
+ "dashscope",
10
+ "xai",
11
+ "openai",
12
+ "deepseek",
13
+ "gemini",
14
+ "anthropic",
15
+ ];
9
16
  static MAX_TEXT_LENGTH = 10000;
10
17
  static MAX_KEY_LENGTH = 500;
11
18
  static MAX_PATH_LENGTH = 1000;
@@ -236,6 +236,15 @@ Original Text: "${text}"`,
236
236
  },
237
237
  ],
238
238
  }),
239
+ anthropic: (sourceLang, targetLang, text, options) => ({
240
+ system: baseTranslationPromptTemplate(sourceLang, targetLang, text, options),
241
+ messages: [
242
+ {
243
+ role: "user",
244
+ content: text,
245
+ },
246
+ ],
247
+ }),
239
248
  deepseek: (sourceLang, targetLang, text, options) => ({
240
249
  messages: [
241
250
  {
@@ -317,6 +326,15 @@ const analysisPrompts = {
317
326
  max_tokens: options.maxTokens || 1000,
318
327
  },
319
328
  }),
329
+ anthropic: (text, options) => ({
330
+ system: "You are a context analysis assistant that helps identify the category and context of text.",
331
+ messages: [
332
+ {
333
+ role: "user",
334
+ content: baseAnalysisPromptTemplate(text, options),
335
+ },
336
+ ],
337
+ }),
320
338
  deepseek: (text, options) => ({
321
339
  messages: [
322
340
  {
@@ -59,6 +59,12 @@ class RateLimiter {
59
59
  lastReset: performance.now(),
60
60
  maxConcurrent: 8,
61
61
  },
62
+ anthropic: {
63
+ requestsPerMinute: 50,
64
+ currentRequests: 0,
65
+ lastReset: performance.now(),
66
+ maxConcurrent: 5,
67
+ },
62
68
  };
63
69
  this._processingLocks = {};
64
70
  Object.keys(this.providers).forEach((provider) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locai",
3
- "version": "2.1.3",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/core/orchestrator.js",
6
6
  "types": "dist/core/orchestrator.d.ts",
@@ -69,17 +69,20 @@
69
69
  "commander": "^14.0.2",
70
70
  "dot-properties": "^1.1.0",
71
71
  "dotenv": "^17.2.3",
72
+ "fast-xml-parser": "^5.3.3",
72
73
  "gettext-parser": "^8.0.0",
73
74
  "jiti": "^2.6.1",
74
75
  "js-yaml": "^4.1.1",
75
76
  "lru-cache": "^11.2.2",
76
77
  "p-limit": "^7.2.0",
78
+ "prompts": "^2.4.2",
77
79
  "vectra": "^0.11.1"
78
80
  },
79
81
  "devDependencies": {
80
82
  "@eslint/js": "^9.39.1",
81
83
  "@types/js-yaml": "^4.0.9",
82
84
  "@types/node": "^25.0.1",
85
+ "@types/prompts": "^2.4.9",
83
86
  "@vitest/coverage-v8": "^4.0.15",
84
87
  "eslint": "^9.39.1",
85
88
  "eslint-config-prettier": "^10.1.8",