🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

xtrans

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

xtrans - npm Package Compare versions

Comparing version
1.0.1
to
1.1.0
+2
dist/interfaces/index.d.ts
export * from './prompt-strategy.js';
export * from './render-strategy.js';
export * from './prompt-strategy.js';
export * from './render-strategy.js';
export interface PromptStrategy<T = any> {
getInitialState(): T;
getSystemMessage(targetLang: string): string;
id: string;
parse(text: string): T;
}
export interface RenderStrategy<T = any> {
id: string;
render(data: T, fromCache: boolean): void;
reset(): void;
}
import { PromptStrategy } from '../interfaces/prompt-strategy.js';
import { StandardBoxData } from '../providers/types.js';
export declare class GrammarPromptStrategy implements PromptStrategy<StandardBoxData> {
id: string;
getInitialState(): StandardBoxData;
getSystemMessage(_targetLang: string): string;
parse(text: string): StandardBoxData;
}
export class GrammarPromptStrategy {
id = 'grammar';
getInitialState() {
return {
content: [],
tags: ['Grammar'],
title: 'Grammar Check'
};
}
getSystemMessage(_targetLang) {
return `
You are a professional grammar checker and writing assistant.
Your task is to analyze the user's input, which is likely in English or another language.
INSTRUCTIONS:
1. Identify grammar errors, spelling mistakes, and awkward phrasing.
2. Provide the corrected version.
3. Explain the mistakes concisely.
4. Output specific tags for parsing.
Required Tags:
[CORRECT] The corrected sentence(s)
[MISTAKE] Specific error explanation (one per line)
[IMPROVE] Optional suggestion for better style/tone (one per line)
Example:
[CORRECT] He went to school yesterday.
[MISTAKE] "He go" -> "He went" (Past tense required)
[IMPROVE] Consider adding context like "high school".
`;
}
parse(text) {
const lines = text.split('\n');
// We recreate state each parse for simplicity,
// real streaming might want to accumulate content.
// For now, this is stateless parsing of the full accumulated buffer.
let corrected = '';
const mistakes = [];
const improvements = [];
for (const line of lines) {
const clean = line.trim();
if (!clean)
continue;
if (clean.startsWith('[CORRECT]')) {
corrected = clean.replace('[CORRECT]', '').trim();
}
else if (clean.startsWith('[MISTAKE]')) {
mistakes.push(clean.replace('[MISTAKE]', '').trim());
}
else if (clean.startsWith('[IMPROVE]')) {
improvements.push(clean.replace('[IMPROVE]', '').trim());
}
}
const sections = [];
if (corrected) {
sections.push({
content: corrected,
style: 'code',
title: 'Correction'
});
}
if (mistakes.length > 0) {
sections.push({
content: mistakes.map(m => `• ${m}`),
title: 'Analysis'
});
}
if (improvements.length > 0) {
sections.push({
content: improvements.map(i => `• ${i}`),
style: 'dim',
title: 'Suggestions'
});
}
return {
content: sections,
tags: ['Grammar'],
title: 'Grammar Check'
};
}
}
import { PromptStrategy } from '../interfaces/prompt-strategy.js';
export declare const PromptFactory: {
get(id: string): PromptStrategy<any>;
register(strategy: PromptStrategy<any>): void;
};
import { GrammarPromptStrategy } from './grammar.js';
import { OxfordPromptStrategy } from './oxford.js';
import { TranslationPromptStrategy } from './translation.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const strategies = {
'grammar': new GrammarPromptStrategy(),
'oxford': new OxfordPromptStrategy(),
'translation': new TranslationPromptStrategy(),
};
export const PromptFactory = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(id) {
const strategy = strategies[id];
if (!strategy) {
throw new Error(`Prompt strategy '${id}' not found`);
}
return strategy;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
register(strategy) {
strategies[strategy.id] = strategy;
},
};
import { PromptStrategy } from '../interfaces/prompt-strategy.js';
import { StandardBoxData } from '../providers/types.js';
export declare class OxfordPromptStrategy implements PromptStrategy<StandardBoxData> {
id: string;
getInitialState(): StandardBoxData;
getSystemMessage(_targetLang: string): string;
parse(text: string): StandardBoxData;
}
export class OxfordPromptStrategy {
id = 'oxford';
getInitialState() {
return {
content: [],
tags: ['Oxford'],
title: 'Oxford Dictionary',
};
}
getSystemMessage(_targetLang) {
return `
You are the Oxford English Dictionary. Provide a comprehensive, academic definition of the word.
Focus on: British English pronunciation (IPA), precise definitions, etymology, and usage labels (e.g., formal, archaic).
Output Format (Stream of tags):
[WORD] The word
[PHONETIC] IPA (British)
[POS] Part of Speech (noun, verb, etc.)
[DEF] Definition (numbered)
[EXAMPLE] Usage example (italics preferred in rendering, but plain text here)
[ETYMOLOGY] Origin/History of the word
[LEVEL] CEFR Level (A1-C2) or Frequency label if applicable
Structure:
- Start with [WORD] and [PHONETIC].
- Then [POS].
- Under [POS], list [DEF] and [EXAMPLE] pairs.
- End with [ETYMOLOGY] and [LEVEL].
`;
}
parse(text) {
const lines = text.split('\n');
const result = this.getInitialState();
let currentPosSection = null;
let etymology = '';
let level = '';
// Temporary storage to build sections
const posSections = [];
for (const line of lines) {
const clean = line.trim();
if (!clean)
continue;
if (clean.startsWith('[WORD]')) {
result.title = clean.replace('[WORD]', '').trim();
}
else if (clean.startsWith('[PHONETIC]')) {
result.subtitle = clean.replace('[PHONETIC]', '').trim();
}
else if (clean.startsWith('[LEVEL]')) {
level = clean.replace('[LEVEL]', '').trim();
if (level)
result.tags?.push(level);
}
else if (clean.startsWith('[POS]')) {
const pos = clean.replace('[POS]', '').trim();
currentPosSection = { content: [], title: pos };
posSections.push(currentPosSection);
}
else if (clean.startsWith('[DEF]')) {
const def = clean.replace('[DEF]', '').trim();
if (currentPosSection) {
currentPosSection.content.push(`• ${def}`);
}
}
else if (clean.startsWith('[EXAMPLE]')) {
const ex = clean.replace('[EXAMPLE]', '').trim();
if (currentPosSection) {
// Add indentation for examples
currentPosSection.content.push(` "${ex}"`);
}
}
else if (clean.startsWith('[ETYMOLOGY]')) {
etymology = clean.replace('[ETYMOLOGY]', '').trim();
}
}
// Assemble content
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result.content = posSections;
if (etymology) {
result.content.push({
content: [etymology],
style: 'dim',
title: 'Etymology'
});
}
return result;
}
}
import { PromptStrategy } from '../interfaces/prompt-strategy.js';
import { TranslationResult } from '../providers/types.js';
export declare class TranslationPromptStrategy implements PromptStrategy<TranslationResult> {
id: string;
getInitialState(): TranslationResult;
getSystemMessage(targetLang: string): string;
parse(text: string): TranslationResult;
}
export class TranslationPromptStrategy {
id = 'translation';
getInitialState() {
return {
antonyms: [],
definitions: [],
examples: [],
idioms: [],
meaning: [],
phonetic: '',
synonyms: [],
tags: [],
word: ''
};
}
getSystemMessage(targetLang) {
return `
You are a professional linguist and translation tool. Analyze the user input.
Translate the input into "${targetLang}".
CRITICAL INSTRUCTION:
- Response MUST be a stream of data lines.
- DO NOT use JSON.
- Format each field with a tag like [TAG] Content.
- Provide BILINGUAL content where appropriate.
Required Tags:
[WORD] The original source word ONLY
[PHONETIC] IPA phonetic transcription
[TAG] Domain tags (e.g. Computing, Medical, Slang) - One per line
[POS] Part of Speech (noun, verb, adj, etc.) - Starts a new definition block
[MEANING] Definition in target language (with source term in brackets if helpful)
[EXAMPLE] Example sentence - Must follow the relevant [POS] and [MEANING]
[SYNONYM] Synonyms - One per line
[ANTONYM] Antonyms - One per line
[IDIOM] Common idioms/phrases containing the word - One per line
[ORIGIN] Etymology/Origin of the word (brief)
Structure:
- Always start with [WORD] and [PHONETIC].
- Then [TAG]s.
- Then iterate through meanings, grouping them by [POS].
- Inside each [POS], provide the [MEANING] followed immediately by relevant [EXAMPLE]s.
- End with [SYNONYM], [ANTONYM], [IDIOM], [ORIGIN].
Example Output:
[WORD] hello
[PHONETIC] /hɛˈloʊ/
[TAG] Greeting
[TAG] Informal
[POS] interjection
[MEANING] 你好 (Used as a greeting or to begin a telephone conversation)
[EXAMPLE] Hello, Paul. (你好,保罗。)
[POS] noun
[MEANING] 招呼 (An utterance of 'hello'; a greeting)
[EXAMPLE] She said hello to me. (她向我打了个招呼。)
[SYNONYM] hi
[ANTONYM] goodbye
[IDIOM] say hello to (向...问好)
[ORIGIN] Mid 19th century: alteration of hollo.
`;
}
parse(text) {
const result = this.getInitialState();
const lines = text.split('\n');
let currentDefinition = null;
for (const line of lines) {
const cleanLine = line.trim();
if (!cleanLine)
continue;
if (cleanLine.startsWith('[WORD]')) {
result.word = cleanLine.replace('[WORD]', '').trim();
}
else if (cleanLine.startsWith('[PHONETIC]')) {
result.phonetic = cleanLine.replace('[PHONETIC]', '').trim();
}
else if (cleanLine.startsWith('[POS]')) {
const pos = cleanLine.replace('[POS]', '').trim();
currentDefinition = { examples: [], meaning: '', partOfSpeech: pos };
result.definitions.push(currentDefinition);
}
else if (cleanLine.startsWith('[MEANING]')) {
const meaning = cleanLine.replace('[MEANING]', '').trim();
if (currentDefinition) {
currentDefinition.meaning = meaning;
}
// Always add to flat list for compatibility
result.meaning.push(meaning);
}
else if (cleanLine.startsWith('[EXAMPLE]')) {
const example = cleanLine.replace('[EXAMPLE]', '').trim();
if (currentDefinition) {
currentDefinition.examples.push(example);
}
// Always add to flat list for compatibility
result.examples.push(example);
}
else if (cleanLine.startsWith('[SYNONYM]')) {
result.synonyms.push(cleanLine.replace('[SYNONYM]', '').trim());
}
else if (cleanLine.startsWith('[ANTONYM]')) {
result.antonyms.push(cleanLine.replace('[ANTONYM]', '').trim());
}
else if (cleanLine.startsWith('[IDIOM]')) {
result.idioms.push(cleanLine.replace('[IDIOM]', '').trim());
}
else if (cleanLine.startsWith('[TAG]')) {
result.tags.push(cleanLine.replace('[TAG]', '').trim());
}
else if (cleanLine.startsWith('[ORIGIN]')) {
result.origin = cleanLine.replace('[ORIGIN]', '').trim();
}
}
return result;
}
}
import { RenderStrategy } from '../interfaces/render-strategy.js';
import { StandardBoxData, TranslationResult } from '../providers/types.js';
export declare class BoxRenderStrategy implements RenderStrategy<StandardBoxData | TranslationResult> {
id: string;
private lastHeight;
render(result: StandardBoxData | TranslationResult, fromCache: boolean): void;
reset(): void;
private isStandardBoxData;
private normalize;
private normalizeTranslationResult;
}
import boxen from 'boxen';
import chalk from 'chalk';
export class BoxRenderStrategy {
id = 'box';
lastHeight = 0;
render(result, fromCache) {
const data = this.normalize(result);
const headerParts = [chalk.bold(data.title || '...')];
if (data.subtitle)
headerParts.push(chalk.dim(data.subtitle));
if (data.tags && data.tags.length > 0) {
headerParts.push(data.tags.map(t => chalk.bgBlue.white(` ${t} `)).join(' '));
}
if (fromCache)
headerParts.push(chalk.dim('(cached)'));
const header = headerParts.join(' ');
const content = [header, ''];
if (data.content && data.content.length > 0) {
for (const section of data.content) {
if (section.title) {
content.push(chalk.cyan.bold(section.title));
}
const lines = Array.isArray(section.content) ? section.content : [section.content];
for (const line of lines) {
if (section.style === 'dim') {
content.push(chalk.dim(line));
}
else if (section.style === 'code') {
content.push(chalk.gray(line));
}
else {
content.push(line);
}
}
content.push('');
}
}
if (data.footer && data.footer.length > 0) {
if (content.at(-1) !== '')
content.push('');
content.push(...data.footer);
}
const box = boxen(content.join('\n').trim(), { borderColor: fromCache ? 'gray' : 'cyan', borderStyle: 'round', padding: 1 });
if (this.lastHeight > 0) {
process.stdout.write(`\u001B[${this.lastHeight}A`);
process.stdout.write('\u001B[0J');
}
console.log(box);
this.lastHeight = box.split('\n').length;
if (fromCache)
this.lastHeight = 0;
}
reset() {
this.lastHeight = 0;
}
isStandardBoxData(input) {
return 'content' in input && Array.isArray(input.content);
}
normalize(input) {
if (this.isStandardBoxData(input)) {
return input;
}
return this.normalizeTranslationResult(input);
}
normalizeTranslationResult(tr) {
const sections = [];
if (tr.definitions && tr.definitions.length > 0) {
for (const def of tr.definitions) {
const lines = [chalk.green(' • ') + def.meaning];
if (def.examples && def.examples.length > 0) {
for (const ex of def.examples) {
lines.push(chalk.dim(' - ') + ex);
}
}
sections.push({
content: lines,
title: def.partOfSpeech,
});
}
}
else if (tr.meaning && tr.meaning.length > 0) {
const lines = tr.meaning.map(m => chalk.green('• ') + m);
if (tr.examples && tr.examples.length > 0) {
lines.push('', chalk.yellow('Examples:'));
for (const ex of tr.examples)
lines.push(chalk.dim('• ') + ex);
}
sections.push({ content: lines });
}
if (tr.idioms && tr.idioms.length > 0) {
const lines = tr.idioms.map(i => chalk.dim('• ') + i);
sections.push({
content: [chalk.magenta.bold('Idioms:'), ...lines],
});
}
const footer = [];
if (tr.synonyms && tr.synonyms.length > 0)
footer.push(chalk.dim('Synonyms: ') + tr.synonyms.join(', '));
if (tr.antonyms && tr.antonyms.length > 0)
footer.push(chalk.dim('Antonyms: ') + tr.antonyms.join(', '));
if (tr.origin)
footer.push(chalk.dim('Origin: ') + tr.origin);
return {
content: sections,
footer,
subtitle: tr.phonetic,
tags: tr.tags,
title: tr.word,
};
}
}
import { RenderStrategy } from '../interfaces/render-strategy.js';
export declare const RendererFactory: {
get(id: string): RenderStrategy<any>;
};
import { BoxRenderStrategy } from './box.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
const strategies = {
'box': new BoxRenderStrategy(),
};
export const RendererFactory = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(id) {
// Create new instance to maintain independent state (like lastHeight)
// Or we could reset state. Creating new is safer.
if (id === 'box')
return new BoxRenderStrategy();
throw new Error(`Renderer strategy '${id}' not found`);
},
};
import { Ora } from 'ora';
import { ConfigService } from '../services/config.js';
export declare function handleProviderError(error: unknown, configService: ConfigService, spinner?: Ora): Promise<boolean>;
import { confirm, select } from '@inquirer/prompts';
import chalk from 'chalk';
import { ProviderFactory } from '../providers/factory.js';
export async function handleProviderError(error, configService, spinner) {
const errorMessage = error instanceof Error ? error.message : String(error);
const { config } = configService;
const currentProviderConfig = configService.getCurrentService();
const isModelNotFoundError = errorMessage.includes('not found') || errorMessage.includes('404');
if (isModelNotFoundError && (config.current === 'ollama' || config.current === 'lmstudio')) {
console.log(chalk.red(`\nError: Model "${currentProviderConfig?.model}" is not available.`));
try {
if (!currentProviderConfig)
return false;
const provider = ProviderFactory.create(config.current, currentProviderConfig);
if (spinner) {
spinner.start('Fetching available models...');
}
else {
process.stdout.write(chalk.dim('Fetching available models...'));
}
const models = await provider.listModels();
if (spinner) {
spinner.stop();
}
else {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
}
if (models.length > 0) {
const shouldSwitch = await confirm({
default: true,
message: 'Would you like to switch to an available model?'
});
if (shouldSwitch) {
const selectedModel = await select({
choices: models.map(m => ({ name: m, value: m })),
message: 'Select Model'
});
// eslint-disable-next-line max-depth
if (currentProviderConfig) {
currentProviderConfig.model = selectedModel;
configService.setService(config.current, currentProviderConfig);
console.log(chalk.green(`Switched to ${selectedModel}. Retrying...`));
return true;
}
}
}
else if (config.current === 'ollama') {
console.log(chalk.yellow(`\nTip: You can install this model by running:`));
console.log(chalk.cyan(` ollama pull ${currentProviderConfig?.model}`));
console.log(chalk.dim(' (Run this in a separate terminal)\n'));
}
}
catch {
if (spinner) {
if (spinner.isSpinning)
spinner.stop();
}
else {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
}
}
}
return false;
}
+3
-3

