| 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 {}; |
| export interface RenderStrategy<T = any> { | ||
| id: string; | ||
| render(data: T, fromCache: boolean): void; | ||
| reset(): void; | ||
| } |
| export {}; |
| 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}) |
+3
-3
@@ -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; | ||
| } |
+49
-38
@@ -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>; | ||
| } |
+22
-27
| 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; | ||
| } |
+12
-10
@@ -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>; | ||
| } |
+62
-45
| 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; | ||
| } |
+42
-41
| 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); | ||
| } | ||
| } |
+187
-103
@@ -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" | ||
| } |
+1
-1
| { | ||
| "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; | ||
| } |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
78198
34.71%45
45.16%1953
35.16%3
-40%