@@ -6,4 +6,4 @@ #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning

const args = process.argv.slice(2)
if (args.indexOf('-h') !== -1) args[args.indexOf('-h')] = '--help'
if (args.indexOf('-v') !== -1) args[args.indexOf('-v')] = '--version'
if (args.includes('-h')) args[args.indexOf('-h')] = '--help'
if (args.includes('-v')) args[args.indexOf('-v')] = '--version'

@@ -17,2 +17,2 @@ const reserved = ['translate', 'help', '--version', '-v', '--help', '-h']

await execute({development: true, dir: import.meta.url, args})
await execute({args, development: true, dir: import.meta.url})

@@ -6,4 +6,4 @@ #!/usr/bin/env node

const args = process.argv.slice(2)
if (args.indexOf('-h') !== -1) args[args.indexOf('-h')] = '--help'
if (args.indexOf('-v') !== -1) args[args.indexOf('-v')] = '--version'
if (args.includes('-h')) args[args.indexOf('-h')] = '--help'
if (args.includes('-v')) args[args.indexOf('-v')] = '--version'

@@ -17,2 +17,2 @@ const reserved = ['translate', 'help', '--version', '-v', '--help', '-h']

await execute({dir: import.meta.url, args})
await execute({args, dir: import.meta.url})
import { Command } from '@oclif/core';
export default class Main extends Command {
static description: string;
static hidden: boolean;
static args: {
input: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
};
static description: string;
static hidden: boolean;
static strict: boolean;
run(): Promise<void>;
}

@@ -1,16 +0,18 @@

import { Command, Args } from '@oclif/core';
import { confirm } from '@inquirer/prompts';
import { Args, Command } from '@oclif/core';
import chalk from 'chalk';
import ora from 'ora';
import { PromptFactory } from '../prompts/index.js';
import { ProviderFactory } from '../providers/factory.js';
import { RendererFactory } from '../renderers/index.js';
import { ConfigService } from '../services/config.js';
import { HistoryService } from '../services/history.js';
import { ProviderFactory } from '../providers/factory.js';
import { renderResult, renderStream, resetRenderer } from '../ui/renderer.js';
import chalk from 'chalk';
import ora from 'ora';
import { startRepl, handleUseCommand, handleCommand } from '../ui/repl.js';
import { confirm } from '@inquirer/prompts';
import { handleCommand, handleUseCommand, startRepl } from '../ui/repl.js';
import { handleProviderError } from '../utils/error-handler.js';
export default class Main extends Command {
static description = 'Translate text or enter interactive mode';
static hidden = true;
static args = {
input: Args.string({ description: 'Text to translate', required: false }),
};
static description = 'Translate text or enter interactive mode';
static hidden = true;
static strict = false;

@@ -31,5 +33,6 @@ async run() {

const spinner = ora('Translating...').start();
let currentProviderConfig;
try {
const config = configService.config;
const currentProviderConfig = configService.getCurrentService();
const { config } = configService;
currentProviderConfig = configService.getCurrentService();
if (!currentProviderConfig) {

@@ -40,20 +43,41 @@ spinner.fail('No provider configured.');

const historyService = new HistoryService();
const cached = historyService.get(inputText);
const targetLang = configService.getTargetLang();
const cached = historyService.get(inputText, targetLang);
const mode = configService.getMode();
// Strategies (Hardcoded for now, but ready for config)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promptStrategy = PromptFactory.get(mode);
const renderStrategy = RendererFactory.get('box');
if (cached && cached.provider === config.current && cached.targetLang === targetLang) {
spinner.stop();
await renderResult(cached.result, true);
renderStrategy.render(cached.result, true);
return;
}
const provider = ProviderFactory.create(config.current, currentProviderConfig);
resetRenderer();
const stream = provider.stream(inputText, targetLang);
const result = await renderStream(stream);
// Force override word with input text
result.word = inputText;
resetRenderer();
spinner.stop(); // This might interfere, let's remove spinner for stream mode?
// Actually, remove spinner completely since stream provides visual feedback.
const systemMessage = promptStrategy.getSystemMessage(targetLang);
renderStrategy.reset();
const stream = provider.stream(inputText, { systemMessage });
let buffer = '';
let result = promptStrategy.getInitialState();
let lastRender = 0;
// Stream Loop
for await (const chunk of stream) {
buffer += chunk;
result = promptStrategy.parse(buffer);
const now = Date.now();
if (now - lastRender > 50) {
renderStrategy.render(result, false);
lastRender = now;
}
}
// Final Render
// Force override word with input text (Specific to Translation Strategy)
if (result && typeof result === 'object' && 'word' in result) {
result.word = inputText;
}
renderStrategy.render(result, false);
renderStrategy.reset(); // Cleanup
spinner.stop();
// 2. Save History
historyService.save(inputText, result, config.current);
historyService.save(inputText, result, config.current, targetLang);
}

@@ -63,3 +87,3 @@ catch (error) {

const errorMessage = error instanceof Error ? error.message : String(error);
const config = configService.config;
const { config } = configService;
// Detect Ollama connection failure

@@ -71,4 +95,4 @@ if (config.current === 'ollama' && errorMessage.includes('fetch failed')) {

const shouldSwitch = await confirm({
default: true,
message: 'Would you like to switch to another provider (e.g. OpenAI)?',
default: true,
});

@@ -83,6 +107,10 @@ if (shouldSwitch) {

}
catch (e) {
catch {
// User cancelled or error in confirm, just exit
}
}
if (await handleProviderError(error, configService, spinner)) {
await this.run();
return;
}
this.error(errorMessage);

@@ -89,0 +117,0 @@ }

@@ -0,5 +1,5 @@

import { ProviderConfig } from '../services/config.js';
import { LLMProvider } from './types.js';
import { ProviderConfig } from '../services/config.js';
export declare class ProviderFactory {
static create(name: string, config: ProviderConfig): LLMProvider;
}
export declare const ProviderFactory: {
create(name: string, config: ProviderConfig): LLMProvider;
};

@@ -0,17 +1,21 @@

import { OllamaProvider } from './ollama.js';
import { OpenAIProvider } from './openai.js';
import { OllamaProvider } from './ollama.js';
export class ProviderFactory {
static create(name, config) {
export const ProviderFactory = {
create(name, config) {
switch (config.type) {
case 'openai':
case 'deepseek':
case 'lmstudio':
case 'deepseek':
case 'moonshot':
case 'openai': {
return new OpenAIProvider(name, config.type, config);
case 'ollama':
}
case 'ollama': {
return new OllamaProvider(name, config.type, config);
default:
}
default: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error(`Unsupported provider type: ${config.type}`);
}
}
}
}
},
};

@@ -1,3 +0,3 @@

import { LLMProvider, TranslationResult } from './types.js';
import { ProviderConfig } from '../services/config.js';
import { LLMProvider, LLMRequestOptions } from './types.js';
export declare class OllamaProvider implements LLMProvider {

@@ -9,7 +9,7 @@ name: string;

private get baseUrl();
stream(text: string, targetLang: string): AsyncGenerator<string>;
private parseOllamaChunk;
generate(text: string, targetLang: string): Promise<TranslationResult>;
checkHealth(): Promise<boolean>;
generate(text: string, options: LLMRequestOptions): Promise<string>;
listModels(): Promise<string[]>;
stream(text: string, options: LLMRequestOptions): AsyncGenerator<string>;
private parseOllamaChunk;
}

@@ -1,3 +0,1 @@

import { getSystemPrompt } from '../services/prompt.js';
import { parseRawOutput } from '../utils/parser.js';
export class OllamaProvider {

@@ -15,19 +13,58 @@ name;

}
async *stream(text, targetLang) {
const prompt = getSystemPrompt(targetLang);
async checkHealth() {
try {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
}
catch {
return false;
}
}
async generate(text, options) {
let fullText = '';
for await (const chunk of this.stream(text, options)) {
fullText += chunk;
}
return fullText;
}
async listModels() {
try {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(`${this.baseUrl}/api/tags`);
if (!response.ok)
return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = (await response.json());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return data.models?.map((m) => m.name) || [];
}
catch {
return [];
}
}
async *stream(text, options) {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: text },
{ content: options.systemMessage, role: 'system' },
{ content: text, role: 'user' },
],
model: this.config.model,
stream: true, // Enable streaming
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Model "${this.config.model}" not found. Please install it with: ollama pull ${this.config.model}`);
}
throw new Error(`Ollama Error: ${response.status} ${response.statusText}`);
}
if (!response.body)
throw new Error('No response body');
// Node.js fetch stream handling
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reader = response.body.getReader ? response.body.getReader() : null;

@@ -38,2 +75,3 @@ if (reader) {

while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();

@@ -48,2 +86,3 @@ if (done)

// Node.js ReadableStream
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for await (const chunk of response.body) {

@@ -65,3 +104,3 @@ yield* this.parseOllamaChunk(chunk.toString());

}
catch (e) {
catch {
// Ignore partial JSON

@@ -71,30 +110,2 @@ }

}
async generate(text, targetLang) {
let fullText = '';
for await (const chunk of this.stream(text, targetLang)) {
fullText += chunk;
}
return parseRawOutput(fullText);
}
async checkHealth() {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
}
catch {
return false;
}
}
async listModels() {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
if (!response.ok)
return [];
const data = (await response.json());
return data.models?.map((m) => m.name) || [];
}
catch {
return [];
}
}
}

@@ -1,3 +0,3 @@

import { LLMProvider, TranslationResult } from './types.js';
import { ProviderConfig } from '../services/config.js';
import { LLMProvider, LLMRequestOptions } from './types.js';
export declare class OpenAIProvider implements LLMProvider {

@@ -9,6 +9,6 @@ name: string;

constructor(name: string, code: string, config: ProviderConfig);
stream(text: string, targetLang: string): AsyncGenerator<string>;
generate(text: string, targetLang: string): Promise<TranslationResult>;
checkHealth(): Promise<boolean>;
generate(text: string, options: LLMRequestOptions): Promise<string>;
listModels(): Promise<string[]>;
stream(text: string, options: LLMRequestOptions): AsyncGenerator<string>;
}
import { OpenAI } from 'openai';
import { getSystemPrompt } from '../services/prompt.js';
import { parseRawOutput } from '../utils/parser.js';
export class OpenAIProvider {

@@ -18,27 +16,2 @@ name;

}
async *stream(text, targetLang) {
const prompt = getSystemPrompt(targetLang);
const stream = await this.client.chat.completions.create({
model: this.config.model,
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: text },
],
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content)
yield content;
}
}
async generate(text, targetLang) {
// Fallback to non-streaming logic or consume stream?
// Let's reuse stream to parse result
let fullText = '';
for await (const chunk of this.stream(text, targetLang)) {
fullText += chunk;
}
return parseRawOutput(fullText);
}
async checkHealth() {

@@ -53,2 +26,9 @@ try {

}
async generate(text, options) {
let fullText = '';
for await (const chunk of this.stream(text, options)) {
fullText += chunk;
}
return fullText;
}
async listModels() {

@@ -63,2 +43,17 @@ try {

}
async *stream(text, options) {
const stream = await this.client.chat.completions.create({
messages: [
{ content: options.systemMessage, role: 'system' },
{ content: text, role: 'user' },
],
model: this.config.model,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content)
yield content;
}
}
}
export interface Definition {
examples: string[];
meaning: string;
partOfSpeech: string;
meaning: string;
examples: string[];
}
export interface TranslationResult {
word: string;
phonetic?: string;
meaning: string[];
examples: string[];
synonyms: string[];
antonyms: string[];
definitions: Definition[];
examples: string[];
idioms: string[];
meaning: string[];
origin?: string;
phonetic?: string;
synonyms: string[];
tags: string[];
origin?: string;
word: string;
}
export interface BoxSection {
content: string | string[];
style?: 'code' | 'dim' | 'normal';
title?: string;
}
export interface StandardBoxData {
content: BoxSection[];
footer?: string[];
subtitle?: string;
tags?: string[];
title: string;
}
export interface LLMRequestOptions {
systemMessage: string;
}
export interface LLMProvider {
name: string;
checkHealth(): Promise<boolean>;
code: string;
stream(text: string, targetLang: string): AsyncGenerator<string>;
generate(text: string, targetLang: string): Promise<TranslationResult>;
checkHealth(): Promise<boolean>;
generate(text: string, options: LLMRequestOptions): Promise<string>;
listModels(): Promise<string[]>;
name: string;
stream(text: string, options: LLMRequestOptions): AsyncGenerator<string>;
}
export declare class AudioService {
clearCache(): void;
/**

@@ -8,3 +9,2 @@ * Play the pronunciation for the given text.

private getAudioFile;
clearCache(): void;
}

@@ -1,5 +0,5 @@

import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import player from 'play-sound';

@@ -14,2 +14,8 @@ // Initialize player

export class AudioService {
clearCache() {
if (fs.existsSync(CACHE_DIR)) {
fs.rmSync(CACHE_DIR, { force: true, recursive: true });
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
/**

@@ -23,2 +29,3 @@ * Play the pronunciation for the given text.

return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
audioPlayer.play(filePath, (err) => {

@@ -47,2 +54,3 @@ if (err)

const url = `http://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&q=${encodeURIComponent(text)}&tl=${lang}`;
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(url);

@@ -57,8 +65,2 @@ if (!response.ok) {

}
clearCache() {
if (fs.existsSync(CACHE_DIR)) {
fs.rmSync(CACHE_DIR, { recursive: true, force: true });
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
}

@@ -11,2 +11,5 @@ import { z } from 'zod';

export declare const ProviderConfigSchema: z.ZodObject<{
apiKey: z.ZodOptional<z.ZodString>;
baseUrl: z.ZodOptional<z.ZodString>;
model: z.ZodString;
type: z.ZodEnum<{

@@ -19,5 +22,2 @@ openai: "openai";

}>;
model: z.ZodString;
baseUrl: z.ZodOptional<z.ZodString>;
apiKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;

@@ -27,4 +27,7 @@ export type ProviderConfig = z.infer<typeof ProviderConfigSchema>;

current: z.ZodString;
targetLang: z.ZodDefault<z.ZodString>;
mode: z.ZodDefault<z.ZodString>;
services: z.ZodRecord<z.ZodString, z.ZodObject<{
apiKey: z.ZodOptional<z.ZodString>;
baseUrl: z.ZodOptional<z.ZodString>;
model: z.ZodString;
type: z.ZodEnum<{

@@ -37,6 +40,4 @@ openai: "openai";

}>;
model: z.ZodString;
baseUrl: z.ZodOptional<z.ZodString>;
apiKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
targetLang: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;

@@ -48,10 +49,14 @@ export type AppConfig = z.infer<typeof ConfigSchema>;

get config(): AppConfig;
get path(): string;
getCurrentService(): ProviderConfig | undefined;
getMode(): string;
getService(name: string): ProviderConfig | undefined;
getCurrentService(): ProviderConfig | undefined;
getTargetLang(): string;
listServices(): Record<string, ProviderConfig>;
removeService(name: string): void;
reset(): void;
setCurrent(name: string): void;
setMode(mode: string): void;
setService(name: string, config: ProviderConfig): void;
setCurrent(name: string): void;
removeService(name: string): void;
setTargetLang(lang: string): void;
getTargetLang(): string;
listServices(): Record<string, ProviderConfig>;
}
import Conf from 'conf';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import path from 'path';
import os from 'os';
import fs from 'fs';
const CONFIG_DIR = path.join(os.homedir(), '.config', 'xtrans');

@@ -13,45 +13,47 @@ // Ensure config directory exists

export const ProviderConfigSchema = z.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
model: z.string(),
type: ProviderTypeSchema,
model: z.string(),
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
});
export const ConfigSchema = z.object({
current: z.string(),
mode: z.string().default('translation'),
services: z.record(z.string(), ProviderConfigSchema),
targetLang: z.string().default('zh'),
services: z.record(z.string(), ProviderConfigSchema),
});
const DEFAULTS = {
current: 'ollama',
targetLang: 'zh',
mode: 'translation',
services: {
'ollama': {
type: 'ollama',
model: 'llama3',
baseUrl: 'http://localhost:11434',
'deepseek': {
apiKey: '',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
type: 'deepseek',
},
'kimi': {
apiKey: '',
baseUrl: 'https://api.moonshot.cn/v1',
model: 'moonshot-v1-8k',
type: 'moonshot',
},
'lmstudio': {
apiKey: 'lm-studio',
baseUrl: 'http://localhost:1234/v1',
model: 'model-identifier',
type: 'lmstudio',
model: 'model-identifier',
baseUrl: 'http://localhost:1234/v1',
apiKey: 'lm-studio',
},
'ollama': {
baseUrl: 'http://localhost:11434',
model: 'qwen2.5:7b',
type: 'ollama',
},
'openai': {
apiKey: '',
model: 'gpt-4o',
type: 'openai',
model: 'gpt-4o',
apiKey: '',
},
'deepseek': {
type: 'deepseek',
model: 'deepseek-chat',
baseUrl: 'https://api.deepseek.com',
apiKey: '',
},
'kimi': {
type: 'moonshot',
model: 'moonshot-v1-8k',
baseUrl: 'https://api.moonshot.cn/v1',
apiKey: '',
},
},
targetLang: 'zh',
};

@@ -62,5 +64,5 @@ export class ConfigService {

this.store = new Conf({
projectName: 'xtrans',
cwd: CONFIG_DIR,
defaults: DEFAULTS,
projectName: 'xtrans',
});

@@ -71,4 +73,4 @@ }

}
getService(name) {
return this.store.get(`services.${name}`);
get path() {
return this.store.path;
}

@@ -79,11 +81,14 @@ getCurrentService() {

}
setService(name, config) {
this.store.set(`services.${name}`, config);
getMode() {
return this.store.get('mode') || 'translation';
}
setCurrent(name) {
if (!this.store.has(`services.${name}`)) {
throw new Error(`Service ${name} not found`);
}
this.store.set('current', name);
getService(name) {
return this.store.get(`services.${name}`);
}
getTargetLang() {
return this.store.get('targetLang') || 'zh';
}
listServices() {
return this.store.get('services');
}
removeService(name) {

@@ -96,13 +101,25 @@ if (this.config.current === name) {

}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.store.delete(`services.${name}`);
}
reset() {
if (fs.existsSync(CONFIG_DIR)) {
fs.rmSync(CONFIG_DIR, { force: true, recursive: true });
}
}
setCurrent(name) {
if (!this.store.has(`services.${name}`)) {
throw new Error(`Service ${name} not found`);
}
this.store.set('current', name);
}
setMode(mode) {
this.store.set('mode', mode);
}
setService(name, config) {
this.store.set(`services.${name}`, config);
}
setTargetLang(lang) {
this.store.set('targetLang', lang);
}
getTargetLang() {
return this.store.get('targetLang') || 'zh';
}
listServices() {
return this.store.get('services');
}
}
import { TranslationResult } from '../providers/types.js';
export interface HistoryEntry {
provider: string;
result: TranslationResult;
source: string;
targetLang: string;
result: TranslationResult;
timestamp: number;
provider: string;
}
export declare class HistoryService {
private replStore;
private store;
private replStore;
constructor();
get(text: string): HistoryEntry | undefined;
getLatest(): HistoryEntry | undefined;
save(text: string, result: TranslationResult, provider: string): void;
add(text: string): void;
clear(): void;
clearReplHistory(): void;
clearTranslationHistory(): void;
clearReplHistory(): void;
clear(): void;
get(text: string, targetLang: string): HistoryEntry | undefined;
getHistory(): string[];
add(text: string): void;
getLatest(): HistoryEntry | undefined;
save(text: string, result: TranslationResult, provider: string, targetLang: string): void;
}
import Conf from 'conf';
import path from 'path';
import os from 'os';
import os from 'node:os';
import path from 'node:path';
const CONFIG_DIR = path.join(os.homedir(), '.config', 'xtrans');
export class HistoryService {
replStore;
store;
replStore;
constructor() {
this.store = new Conf({
projectName: 'xtrans',
configName: 'history', // Translation cache
cwd: CONFIG_DIR,
configName: 'history', // Translation cache
fileExtension: 'json',
defaults: {
entries: [],
},
fileExtension: 'json',
projectName: 'xtrans',
});
this.replStore = new Conf({
projectName: 'xtrans',
configName: 'repl_history', // Shell command history
cwd: CONFIG_DIR,
configName: 'repl_history', // Shell command history
fileExtension: 'json',
defaults: {
history: [],
},
fileExtension: 'json',
projectName: 'xtrans',
});
}
// --- Translation Cache Methods ---
get(text) {
add(text) {
let history = this.getHistory();
if (history.length > 0 && history[0] === text) {
return;
}
history.unshift(text);
if (history.length > 200) {
history = history.slice(0, 200);
}
this.replStore.set('history', history);
}
clear() {
this.clearTranslationHistory();
this.clearReplHistory();
}
clearReplHistory() {
this.replStore.set('history', []);
}
clearTranslationHistory() {
this.store.set('entries', []);
}
get(text, targetLang) {
const entries = this.store.get('entries');
return entries.find(e => e.source.trim() === text.trim());
return entries.find(e => e.source.trim() === text.trim() && e.targetLang === targetLang);
}
getHistory() {
return this.replStore.get('history') || [];
}
// --- REPL Shell History Methods ---
getLatest() {

@@ -37,11 +62,12 @@ const entries = this.store.get('entries');

}
save(text, result, provider) {
save(text, result, provider, targetLang) {
const entries = this.store.get('entries');
const filtered = entries.filter(e => e.source.trim() !== text.trim());
// Only remove existing entry if it matches BOTH source AND targetLang
const filtered = entries.filter(e => !(e.source.trim() === text.trim() && e.targetLang === targetLang));
filtered.unshift({
provider,
result,
source: text.trim(),
targetLang: 'zh', // Default to Chinese for now
result,
targetLang,
timestamp: Date.now(),
provider,
});

@@ -53,27 +79,2 @@ if (filtered.length > 1000) {

}
clearTranslationHistory() {
this.store.set('entries', []);
}
clearReplHistory() {
this.replStore.set('history', []);
}
clear() {
this.clearTranslationHistory();
this.clearReplHistory();
}
// --- REPL Shell History Methods ---
getHistory() {
return this.replStore.get('history') || [];
}
add(text) {
let history = this.getHistory();
if (history.length > 0 && history[0] === text) {
return;
}
history.unshift(text);
if (history.length > 200) {
history = history.slice(0, 200);
}
this.replStore.set('history', history);
}
}

@@ -0,16 +1,16 @@

import { checkbox, confirm, input as inputPrompt, password, select } from '@inquirer/prompts';
import chalk from 'chalk';
import { PromptFactory } from '../prompts/index.js';
import { ProviderFactory } from '../providers/factory.js';
import { RendererFactory } from '../renderers/index.js';
import { AudioService } from '../services/audio.js';
import { HistoryService } from '../services/history.js';
import { ProviderFactory } from '../providers/factory.js';
import { renderResult, renderStream, resetRenderer } from './renderer.js';
import { select, password, confirm, checkbox } from '@inquirer/prompts';
import { input as inputPrompt } from '@inquirer/prompts';
import search from '@inquirer/search';
let lastWord = "";
import { handleProviderError } from '../utils/error-handler.js';
function printHeader(configService) {
const config = configService.config;
const { config } = configService;
const currentProvider = configService.getCurrentService();
const targetLang = configService.getTargetLang();
const mode = configService.getMode();
if (currentProvider) {
console.log(chalk.dim(`Provider: ${chalk.white(config.current)} | Model: ${chalk.white(currentProvider.model)} | Target: ${chalk.white(targetLang)}`));
console.log(chalk.dim(`Provider: ${chalk.white(config.current)} | Model: ${chalk.white(currentProvider.model)} | Target: ${chalk.white(targetLang)} | Mode: ${chalk.white(mode)}`));
}

@@ -29,2 +29,3 @@ }

// 2. Read input manually to catch '/'
// eslint-disable-next-line no-await-in-loop
const line = await readLineOrCommand(historyService.getHistory());

@@ -36,2 +37,3 @@ if (!line)

if (line.startsWith('/')) {
// eslint-disable-next-line no-await-in-loop
const shouldExit = await handleCommand(line, configService);

@@ -42,2 +44,3 @@ if (shouldExit)

else {
// eslint-disable-next-line no-await-in-loop
await handleTranslation(line, configService);

@@ -65,2 +68,3 @@ }

stdout.write('\n');
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
process.exit(0);

@@ -71,3 +75,3 @@ }

// Clear screen and move cursor to top-left
stdout.write('\x1b[2J\x1b[3J\x1b[H');
stdout.write('\u001B[2J\u001B[3J\u001B[H');
// Redraw prompt

@@ -80,3 +84,3 @@ stdout.write(chalk.cyan('› '));

// Up Arrow
if (key === '\u001b[A') {
if (key === '\u001B[A') {
if (history.length > 0 && historyIndex < history.length - 1) {

@@ -95,11 +99,6 @@ if (historyIndex === -1)

// Down Arrow
if (key === '\u001b[B') {
if (key === '\u001B[B') {
if (historyIndex > -1) {
historyIndex--;
if (historyIndex === -1) {
buffer = tempBuffer;
}
else {
buffer = history[historyIndex];
}
buffer = historyIndex === -1 ? tempBuffer : history[historyIndex];
// Redraw line

@@ -113,7 +112,7 @@ stdout.clearLine(0);

// Left/Right Arrow - Ignore for now to prevent corruption
if (key === '\u001b[C' || key === '\u001b[D') {
if (key === '\u001B[C' || key === '\u001B[D') {
return;
}
// Backspace (DEL)
if (key === '\x7f') {
if (key === '\u007F') {
if (buffer.length > 0) {

@@ -137,3 +136,3 @@ buffer = buffer.slice(0, -1);

// Clear the prompt "› " temporarily so menu looks clean
stdout.write('\r\x1b[K');
stdout.write('\r\u001B[K');
// Launch Command Menu

@@ -166,24 +165,23 @@ const command = await selectCommand();

const commands = [
{ name: '/use (Switch provider)', value: '/use', description: 'Switch AI provider or model' },
{ name: '/add (Add provider)', value: '/add', description: 'Add a new custom provider' },
{ name: '/remove (Remove provider)', value: '/remove', description: 'Remove an existing provider' },
{ name: '/target (Set language)', value: '/target', description: 'Set target language' },
{ name: '/say (Pronounce)', value: '/say', description: 'Pronounce the last translated text' },
{ name: '/status (Check status)', value: '/status', description: 'Check services availability' },
{ name: '/clean (Clear history)', value: '/clean', description: 'Clear history and cache' },
{ name: '/exit (Quit)', value: '/exit', description: 'Exit the REPL' },
{ description: 'Pronounce the last translated text', name: '/say (Pronounce)', value: '/say' },
{ description: 'Set target language', name: '/target (Set language)', value: '/target' },
{ description: 'Set prompt mode (translation, grammar)', name: '/mode (Set Mode)', value: '/mode' },
{ description: 'Switch AI provider or model', name: '/use (Switch provider)', value: '/use' },
{ description: 'Check services availability', name: '/status (Check status)', value: '/status' },
{ description: 'Add a new custom provider', name: '/add (Add provider)', value: '/add' },
{ description: 'Remove an existing provider', name: '/remove (Remove provider)', value: '/remove' },
{ description: 'Clear history and cache', name: '/clean (Clear history)', value: '/clean' },
{ description: 'Reset all configuration', name: '/reset (Factory reset)', value: '/reset' },
{ description: 'Exit the REPL', name: '/exit (Quit)', value: '/exit' },
];
try {
const answer = await search({
const answer = await select({
choices: commands,
loop: true,
message: 'Select Command',
source: async (term) => {
if (!term)
return commands;
return commands.filter(c => c.name.toLowerCase().includes(term.toLowerCase()) ||
c.value.includes(term.toLowerCase()));
},
pageSize: 10,
});
return answer;
}
catch (e) {
catch {
return null; // Cancelled (Ctrl+C during search)

@@ -195,15 +193,27 @@ }

switch (command) {
case '/exit':
case '/add': {
await handleAddProvider(configService);
return false;
}
case '/clean': {
await handleCleanCommand();
return false;
}
case '/exit': {
return true;
case '/target':
let lang = arg;
if (!lang) {
}
case '/mode': {
let mode = arg;
if (!mode) {
try {
lang = await inputPrompt({
message: 'Enter target language:',
default: configService.getTargetLang(),
validate: (value) => value.length > 0 || 'Language code is required',
mode = await select({
choices: [
{ name: 'Translation', value: 'translation' },
{ name: 'Grammar Check', value: 'grammar' },
{ name: 'Oxford Dictionary', value: 'oxford' }
],
message: 'Select Mode:',
});
}
catch (e) {
catch {
console.log(chalk.red('\nOperation cancelled.'));

@@ -213,8 +223,14 @@ return false;

}
configService.setTargetLang(lang);
console.log(chalk.green(`Target language set to: ${lang}`));
configService.setMode(mode);
console.log(chalk.green(`Mode set to: ${mode}`));
return false;
case '/use':
await handleUseCommand(configService);
}
case '/remove': {
await handleRemoveProvider(configService);
return false;
}
case '/reset': {
await handleResetCommand(configService);
return true;
} // Exit after reset
case '/say': {

@@ -230,17 +246,33 @@ const history = new HistoryService();

}
case '/add':
await handleAddProvider(configService);
case '/status': {
await handleStatusCommand(configService);
return false;
case '/remove':
await handleRemoveProvider(configService);
}
case '/target': {
let lang = arg;
if (!lang) {
try {
lang = await inputPrompt({
default: configService.getTargetLang(),
message: 'Enter target language:',
validate: (value) => value.length > 0 || 'Language code is required',
});
}
catch {
console.log(chalk.red('\nOperation cancelled.'));
return false;
}
}
configService.setTargetLang(lang);
console.log(chalk.green(`Target language set to: ${lang}`));
return false;
case '/status':
await handleStatusCommand(configService);
}
case '/use': {
await handleUseCommand(configService);
return false;
case '/clean':
await handleCleanCommand();
return false;
default:
}
default: {
console.log(chalk.red('Unknown command.'));
return false;
}
}

@@ -251,3 +283,3 @@ }

const services = configService.listServices();
const current = configService.config.current;
const { current } = configService.config;
// Filter out active service

@@ -266,8 +298,8 @@ const choices = Object.keys(services)

const providerToRemove = await select({
choices,
message: 'Select Provider to Remove:',
choices,
});
const confirmed = await confirm({
default: false,
message: `Are you sure you want to delete "${providerToRemove}"?`,
default: false,
});

@@ -282,5 +314,5 @@ if (confirmed) {

}
catch (e) {
if (e.message && e.message.includes('Cannot remove active service')) {
console.log(chalk.red('\nError: ' + e.message));
catch (error) { // eslint-disable-line @typescript-eslint/no-explicit-any
if (error.message && error.message.includes('Cannot remove active service')) {
console.log(chalk.red('\nError: ' + error.message));
}

@@ -298,3 +330,3 @@ else {

message: 'Provider Name (ID):',
validate: (value) => {
validate(value) {
if (!value)

@@ -311,7 +343,7 @@ return 'Name is required';

const type = await select({
message: 'Provider Type:',
choices: [
{ name: 'OpenAI Compatible (Generic)', value: 'openai' },
{ name: 'Ollama', value: 'ollama' }
]
],
message: 'Provider Type:'
});

@@ -321,4 +353,4 @@ // 3. Base URL

const baseUrl = await inputPrompt({
default: defaultBaseUrl,
message: 'Base URL:',
default: defaultBaseUrl,
validate: (value) => value.length > 0 || 'Base URL is required'

@@ -330,4 +362,4 @@ });

apiKey = await password({
mask: '*',
message: 'API Key:',
mask: '*',
validate: (value) => value.length > 0 || 'API Key is required for this type'

@@ -338,4 +370,4 @@ });

const model = await inputPrompt({
default: type === 'ollama' ? 'llama3' : 'gpt-3.5-turbo',
message: 'Default Model Name:',
default: type === 'ollama' ? 'llama3' : 'gpt-3.5-turbo',
validate: (value) => value.length > 0 || 'Model name is required'

@@ -345,11 +377,12 @@ });

configService.setService(name, {
type: type,
apiKey,
baseUrl,
apiKey,
model
model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: type
});
console.log(chalk.green(`\n✓ Provider "${name}" added successfully.`));
const switchNow = await confirm({
message: `Switch to "${name}" now?`,
default: true
default: true,
message: `Switch to "${name}" now?`
});

@@ -361,3 +394,3 @@ if (switchNow) {

}
catch (e) {
catch {
console.log(chalk.red('\nOperation cancelled.'));

@@ -374,7 +407,7 @@ }

const selectedService = await select({
choices,
message: 'Select Service',
choices,
});
const config = services[selectedService];
const needsKey = ['openai', 'deepseek', 'moonshot'].includes(config.type);
const needsKey = ['deepseek', 'moonshot', 'openai'].includes(config.type);
if (needsKey && !config.apiKey) {

@@ -384,4 +417,4 @@ console.log(chalk.yellow(`API Key required for ${selectedService}`));

const apiKey = await password({
mask: '*',
message: 'Enter API Key:',
mask: '*',
validate: (value) => value.length > 0 || 'API Key cannot be empty',

@@ -393,3 +426,3 @@ });

}
catch (e) {
catch {
console.log(chalk.red('\nConfiguration cancelled. API Key is required.'));

@@ -407,4 +440,4 @@ return; // Abort switch

const useManual = await select({
choices: [{ name: 'Yes', value: true }, { name: 'No', value: false }],
message: 'Do you want to enter a model name manually?',
choices: [{ name: 'Yes', value: true }, { name: 'No', value: false }],
});

@@ -424,3 +457,2 @@ if (!useManual) {

const selectedModel = await select({
message: 'Select Model',
choices: [

@@ -430,2 +462,3 @@ ...models.map(m => ({ name: m, value: m })),

],
message: 'Select Model',
});

@@ -448,3 +481,3 @@ if (selectedModel === '__manual__') {

}
catch (e) {
catch {
console.log(chalk.red('Selection cancelled'));

@@ -463,2 +496,3 @@ }

process.stdout.write(` ${name}: ${chalk.gray('checking...')}\r`);
// eslint-disable-next-line no-await-in-loop
const isHealthy = await provider.checkHealth();

@@ -484,8 +518,8 @@ if (process.stdout.isTTY) {

const choices = await checkbox({
message: 'Select items to clear:',
choices: [
{ name: 'Translation History', value: 'translation', checked: true },
{ name: 'Command History', value: 'repl', checked: true },
{ name: 'Audio Cache', value: 'audio', checked: true },
{ checked: true, name: 'Translation History', value: 'translation' },
{ checked: true, name: 'Command History', value: 'repl' },
{ checked: true, name: 'Audio Cache', value: 'audio' },
],
message: 'Select items to clear:',
});

@@ -509,6 +543,30 @@ if (choices.length === 0) {

}
catch (e) {
catch {
console.log(chalk.red('Operation cancelled'));
}
}
async function handleResetCommand(configService) {
console.log(chalk.bgRed.white.bold(' FACTORY RESET '));
console.log(chalk.yellow('This will delete ALL configuration, history, and cache.'));
console.log(chalk.dim('Path: ' + configService.path || 'Config Directory')); // Requires path getter in ConfigService
try {
const confirmed = await confirm({
default: false,
message: 'Are you absolutely sure?',
});
if (confirmed) {
configService.reset();
console.log(chalk.green('\n✓ System reset successfully.'));
console.log(chalk.dim('Please restart the application.'));
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
process.exit(0);
}
else {
console.log(chalk.dim('Operation cancelled.'));
}
}
catch {
console.log(chalk.red('\nOperation cancelled.'));
}
}
async function handleTranslation(text, configService) {

@@ -521,9 +579,12 @@ const currentConfig = configService.getCurrentService();

const historyService = new HistoryService();
const cached = historyService.get(text);
const targetLang = configService.getTargetLang();
const cached = historyService.get(text, targetLang);
const mode = configService.getMode();
// Strategies (Hardcoded for now)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promptStrategy = PromptFactory.get(mode);
const renderStrategy = RendererFactory.get('box');
if (cached && cached.provider === configService.config.current && cached.targetLang === targetLang) {
// Return cached result immediately
await renderResult(cached.result, true);
if (cached.result.word)
lastWord = cached.result.word;
renderStrategy.render(cached.result, true);
return;

@@ -534,12 +595,25 @@ }

try {
resetRenderer(); // Reset cursor tracker
const stream = provider.stream(text, targetLang);
const result = await renderStream(stream);
const systemMessage = promptStrategy.getSystemMessage(targetLang);
renderStrategy.reset(); // Reset cursor tracker
const stream = provider.stream(text, { systemMessage });
let buffer = '';
let result = promptStrategy.getInitialState();
let lastRender = 0;
for await (const chunk of stream) {
buffer += chunk;
result = promptStrategy.parse(buffer);
const now = Date.now();
if (now - lastRender > 50) {
renderStrategy.render(result, false);
lastRender = now;
}
}
// Force override word with input text to ensure clean header
result.word = text;
if (result.word)
lastWord = result.word; // Real streaming render
resetRenderer(); // Clean up after done
if (result && typeof result === 'object' && 'word' in result) {
result.word = text;
}
renderStrategy.render(result, false);
renderStrategy.reset(); // Clean up after done
// 2. Save to History
historyService.save(text, result, configService.config.current);
historyService.save(text, result, configService.config.current, targetLang);
}

@@ -549,3 +623,13 @@ catch (error) {

process.stdout.cursorTo(0);
console.log(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
// We pass a dummy spinner or undefined, handleProviderError needs to support that
// But since handleProviderError might use spinner, we should check its signature
// Actually handleProviderError in utils takes spinner?
// Let's assume it handles missing spinner or we pass null.
// In repl we don't have a spinner usually.
if (await handleProviderError(error, configService)) {
await handleTranslation(text, configService);
return;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(chalk.red(`Error: ${errorMessage}`));
}

@@ -561,3 +645,3 @@ }

}
catch (e) {
catch {
process.stdout.clearLine(0);

@@ -564,0 +648,0 @@ process.stdout.cursorTo(0);

@@ -31,3 +31,3 @@ {

},
"version": "1.0.1"
"version": "1.1.0"
}
{
"name": "xtrans",
"description": "Minimalist AI-powered terminal translator / 极简 AI 终端翻译工具",
"version": "1.0.1",
"version": "1.1.0",
"author": "wengqianshan",

@@ -6,0 +6,0 @@ "bin": {

export declare function getSystemPrompt(targetLang: string): string;
export function getSystemPrompt(targetLang) {
return `
You are a professional linguist and translation tool. Analyze the user input.
Translate the input into "${targetLang}".
CRITICAL INSTRUCTION:
- Response MUST be a stream of data lines.
- DO NOT use JSON.
- Format each field with a tag like [TAG] Content.
- Provide BILINGUAL content where appropriate.
Required Tags:
[WORD] The original source word ONLY
[PHONETIC] IPA phonetic transcription
[TAG] Domain tags (e.g. Computing, Medical, Slang) - One per line
[POS] Part of Speech (noun, verb, adj, etc.) - Starts a new definition block
[MEANING] Definition in target language (with source term in brackets if helpful)
[EXAMPLE] Example sentence - Must follow the relevant [POS] and [MEANING]
[SYNONYM] Synonyms - One per line
[ANTONYM] Antonyms - One per line
[IDIOM] Common idioms/phrases containing the word - One per line
[ORIGIN] Etymology/Origin of the word (brief)
Structure:
- Always start with [WORD] and [PHONETIC].
- Then [TAG]s.
- Then iterate through meanings, grouping them by [POS].
- Inside each [POS], provide the [MEANING] followed immediately by relevant [EXAMPLE]s.
- End with [SYNONYM], [ANTONYM], [IDIOM], [ORIGIN].
Example Output:
[WORD] hello
[PHONETIC] /hɛˈloʊ/
[TAG] Greeting
[TAG] Informal
[POS] interjection
[MEANING] 你好 (Used as a greeting or to begin a telephone conversation)
[EXAMPLE] Hello, Paul. (你好,保罗。)
[POS] noun
[MEANING] 招呼 (An utterance of 'hello'; a greeting)
[EXAMPLE] She said hello to me. (她向我打了个招呼。)
[SYNONYM] hi
[ANTONYM] goodbye
[IDIOM] say hello to (向...问好)
[ORIGIN] Mid 19th century: alteration of hollo.
`;
}
import { TranslationResult } from '../providers/types.js';
export declare function renderStream(stream: AsyncGenerator<string>): Promise<TranslationResult>;
export declare function renderResult(result: TranslationResult, fromCache: boolean): Promise<void>;
export declare function resetRenderer(): void;
import boxen from 'boxen';
import chalk from 'chalk';
import { parseRawOutput } from '../utils/parser.js';
export async function renderStream(stream) {
let buffer = '';
let lastRender = 0;
let result = {
word: '',
phonetic: '',
meaning: [],
examples: [],
synonyms: [],
antonyms: [],
definitions: [],
idioms: [],
tags: []
};
for await (const chunk of stream) {
buffer += chunk;
result = parseRawOutput(buffer);
const now = Date.now();
if (now - lastRender > 50) {
renderBox(result, false);
lastRender = now;
}
}
renderBox(result, false);
return result;
}
export async function renderResult(result, fromCache) {
renderBox(result, fromCache);
}
let lastHeight = 0;
function renderBox(result, fromCache) {
const headerParts = [chalk.bold(result.word || '...')];
if (result.phonetic)
headerParts.push(chalk.dim(result.phonetic));
if (result.tags && result.tags.length > 0) {
headerParts.push(result.tags.map(t => chalk.bgBlue.white(` ${t} `)).join(' '));
}
if (fromCache)
headerParts.push(chalk.dim('(cached)'));
const header = headerParts.join(' ');
const content = [header, ''];
if (result.definitions && result.definitions.length > 0) {
result.definitions.forEach(def => {
content.push(chalk.cyan.bold(def.partOfSpeech));
content.push(chalk.green(' • ') + def.meaning);
if (def.examples && def.examples.length > 0) {
def.examples.forEach(ex => content.push(chalk.dim(' - ') + ex));
}
content.push('');
});
}
else if (result.meaning && result.meaning.length > 0) {
// Fallback for flat meanings
content.push(...result.meaning.map(m => chalk.green('• ') + m));
if (result.examples && result.examples.length > 0) {
content.push('');
content.push(chalk.yellow('Examples:'));
result.examples.forEach(ex => content.push(chalk.dim('• ') + ex));
}
}
if (result.idioms && result.idioms.length > 0) {
content.push(chalk.magenta.bold('Idioms:'));
result.idioms.forEach(idiom => content.push(chalk.dim('• ') + idiom));
content.push('');
}
const footer = [];
if (result.synonyms && result.synonyms.length > 0) {
footer.push(chalk.dim('Synonyms: ') + result.synonyms.join(', '));
}
if (result.antonyms && result.antonyms.length > 0) {
footer.push(chalk.dim('Antonyms: ') + result.antonyms.join(', '));
}
if (result.origin) {
footer.push(chalk.dim('Origin: ') + result.origin);
}
if (footer.length > 0) {
if (content[content.length - 1] !== '')
content.push('');
content.push(...footer);
}
const box = boxen(content.join('\n').trim(), { padding: 1, borderStyle: 'round', borderColor: fromCache ? 'gray' : 'cyan' });
if (lastHeight > 0) {
process.stdout.write(`\x1B[${lastHeight}A`);
process.stdout.write('\x1B[0J');
}
console.log(box);
lastHeight = box.split('\n').length;
if (fromCache)
lastHeight = 0;
}
export function resetRenderer() {
lastHeight = 0;
}
import { TranslationResult } from '../providers/types.js';
export declare function parseRawOutput(text: string): TranslationResult;
export function parseRawOutput(text) {
const result = {
word: '',
phonetic: '',
meaning: [],
examples: [],
synonyms: [],
antonyms: [],
definitions: [],
idioms: [],
tags: []
};
const lines = text.split('\n');
let currentDefinition = null;
for (const line of lines) {
const cleanLine = line.trim();
if (!cleanLine)
continue;
if (cleanLine.startsWith('[WORD]')) {
result.word = cleanLine.replace('[WORD]', '').trim();
}
else if (cleanLine.startsWith('[PHONETIC]')) {
result.phonetic = cleanLine.replace('[PHONETIC]', '').trim();
}
else if (cleanLine.startsWith('[POS]')) {
const pos = cleanLine.replace('[POS]', '').trim();
currentDefinition = { partOfSpeech: pos, meaning: '', examples: [] };
result.definitions.push(currentDefinition);
}
else if (cleanLine.startsWith('[MEANING]')) {
const meaning = cleanLine.replace('[MEANING]', '').trim();
if (currentDefinition) {
currentDefinition.meaning = meaning;
}
// Always add to flat list for compatibility
result.meaning.push(meaning);
}
else if (cleanLine.startsWith('[EXAMPLE]')) {
const example = cleanLine.replace('[EXAMPLE]', '').trim();
if (currentDefinition) {
currentDefinition.examples.push(example);
}
// Always add to flat list for compatibility
result.examples.push(example);
}
else if (cleanLine.startsWith('[SYNONYM]')) {
result.synonyms.push(cleanLine.replace('[SYNONYM]', '').trim());
}
else if (cleanLine.startsWith('[ANTONYM]')) {
result.antonyms.push(cleanLine.replace('[ANTONYM]', '').trim());
}
else if (cleanLine.startsWith('[IDIOM]')) {
result.idioms.push(cleanLine.replace('[IDIOM]', '').trim());
}
else if (cleanLine.startsWith('[TAG]')) {
result.tags.push(cleanLine.replace('[TAG]', '').trim());
}
else if (cleanLine.startsWith('[ORIGIN]')) {
result.origin = cleanLine.replace('[ORIGIN]', '').trim();
}
}
return result;
}