From 7b04ca4014bcf43bce2bf66bad59e7bd1b8ab45a Mon Sep 17 00:00:00 2001 From: "heesk0223@gmail.com" Date: Sun, 31 May 2026 23:10:39 +0900 Subject: [PATCH 1/3] Add minimum operating - translate annotations, install ollama and AI models --- src/extension.ts | 190 +++++++++++++++-- .../ollama/commands/pullRecommendedModel.ts | 93 ++++++++ .../ollama/commands/showOllamaModels.ts | 125 +++++++++++ src/features/ollama/commands/startOllama.ts | 58 +++++ src/features/ollama/index.ts | 25 +++ .../ollama/services/ollamaCliService.ts | 149 +++++++++++++ src/features/ollama/services/ollamaOutput.ts | 71 +++++++ .../ollama/services/ollamaRuntimeService.ts | 122 +++++++++++ src/features/ollama/services/statusBar.ts | 52 +++++ .../ollama/tests/ollamaCliService.test.ts | 27 +++ .../ollama/tests/ollamaOutput.test.ts | 34 +++ .../ollama/tests/ollamaRuntimeService.test.ts | 98 +++++++++ src/features/ollama/tests/statusBar.test.ts | 34 +++ src/features/ollama/types/ollama.ts | 13 ++ .../settings/commands/selectOllamaModel.ts | 96 +++++++++ src/features/settings/index.ts | 1 + .../commands/translateSelection.ts | 102 +++++++++ .../translation/commands/translateText.ts | 93 ++++++++ src/features/translation/index.ts | 9 + .../translation/prompts/translationPrompt.ts | 24 +++ .../translation/services/hoverText.ts | 130 +++++++++++ .../translation/services/ollamaService.ts | 150 +++++++++++++ .../translation/services/outputChannel.ts | 55 +++++ .../services/translateHoverProvider.ts | 82 +++++++ .../services/translateSelectionCore.ts | 67 ++++++ .../translation/tests/hoverText.test.ts | 70 ++++++ .../translation/tests/ollamaService.test.ts | 180 ++++++++++++++++ src/features/translation/tests/ollamaSmoke.ts | 80 +++++++ .../tests/translateSelection.test.ts | 201 ++++++++++++++++++ .../tests/translateSelectionCore.test.ts | 71 +++++++ src/features/translation/types/translation.ts | 10 + src/shared/config/ollama.ts | 68 ++++++ src/shared/constants/messages.ts | 10 + src/test/extension.test.ts | 21 +- 34 files changed, 2582 insertions(+), 29 deletions(-) create mode 100644 src/features/ollama/commands/pullRecommendedModel.ts create mode 100644 src/features/ollama/commands/showOllamaModels.ts create mode 100644 src/features/ollama/commands/startOllama.ts create mode 100644 src/features/ollama/index.ts create mode 100644 src/features/ollama/services/ollamaCliService.ts create mode 100644 src/features/ollama/services/ollamaOutput.ts create mode 100644 src/features/ollama/services/ollamaRuntimeService.ts create mode 100644 src/features/ollama/services/statusBar.ts create mode 100644 src/features/ollama/tests/ollamaCliService.test.ts create mode 100644 src/features/ollama/tests/ollamaOutput.test.ts create mode 100644 src/features/ollama/tests/ollamaRuntimeService.test.ts create mode 100644 src/features/ollama/tests/statusBar.test.ts create mode 100644 src/features/ollama/types/ollama.ts create mode 100644 src/features/settings/commands/selectOllamaModel.ts create mode 100644 src/features/settings/index.ts create mode 100644 src/features/translation/commands/translateSelection.ts create mode 100644 src/features/translation/commands/translateText.ts create mode 100644 src/features/translation/index.ts create mode 100644 src/features/translation/prompts/translationPrompt.ts create mode 100644 src/features/translation/services/hoverText.ts create mode 100644 src/features/translation/services/ollamaService.ts create mode 100644 src/features/translation/services/outputChannel.ts create mode 100644 src/features/translation/services/translateHoverProvider.ts create mode 100644 src/features/translation/services/translateSelectionCore.ts create mode 100644 src/features/translation/tests/hoverText.test.ts create mode 100644 src/features/translation/tests/ollamaService.test.ts create mode 100644 src/features/translation/tests/ollamaSmoke.ts create mode 100644 src/features/translation/tests/translateSelection.test.ts create mode 100644 src/features/translation/tests/translateSelectionCore.test.ts create mode 100644 src/features/translation/types/translation.ts create mode 100644 src/shared/config/ollama.ts create mode 100644 src/shared/constants/messages.ts diff --git a/src/extension.ts b/src/extension.ts index 2c6310b..7b146f5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,26 +1,178 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; +import { Ollama } from 'ollama'; -// This method is called when your extension is activated -// Your extension is activated the very first time the command is executed -export function activate(context: vscode.ExtensionContext) { +import { + createPullRecommendedModelCommand, + createShowOllamaModelsCommand, + createStartOllamaCommand, + OllamaCliService, + OllamaRuntimeService, + updateOllamaStatusBarItem, +} from './features/ollama'; +import { createSelectOllamaModelCommand } from './features/settings'; +import { + FRILINGO_OUTPUT_CHANNEL_NAME, + createTranslateHoverProvider, + createTranslateSelectionCommand, + createTranslateTextCommand, + OllamaService, +} from './features/translation'; +import { createOllamaConfig } from './shared/config/ollama'; - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "frilingo" is now active!'); +export function activate(context: vscode.ExtensionContext) { + const getOllamaConfig = () => + createOllamaConfig(vscode.workspace.getConfiguration('frilingo')); + const ollamaService = new OllamaService(undefined, getOllamaConfig); + const ollamaCliService = new OllamaCliService(); + const ollamaRuntimeService = new OllamaRuntimeService( + ollamaCliService, + new Ollama({ host: getOllamaConfig().host }), + getOllamaConfig, + ); + // Creates a shared output channel so every translation request writes to the same log. + const outputChannel = vscode.window.createOutputChannel(FRILINGO_OUTPUT_CHANNEL_NAME); + // Keeps the current Ollama readiness visible even when the output panel is closed. + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + updateOllamaStatusBarItem(statusBarItem, ollamaRuntimeService.getState()); + const runtimeSubscription = ollamaRuntimeService.onDidChangeState((state) => { + updateOllamaStatusBarItem(statusBarItem, state); + }); + const showOllamaModelsDisposable = vscode.commands.registerCommand( + 'frilingo.showOllamaModels', + createShowOllamaModelsCommand(ollamaCliService, ollamaRuntimeService, getOllamaConfig, { + showInformationMessage: (message: string, ...items: string[]) => + vscode.window.showInformationMessage(message, ...items), + showWarningMessage: (message: string, ...items: string[]) => + vscode.window.showWarningMessage(message, ...items), + executeCommand: (command: string) => vscode.commands.executeCommand(command), + openExternal: (uri: vscode.Uri) => vscode.env.openExternal(uri), + outputChannel, + refreshRuntimeState: () => ollamaRuntimeService.refreshState(), + }), + ); + const startOllamaDisposable = vscode.commands.registerCommand( + 'frilingo.startOllama', + createStartOllamaCommand(ollamaCliService, ollamaRuntimeService, { + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: (task: () => Promise) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Frilingo: Starting Ollama...', + cancellable: false, + }, + task, + ), + outputChannel, + refreshRuntimeState: () => ollamaRuntimeService.refreshState(), + }), + ); + const pullRecommendedModelDisposable = vscode.commands.registerCommand( + 'frilingo.pullRecommendedModel', + createPullRecommendedModelCommand(ollamaCliService, ollamaRuntimeService, getOllamaConfig, { + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (progress: vscode.Progress<{ message?: string }>) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: false, + }, + (progress) => task(progress), + ), + outputChannel, + refreshRuntimeState: () => ollamaRuntimeService.refreshState(), + }), + ); + const selectOllamaModelDisposable = vscode.commands.registerCommand( + 'frilingo.selectOllamaModel', + createSelectOllamaModelCommand(ollamaCliService, ollamaRuntimeService, { + showQuickPick: (items, options) => vscode.window.showQuickPick(items, options), + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showWarningMessage: (message: string) => vscode.window.showWarningMessage(message), + executeCommand: (command: string) => vscode.commands.executeCommand(command), + updateConfiguration: (model: string) => + vscode.workspace + .getConfiguration('frilingo') + .update('ollamaModel', model, vscode.ConfigurationTarget.Global), + refreshRuntimeState: () => ollamaRuntimeService.refreshState(), + }), + ); + const translateSelectionDisposable = vscode.commands.registerCommand( + 'frilingo.translateSelection', + createTranslateSelectionCommand(ollamaService, { + getActiveTextEditor: () => vscode.window.activeTextEditor, + getActiveModelName: () => getOllamaConfig().model, + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + (_progress, cancellationToken) => task(cancellationToken), + ), + outputChannel, + }), + ); + const translateTextDisposable = vscode.commands.registerCommand( + 'frilingo.translateText', + createTranslateTextCommand(ollamaService, { + getActiveModelName: () => getOllamaConfig().model, + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + (_progress, cancellationToken) => task(cancellationToken), + ), + outputChannel, + }), + ); + const hoverProviderDisposable = vscode.languages.registerHoverProvider( + { scheme: 'file' }, + createTranslateHoverProvider(ollamaRuntimeService), + ); - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - const disposable = vscode.commands.registerCommand('frilingo.helloWorld', () => { - // The code you place here will be executed every time your command is executed - // Display a message box to the user - vscode.window.showInformationMessage('Hello World from frilingo!'); - }); + // Runs one startup inventory pass so the output channel shows local model state immediately. + void vscode.commands.executeCommand('frilingo.showOllamaModels'); - context.subscriptions.push(disposable); + context.subscriptions.push( + statusBarItem, + runtimeSubscription, + showOllamaModelsDisposable, + startOllamaDisposable, + pullRecommendedModelDisposable, + selectOllamaModelDisposable, + translateSelectionDisposable, + translateTextDisposable, + hoverProviderDisposable, + outputChannel, + ); } -// This method is called when your extension is deactivated -export function deactivate() {} +export function deactivate() { + // No cleanup is required for v0.1. +} diff --git a/src/features/ollama/commands/pullRecommendedModel.ts b/src/features/ollama/commands/pullRecommendedModel.ts new file mode 100644 index 0000000..5a271d4 --- /dev/null +++ b/src/features/ollama/commands/pullRecommendedModel.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; + +import { DEFAULT_OLLAMA_MODEL, FrilingoOllamaConfig, OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { + appendOllamaCliError, + appendOllamaPullResult, + extractLatestPullStatus, +} from '../services/ollamaOutput'; +import { OllamaCliService } from '../services/ollamaCliService'; +import { OllamaRuntimeService } from '../services/ollamaRuntimeService'; + +type PullRecommendedModelDependencies = { + showInformationMessage: (message: string) => Thenable; + showErrorMessage: (message: string) => Thenable; + withProgress: ( + title: string, + task: (progress: vscode.Progress<{ message?: string }>) => Promise, + ) => Thenable; + outputChannel: vscode.OutputChannel; + refreshRuntimeState?: () => Promise; +}; + +const defaultDependencies: PullRecommendedModelDependencies = { + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (progress: vscode.Progress<{ message?: string }>) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: false, + }, + (progress) => task(progress), + ), + outputChannel: vscode.window.createOutputChannel('Frilingo'), +}; + +// Pulls the configured recommendation so the user can bootstrap Frilingo quickly. +export function createPullRecommendedModelCommand( + ollamaCliService: OllamaCliService, + runtimeService?: Pick, + getConfig: () => FrilingoOllamaConfig = () => OLLAMA_CONFIG, + dependencies: PullRecommendedModelDependencies = defaultDependencies, +): () => Promise { + return async () => { + const config = getConfig(); + const model = config.model || DEFAULT_OLLAMA_MODEL; + + try { + dependencies.outputChannel.appendLine( + `[Frilingo] Pulling model: ${model}`, + ); + const output = await dependencies.withProgress( + `Frilingo: Pulling ${model}...`, + (progress) => + ollamaCliService.pullModel(model, { + // Updates the VSCode notification with the latest sanitized CLI status instead of dumping raw ANSI output. + onOutput: (chunk: string) => { + const latestStatus = extractLatestPullStatus(chunk); + + if (latestStatus) { + progress.report({ message: latestStatus }); + } + }, + }), + ); + + appendOllamaPullResult( + dependencies.outputChannel, + model, + output, + ); + await (dependencies.refreshRuntimeState?.() ?? runtimeService?.refreshState()); + dependencies.outputChannel.show(true); + + await dependencies.showInformationMessage( + `Frilingo: Pulled recommended model ${model}.`, + ); + } catch (error) { + const errorMessage = String(error); + + appendOllamaCliError(dependencies.outputChannel, 'pull', errorMessage); + dependencies.outputChannel.show(true); + await dependencies.showErrorMessage( + `Frilingo: Failed to pull ${model}.`, + ); + } + }; +} diff --git a/src/features/ollama/commands/showOllamaModels.ts b/src/features/ollama/commands/showOllamaModels.ts new file mode 100644 index 0000000..c0fbdcb --- /dev/null +++ b/src/features/ollama/commands/showOllamaModels.ts @@ -0,0 +1,125 @@ +import * as vscode from 'vscode'; + +import { DEFAULT_OLLAMA_MODEL, FrilingoOllamaConfig, OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { + appendOllamaCliError, + appendOllamaModelList, +} from '../services/ollamaOutput'; +import { OllamaCliService } from '../services/ollamaCliService'; +import { OllamaRuntimeService } from '../services/ollamaRuntimeService'; + +type ShowOllamaModelsDependencies = { + showInformationMessage: ( + message: string, + ...items: string[] + ) => Thenable; + showWarningMessage: ( + message: string, + ...items: string[] + ) => Thenable; + executeCommand: (command: string) => Thenable; + openExternal: (uri: vscode.Uri) => Thenable; + outputChannel: vscode.OutputChannel; + refreshRuntimeState?: () => Promise; +}; + +const START_OLLAMA_ACTION = 'Start Ollama'; +const INSTALL_OLLAMA_ACTION = 'Install Ollama'; +const SELECT_MODEL_ACTION = 'Select Model'; + +const defaultDependencies: ShowOllamaModelsDependencies = { + showInformationMessage: (message: string, ...items: string[]) => + vscode.window.showInformationMessage(message, ...items), + showWarningMessage: (message: string, ...items: string[]) => + vscode.window.showWarningMessage(message, ...items), + executeCommand: (command: string) => vscode.commands.executeCommand(command), + openExternal: (uri: vscode.Uri) => vscode.env.openExternal(uri), + outputChannel: vscode.window.createOutputChannel('Frilingo'), +}; + +// Lists local Ollama models and recommends the configured default when none are installed. +export function createShowOllamaModelsCommand( + ollamaCliService: OllamaCliService, + runtimeService?: Pick, + getConfig: () => FrilingoOllamaConfig = () => OLLAMA_CONFIG, + dependencies: ShowOllamaModelsDependencies = defaultDependencies, +): () => Promise { + return async () => { + const config = getConfig(); + const recommendedModel = config.model || DEFAULT_OLLAMA_MODEL; + const pullRecommendedModelAction = `Pull ${recommendedModel}`; + + try { + const runtimeState = await ( + dependencies.refreshRuntimeState?.() ?? runtimeService?.refreshState() + ); + + if (runtimeState && typeof runtimeState === 'object' && 'status' in runtimeState) { + if (runtimeState.status === 'cli_missing') { + const selection = await dependencies.showWarningMessage( + 'Frilingo: Ollama CLI was not found. Install Ollama to continue.', + INSTALL_OLLAMA_ACTION, + ); + + if (selection === INSTALL_OLLAMA_ACTION) { + await dependencies.openExternal(vscode.Uri.parse('https://ollama.com/download')); + } + + return; + } + + if (runtimeState.status === 'not_running') { + const selection = await dependencies.showWarningMessage( + 'Frilingo: Ollama server is not running. Start it now?', + START_OLLAMA_ACTION, + ); + + if (selection === START_OLLAMA_ACTION) { + await dependencies.executeCommand('frilingo.startOllama'); + } + + return; + } + } + + const result = await ollamaCliService.listModels(); + + appendOllamaModelList( + dependencies.outputChannel, + result.models, + result.rawOutput, + ); + dependencies.outputChannel.show(true); + + if (result.models.length === 0) { + const selection = await dependencies.showWarningMessage( + `Frilingo: No Ollama models found. Pull ${recommendedModel}?`, + pullRecommendedModelAction, + ); + + if (selection === pullRecommendedModelAction) { + await dependencies.executeCommand('frilingo.pullRecommendedModel'); + } + + return; + } + + const selection = await dependencies.showInformationMessage( + `Frilingo: Found ${result.models.length} Ollama model(s). Active model: ${recommendedModel}.`, + SELECT_MODEL_ACTION, + ); + + if (selection === SELECT_MODEL_ACTION) { + await dependencies.executeCommand('frilingo.selectOllamaModel'); + } + } catch (error) { + const errorMessage = String(error); + + appendOllamaCliError(dependencies.outputChannel, 'list', errorMessage); + dependencies.outputChannel.show(true); + await dependencies.showWarningMessage( + 'Frilingo: Failed to read local Ollama models.', + ); + } + }; +} diff --git a/src/features/ollama/commands/startOllama.ts b/src/features/ollama/commands/startOllama.ts new file mode 100644 index 0000000..ab345e8 --- /dev/null +++ b/src/features/ollama/commands/startOllama.ts @@ -0,0 +1,58 @@ +import * as vscode from 'vscode'; + +import { appendOllamaCliError } from '../services/ollamaOutput'; +import { OllamaCliService } from '../services/ollamaCliService'; +import { OllamaRuntimeService } from '../services/ollamaRuntimeService'; + +type StartOllamaDependencies = { + showInformationMessage: (message: string) => Thenable; + showErrorMessage: (message: string) => Thenable; + withProgress: (task: () => Promise) => Thenable; + outputChannel: vscode.OutputChannel; + refreshRuntimeState?: () => Promise; +}; + +const defaultDependencies: StartOllamaDependencies = { + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: (task: () => Promise) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Frilingo: Starting Ollama...', + cancellable: false, + }, + task, + ), + outputChannel: vscode.window.createOutputChannel('Frilingo'), +}; + +// Starts the Ollama server on demand without introducing background polling. +export function createStartOllamaCommand( + ollamaCliService: OllamaCliService, + runtimeService?: Pick, + dependencies: StartOllamaDependencies = defaultDependencies, +): () => Promise { + return async () => { + try { + await dependencies.withProgress(async () => { + await ollamaCliService.startServer(); + // Gives the spawned server a brief window to bind the local port before the next state check. + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + await (dependencies.refreshRuntimeState?.() ?? runtimeService?.refreshState()); + dependencies.outputChannel.appendLine('[Frilingo] Started Ollama server.'); + dependencies.outputChannel.appendLine(''); + dependencies.outputChannel.show(true); + await dependencies.showInformationMessage('Frilingo: Started Ollama server.'); + } catch (error) { + const errorMessage = String(error); + + appendOllamaCliError(dependencies.outputChannel, 'start', errorMessage); + dependencies.outputChannel.show(true); + await dependencies.showErrorMessage('Frilingo: Failed to start Ollama.'); + } + }; +} diff --git a/src/features/ollama/index.ts b/src/features/ollama/index.ts new file mode 100644 index 0000000..00125e3 --- /dev/null +++ b/src/features/ollama/index.ts @@ -0,0 +1,25 @@ +export { createPullRecommendedModelCommand } from './commands/pullRecommendedModel'; +export { createShowOllamaModelsCommand } from './commands/showOllamaModels'; +export { createStartOllamaCommand } from './commands/startOllama'; +export { OllamaCliService, parseOllamaListOutput } from './services/ollamaCliService'; +export { + appendOllamaCliError, + appendOllamaModelList, + appendOllamaPullResult, + extractLatestPullStatus, + stripAnsiControlSequences, +} from './services/ollamaOutput'; +export { + createDefaultRuntimeState, + OllamaRuntimeService, +} from './services/ollamaRuntimeService'; +export { + formatOllamaStatusBarText, + formatOllamaStatusBarTooltip, + updateOllamaStatusBarItem, +} from './services/statusBar'; +export { OllamaServiceError } from './types/ollama'; +export type { + OllamaRuntimeState, + OllamaRuntimeStatus, +} from './services/ollamaRuntimeService'; diff --git a/src/features/ollama/services/ollamaCliService.ts b/src/features/ollama/services/ollamaCliService.ts new file mode 100644 index 0000000..240ef81 --- /dev/null +++ b/src/features/ollama/services/ollamaCliService.ts @@ -0,0 +1,149 @@ +import { spawn } from 'child_process'; + +// Describes the parsed result of an `ollama list` call. +export interface OllamaModelListResult { + models: string[]; + rawOutput: string; +} + +// Parses the standard `ollama list` table output into model names. +export function parseOllamaListOutput(output: string): string[] { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length <= 1) { + return []; + } + + return lines + .slice(1) + .map((line) => line.split(/\s+/)[0]) + .filter((model): model is string => Boolean(model)); +} + +type OllamaCliCommandResult = { + stdout: string; + stderr: string; +}; + +type OllamaCliCommandOptions = { + onOutput?: (chunk: string) => void; +}; + +// Wraps the Ollama CLI so extension code can inspect and manage local models. +export class OllamaCliService { + // Resolves the Ollama binary through the current shell PATH before other CLI actions run. + private resolveCommandPath(): Promise { + return new Promise((resolve, reject) => { + const shell = process.env.SHELL || '/bin/sh'; + const child = spawn(shell, ['-lc', 'command -v ollama']); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + const resolvedPath = stdout.trim(); + + if (code === 0 && resolvedPath) { + resolve(resolvedPath); + return; + } + + reject(new Error(stderr.trim() || 'ollama command was not found in PATH')); + }); + }); + } + + // Runs a plain Ollama CLI command and captures both stdout and stderr. + private runCommand( + args: string[], + options: OllamaCliCommandOptions = {}, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn('ollama', args); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + + stdout += text; + options.onOutput?.(text); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + + stderr += text; + options.onOutput?.(text); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + reject(new Error(stderr.trim() || `ollama exited with code ${String(code)}`)); + }); + }); + } + + // Reads the locally installed Ollama models from the CLI table output. + public async listModels(): Promise { + const result = await this.runCommand(['list']); + + return { + models: parseOllamaListOutput(result.stdout), + rawOutput: result.stdout.trim(), + }; + } + + // Verifies that the Ollama CLI binary is available in the current environment. + public async getVersion(): Promise { + await this.resolveCommandPath(); + + const result = await this.runCommand(['--version']); + + return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n'); + } + + // Starts the Ollama server process only when the user explicitly asks for it. + public async startServer(): Promise { + await this.resolveCommandPath(); + + const child = spawn('ollama', ['serve'], { + detached: true, + stdio: 'ignore', + }); + + child.unref(); + } + + // Pulls one model through the Ollama CLI and returns raw CLI output for logging. + public async pullModel( + model: string, + options: OllamaCliCommandOptions = {}, + ): Promise { + const result = await this.runCommand(['pull', model], options); + + return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n'); + } +} diff --git a/src/features/ollama/services/ollamaOutput.ts b/src/features/ollama/services/ollamaOutput.ts new file mode 100644 index 0000000..dcc4658 --- /dev/null +++ b/src/features/ollama/services/ollamaOutput.ts @@ -0,0 +1,71 @@ +import * as vscode from 'vscode'; + +const ANSI_ESCAPE_PATTERN = + // Removes terminal control sequences from raw Ollama CLI output before showing it in VSCode UI. + /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; + +// Strips terminal escape sequences so CLI progress can be shown in VSCode-friendly UI text. +export function stripAnsiControlSequences(text: string): string { + return text.replace(ANSI_ESCAPE_PATTERN, ''); +} + +// Extracts the latest readable pull status line from a raw CLI chunk. +export function extractLatestPullStatus(chunk: string): string | undefined { + const cleaned = stripAnsiControlSequences(chunk); + const lines = cleaned + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + return lines.at(-1); +} + +// Appends the current local Ollama model list to the shared output channel. +export function appendOllamaModelList( + outputChannel: vscode.OutputChannel, + models: string[], + rawOutput: string, +): void { + outputChannel.appendLine('[Frilingo] Ollama model inventory'); + + if (models.length === 0) { + outputChannel.appendLine('[Frilingo] No local Ollama models were found.'); + } else { + models.forEach((model) => { + outputChannel.appendLine(`[Frilingo] Model: ${model}`); + }); + } + + if (rawOutput) { + outputChannel.appendLine('[Frilingo] Raw `ollama list` output:'); + outputChannel.appendLine(rawOutput); + } + + outputChannel.appendLine(''); +} + +// Appends the result of a pull command so slow downloads stay visible after the notification closes. +export function appendOllamaPullResult( + outputChannel: vscode.OutputChannel, + model: string, + output: string, +): void { + outputChannel.appendLine(`[Frilingo] Pulled model: ${model}`); + + if (output) { + outputChannel.appendLine(output); + } + + outputChannel.appendLine(''); +} + +// Appends CLI-level failures that happen outside the translation request path. +export function appendOllamaCliError( + outputChannel: vscode.OutputChannel, + action: string, + errorMessage: string, +): void { + outputChannel.appendLine(`[Frilingo] Ollama CLI action failed: ${action}`); + outputChannel.appendLine(`[Frilingo] Error: ${errorMessage}`); + outputChannel.appendLine(''); +} diff --git a/src/features/ollama/services/ollamaRuntimeService.ts b/src/features/ollama/services/ollamaRuntimeService.ts new file mode 100644 index 0000000..663d44b --- /dev/null +++ b/src/features/ollama/services/ollamaRuntimeService.ts @@ -0,0 +1,122 @@ +import { ListResponse, Ollama, VersionResponse } from 'ollama'; + +import { FrilingoOllamaConfig, OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { OllamaCliService } from './ollamaCliService'; + +export type OllamaRuntimeStatus = + | 'checking' + | 'cli_missing' + | 'not_running' + | 'no_model' + | 'ready'; + +export interface OllamaRuntimeState { + status: OllamaRuntimeStatus; + models: string[]; + configuredModel: string; + cliVersion?: string; + serverVersion?: string; + errorMessage?: string; +} + +export function createDefaultRuntimeState( + configuredModel: string = OLLAMA_CONFIG.model, +): OllamaRuntimeState { + return { + status: 'checking', + models: [], + configuredModel, + }; +} + +type OllamaRuntimeClient = Pick; + +// Resolves the current Ollama readiness so UI entry points can react before failing. +export class OllamaRuntimeService { + private state: OllamaRuntimeState = createDefaultRuntimeState(); + private readonly listeners = new Set<(state: OllamaRuntimeState) => void>(); + + public constructor( + private readonly cliService: Pick, + private readonly client: OllamaRuntimeClient = new Ollama({ host: OLLAMA_CONFIG.host }), + private readonly getConfig: () => FrilingoOllamaConfig = () => OLLAMA_CONFIG, + ) {} + + // Returns the latest cached runtime state without performing network or CLI work. + public getState(): OllamaRuntimeState { + return this.state; + } + + // Subscribes to runtime state changes so UI components can stay in sync. + public onDidChangeState( + listener: (state: OllamaRuntimeState) => void, + ): { dispose: () => void } { + this.listeners.add(listener); + + return { + dispose: () => { + this.listeners.delete(listener); + }, + }; + } + + // Broadcasts the latest cached state to any UI listeners. + private emitState(): void { + this.listeners.forEach((listener) => listener(this.state)); + } + + // Refreshes the runtime state by checking the CLI, server, and configured model. + public async refreshState(): Promise { + const config = this.getConfig(); + let cliVersion: string; + + try { + cliVersion = await this.cliService.getVersion(); + } catch (error) { + this.state = { + status: 'cli_missing', + models: [], + configuredModel: config.model, + errorMessage: String(error), + }; + this.emitState(); + + return this.state; + } + + let serverVersion: VersionResponse; + let listResponse: ListResponse; + + try { + [serverVersion, listResponse] = await Promise.all([ + this.client.version(), + this.client.list(), + ]); + } catch (error) { + this.state = { + status: 'not_running', + models: [], + configuredModel: config.model, + cliVersion, + errorMessage: String(error), + }; + this.emitState(); + + return this.state; + } + + const models = listResponse.models.map((model) => model.name); + const status = models.includes(config.model) ? 'ready' : 'no_model'; + + this.state = { + status, + models, + configuredModel: config.model, + cliVersion, + serverVersion: String(serverVersion.version), + }; + this.emitState(); + + return this.state; + } +} diff --git a/src/features/ollama/services/statusBar.ts b/src/features/ollama/services/statusBar.ts new file mode 100644 index 0000000..473565c --- /dev/null +++ b/src/features/ollama/services/statusBar.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; + +import { OllamaRuntimeState } from './ollamaRuntimeService'; + +export const FRILINGO_STATUS_BAR_COMMAND = 'frilingo.showOllamaModels'; + +// Maps the current Ollama runtime state to one compact status bar label. +export function formatOllamaStatusBarText(state: OllamaRuntimeState): string { + switch (state.status) { + case 'checking': + return '$(sync~spin) Frilingo: Checking Ollama'; + case 'cli_missing': + return '$(warning) Frilingo: Ollama CLI Missing'; + case 'not_running': + return '$(circle-slash) Frilingo: Ollama Offline'; + case 'no_model': + return `$(package) Frilingo: Pull ${state.configuredModel}`; + case 'ready': + return `$(check) Frilingo: ${state.configuredModel} Ready`; + default: + return '$(question) Frilingo: Ollama Unknown'; + } +} + +// Maps the current Ollama runtime state to a tooltip with the next suggested action. +export function formatOllamaStatusBarTooltip(state: OllamaRuntimeState): string { + switch (state.status) { + case 'checking': + return 'Frilingo is checking the local Ollama runtime.'; + case 'cli_missing': + return 'Ollama CLI is not available. Click to inspect local Ollama status.'; + case 'not_running': + return 'Ollama server is not running. Click to inspect local models and runtime state.'; + case 'no_model': + return `Configured model ${state.configuredModel} is missing. Click to inspect or pull models.`; + case 'ready': + return `Ollama is ready with ${state.configuredModel}. Click to inspect local models.`; + default: + return 'Click to inspect the local Ollama runtime.'; + } +} + +// Updates the shared status bar item whenever the Ollama runtime state changes. +export function updateOllamaStatusBarItem( + statusBarItem: vscode.StatusBarItem, + state: OllamaRuntimeState, +): void { + statusBarItem.text = formatOllamaStatusBarText(state); + statusBarItem.tooltip = formatOllamaStatusBarTooltip(state); + statusBarItem.command = FRILINGO_STATUS_BAR_COMMAND; + statusBarItem.show(); +} diff --git a/src/features/ollama/tests/ollamaCliService.test.ts b/src/features/ollama/tests/ollamaCliService.test.ts new file mode 100644 index 0000000..990da8f --- /dev/null +++ b/src/features/ollama/tests/ollamaCliService.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { parseOllamaListOutput } from '../services/ollamaCliService'; + +suite('Ollama CLI Service', () => { + test('parses model names from standard ollama list output', () => { + // Expected: each data row contributes its first column as the model name. + const models = parseOllamaListOutput( + [ + 'NAME ID SIZE MODIFIED', + 'qwen3:4b abc123 2.6 GB 2 minutes ago', + 'exaone:latest def456 7.1 GB 1 hour ago', + ].join('\n'), + ); + + assert.deepStrictEqual(models, ['qwen3:4b', 'exaone:latest']); + }); + + test('returns an empty list when ollama list has only the header', () => { + // Expected: a header-only table means no local models are installed. + const models = parseOllamaListOutput('NAME ID SIZE MODIFIED'); + + assert.deepStrictEqual(models, []); + }); +}); diff --git a/src/features/ollama/tests/ollamaOutput.test.ts b/src/features/ollama/tests/ollamaOutput.test.ts new file mode 100644 index 0000000..7094537 --- /dev/null +++ b/src/features/ollama/tests/ollamaOutput.test.ts @@ -0,0 +1,34 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { + extractLatestPullStatus, + stripAnsiControlSequences, +} from '../services/ollamaOutput'; + +suite('Ollama Output', () => { + test('strips ansi control sequences from raw pull output', () => { + // Expected: terminal escape sequences are removed before UI text is shown inside VSCode. + const cleaned = stripAnsiControlSequences( + '\u001b[?25lpulling manifest \u001b[K', + ); + + assert.strictEqual(cleaned, 'pulling manifest '); + }); + + test('extracts the latest readable pull status from a raw chunk', () => { + // Expected: noisy multi-line pull chunks collapse into one final progress line for notification updates. + const latestStatus = extractLatestPullStatus( + [ + '\u001b[1Gpulling manifest \u001b[K', + 'pulling aeda25e63ebd: 11% ▕█ ▏ 364 MB/3.3 GB 32 MB/s 1m31s\u001b[K', + ].join('\n'), + ); + + assert.strictEqual( + latestStatus, + 'pulling aeda25e63ebd: 11% ▕█ ▏ 364 MB/3.3 GB 32 MB/s 1m31s', + ); + }); +}); diff --git a/src/features/ollama/tests/ollamaRuntimeService.test.ts b/src/features/ollama/tests/ollamaRuntimeService.test.ts new file mode 100644 index 0000000..14b0742 --- /dev/null +++ b/src/features/ollama/tests/ollamaRuntimeService.test.ts @@ -0,0 +1,98 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { + createDefaultRuntimeState, + OllamaRuntimeService, +} from '../services/ollamaRuntimeService'; + +suite('Ollama Runtime Service', () => { + test('starts in checking state before the first refresh', () => { + // Expected: the runtime state is unknown until the first environment check runs. + assert.deepStrictEqual(createDefaultRuntimeState(), { + status: 'checking', + models: [], + configuredModel: 'gemma3:4b', + }); + }); + + test('returns cli_missing when the ollama binary is unavailable', async () => { + // Expected: CLI lookup failure prevents the extension from exposing translation actions. + const service = new OllamaRuntimeService( + { + getVersion: async () => { + throw new Error('spawn ollama ENOENT'); + }, + }, + {} as never, + ); + + const state = await service.refreshState(); + + assert.strictEqual(state.status, 'cli_missing'); + assert.strictEqual(state.configuredModel, 'gemma3:4b'); + }); + + test('returns not_running when the server version check fails', async () => { + // Expected: a reachable CLI with an unreachable server is treated as not running. + const service = new OllamaRuntimeService( + { + getVersion: async () => 'ollama version 0.1.0', + }, + { + version: async () => { + throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); + }, + list: async () => ({ models: [] }), + } as never, + ); + + const state = await service.refreshState(); + + assert.strictEqual(state.status, 'not_running'); + assert.strictEqual(state.configuredModel, 'gemma3:4b'); + }); + + test('returns no_model when the configured model is not installed', async () => { + // Expected: the extension can suggest a pull action when the server is ready but the model is missing. + const service = new OllamaRuntimeService( + { + getVersion: async () => 'ollama version 0.1.0', + }, + { + version: async () => ({ version: '0.1.0' }), + list: async () => ({ + models: [{ name: 'exaone:latest' }], + }), + } as never, + ); + + const state = await service.refreshState(); + + assert.strictEqual(state.status, 'no_model'); + assert.deepStrictEqual(state.models, ['exaone:latest']); + assert.strictEqual(state.configuredModel, 'gemma3:4b'); + }); + + test('returns ready when the configured model is installed', async () => { + // Expected: hover translation becomes available only after the target model is present. + const service = new OllamaRuntimeService( + { + getVersion: async () => 'ollama version 0.1.0', + }, + { + version: async () => ({ version: '0.1.0' }), + list: async () => ({ + models: [{ name: 'gemma3:4b' }], + }), + } as never, + ); + + const state = await service.refreshState(); + + assert.strictEqual(state.status, 'ready'); + assert.deepStrictEqual(state.models, ['gemma3:4b']); + assert.strictEqual(state.configuredModel, 'gemma3:4b'); + }); +}); diff --git a/src/features/ollama/tests/statusBar.test.ts b/src/features/ollama/tests/statusBar.test.ts new file mode 100644 index 0000000..2bb5649 --- /dev/null +++ b/src/features/ollama/tests/statusBar.test.ts @@ -0,0 +1,34 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { + formatOllamaStatusBarText, + formatOllamaStatusBarTooltip, +} from '../services/statusBar'; + +suite('Status Bar', () => { + test('formats the ready state with the configured model name', () => { + // Expected: the ready label clearly shows the configured model for quick confirmation. + assert.strictEqual( + formatOllamaStatusBarText({ + status: 'ready', + models: ['gemma3:4b'], + configuredModel: 'gemma3:4b', + }), + '$(check) Frilingo: gemma3:4b Ready', + ); + }); + + test('formats the no_model tooltip with the next suggested action', () => { + // Expected: the tooltip points the user toward pulling the configured model. + assert.strictEqual( + formatOllamaStatusBarTooltip({ + status: 'no_model', + models: [], + configuredModel: 'gemma3:4b', + }), + 'Configured model gemma3:4b is missing. Click to inspect or pull models.', + ); + }); +}); diff --git a/src/features/ollama/types/ollama.ts b/src/features/ollama/types/ollama.ts new file mode 100644 index 0000000..4eef003 --- /dev/null +++ b/src/features/ollama/types/ollama.ts @@ -0,0 +1,13 @@ +export type OllamaServiceErrorCode = + | 'connection_failed' + | 'request_failed' + | 'invalid_response'; + +export class OllamaServiceError extends Error { + public readonly code: OllamaServiceErrorCode; + + public constructor(code: OllamaServiceErrorCode, message: string) { + super(message); + this.code = code; + } +} diff --git a/src/features/settings/commands/selectOllamaModel.ts b/src/features/settings/commands/selectOllamaModel.ts new file mode 100644 index 0000000..9c16235 --- /dev/null +++ b/src/features/settings/commands/selectOllamaModel.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; + +import { DEFAULT_OLLAMA_MODEL } from '../../../shared/config/ollama'; +import { OllamaCliService } from '../../ollama/services/ollamaCliService'; +import { OllamaRuntimeService } from '../../ollama/services/ollamaRuntimeService'; + +type ModelQuickPickItem = vscode.QuickPickItem & { + action: 'select' | 'pull'; + model?: string; +}; + +type SelectOllamaModelDependencies = { + showQuickPick: ( + items: readonly ModelQuickPickItem[], + options: vscode.QuickPickOptions, + ) => Thenable; + showInformationMessage: (message: string) => Thenable; + showWarningMessage: (message: string) => Thenable; + executeCommand: (command: string) => Thenable; + updateConfiguration: (model: string) => Thenable; + refreshRuntimeState?: () => Promise; +}; + +const defaultDependencies: SelectOllamaModelDependencies = { + showQuickPick: (items, options) => vscode.window.showQuickPick(items, options), + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showWarningMessage: (message: string) => vscode.window.showWarningMessage(message), + executeCommand: (command: string) => vscode.commands.executeCommand(command), + updateConfiguration: (model: string) => + vscode.workspace + .getConfiguration('frilingo') + .update('ollamaModel', model, vscode.ConfigurationTarget.Global), +}; + +// Lets the user switch the active Ollama model without editing source files. +export function createSelectOllamaModelCommand( + ollamaCliService: Pick, + runtimeService?: Pick, + dependencies: SelectOllamaModelDependencies = defaultDependencies, +): () => Promise { + return async () => { + const result = await ollamaCliService.listModels(); + const state = runtimeService?.getState(); + const installedModels = result.models; + const items: ModelQuickPickItem[] = installedModels.map((model) => ({ + label: model, + description: + model === state?.configuredModel ? 'Current configured model' : undefined, + action: 'select', + model, + })); + + if (!installedModels.includes(DEFAULT_OLLAMA_MODEL)) { + items.unshift({ + label: `Pull ${DEFAULT_OLLAMA_MODEL}`, + description: 'Recommended model for Frilingo v0.1', + action: 'pull', + model: DEFAULT_OLLAMA_MODEL, + }); + } + + if (items.length === 0) { + await dependencies.showWarningMessage( + 'Frilingo: No Ollama models found. Pull the recommended model first.', + ); + await dependencies.executeCommand('frilingo.pullRecommendedModel'); + return; + } + + const selection = await dependencies.showQuickPick(items, { + title: 'Select an Ollama model for Frilingo', + placeHolder: 'Choose an installed model or pull the recommended one', + ignoreFocusOut: true, + }); + + if (!selection) { + return; + } + + if (selection.action === 'pull') { + await dependencies.executeCommand('frilingo.pullRecommendedModel'); + return; + } + + if (!selection.model) { + return; + } + + await dependencies.updateConfiguration(selection.model); + await (dependencies.refreshRuntimeState?.() ?? runtimeService?.refreshState()); + await dependencies.showInformationMessage( + `Frilingo: Using Ollama model ${selection.model}.`, + ); + }; +} diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..6b1251e --- /dev/null +++ b/src/features/settings/index.ts @@ -0,0 +1 @@ +export { createSelectOllamaModelCommand } from './commands/selectOllamaModel'; diff --git a/src/features/translation/commands/translateSelection.ts b/src/features/translation/commands/translateSelection.ts new file mode 100644 index 0000000..57212f4 --- /dev/null +++ b/src/features/translation/commands/translateSelection.ts @@ -0,0 +1,102 @@ +import * as vscode from 'vscode'; + +import { FRILINGO_MESSAGES } from '../../../shared/constants/messages'; +import { + appendTranslationError, + appendTranslationResult, +} from '../services/outputChannel'; +import { OllamaService } from '../services/ollamaService'; +import { runTranslateSelection } from '../services/translateSelectionCore'; + +type TranslateSelectionCommandDependencies = { + getActiveTextEditor: () => vscode.TextEditor | undefined; + getActiveModelName: () => string; + showInformationMessage: (message: string) => Thenable; + showErrorMessage: (message: string) => Thenable; + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => Thenable; + outputChannel: vscode.OutputChannel; +}; + +const defaultDependencies: TranslateSelectionCommandDependencies = { + getActiveTextEditor: () => vscode.window.activeTextEditor, + getActiveModelName: () => 'Ollama', + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + (_progress, cancellationToken) => task(cancellationToken), + ), + outputChannel: vscode.window.createOutputChannel('Frilingo'), +}; + +// Adapts the active editor selection into the shared translation workflow. +export function createTranslateSelectionCommand( + ollamaService: OllamaService, + dependencies: TranslateSelectionCommandDependencies = defaultDependencies, +): () => Promise { + return async () => { + const editor = dependencies.getActiveTextEditor(); + + if (!editor) { + await dependencies.showErrorMessage(FRILINGO_MESSAGES.noActiveEditor); + return; + } + + const modelName = dependencies.getActiveModelName(); + + await dependencies.withProgress( + `Frilingo: Translating with ${modelName}...`, + async (cancellationToken) => { + let cancelled = false; + const cancellationPromise = new Promise((resolve) => { + cancellationToken.onCancellationRequested(() => { + cancelled = true; + resolve(); + }); + }); + const translationPromise = runTranslateSelection({ + selectedText: editor.document.getText(editor.selection), + translateToKorean: (text: string) => ollamaService.translateToKorean(text), + isCancelled: () => cancelled, + showInformationMessage: dependencies.showInformationMessage, + showErrorMessage: dependencies.showErrorMessage, + onTranslationResult: (sourceText, result) => { + appendTranslationResult(dependencies.outputChannel, sourceText, result); + dependencies.outputChannel.show(true); + }, + onTranslationError: (sourceText, errorMessage) => { + appendTranslationError(dependencies.outputChannel, sourceText, errorMessage); + dependencies.outputChannel.show(true); + }, + }).catch((error) => { + if (cancelled) { + return; + } + + throw error; + }); + + await Promise.race([translationPromise, cancellationPromise]); + + if (cancelled) { + await dependencies.showInformationMessage(FRILINGO_MESSAGES.translationCancelled); + return; + } + + await translationPromise; + }, + ); + }; +} diff --git a/src/features/translation/commands/translateText.ts b/src/features/translation/commands/translateText.ts new file mode 100644 index 0000000..5dd7ef9 --- /dev/null +++ b/src/features/translation/commands/translateText.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; + +import { FRILINGO_MESSAGES } from '../../../shared/constants/messages'; +import { + appendTranslationError, + appendTranslationResult, +} from '../services/outputChannel'; +import { OllamaService } from '../services/ollamaService'; +import { runTranslateSelection } from '../services/translateSelectionCore'; + +type TranslateTextCommandDependencies = { + getActiveModelName: () => string; + showInformationMessage: (message: string) => Thenable; + showErrorMessage: (message: string) => Thenable; + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => Thenable; + outputChannel: vscode.OutputChannel; +}; + +const defaultDependencies: TranslateTextCommandDependencies = { + getActiveModelName: () => 'Ollama', + showInformationMessage: (message: string) => + vscode.window.showInformationMessage(message), + showErrorMessage: (message: string) => vscode.window.showErrorMessage(message), + withProgress: ( + title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + (_progress, cancellationToken) => task(cancellationToken), + ), + outputChannel: vscode.window.createOutputChannel('Frilingo'), +}; + +// Adapts hover-provided text into the shared translation workflow. +export function createTranslateTextCommand( + ollamaService: OllamaService, + dependencies: TranslateTextCommandDependencies = defaultDependencies, +): (text: string | undefined) => Promise { + return async (text: string | undefined) => { + const modelName = dependencies.getActiveModelName(); + + await dependencies.withProgress( + `Frilingo: Translating with ${modelName}...`, + async (cancellationToken) => { + let cancelled = false; + const cancellationPromise = new Promise((resolve) => { + cancellationToken.onCancellationRequested(() => { + cancelled = true; + resolve(); + }); + }); + const translationPromise = runTranslateSelection({ + selectedText: text, + translateToKorean: (value: string) => ollamaService.translateToKorean(value), + isCancelled: () => cancelled, + showInformationMessage: dependencies.showInformationMessage, + showErrorMessage: dependencies.showErrorMessage, + onTranslationResult: (sourceText, result) => { + appendTranslationResult(dependencies.outputChannel, sourceText, result); + dependencies.outputChannel.show(true); + }, + onTranslationError: (sourceText, errorMessage) => { + appendTranslationError(dependencies.outputChannel, sourceText, errorMessage); + dependencies.outputChannel.show(true); + }, + }).catch((error) => { + if (cancelled) { + return; + } + + throw error; + }); + + await Promise.race([translationPromise, cancellationPromise]); + + if (cancelled) { + await dependencies.showInformationMessage(FRILINGO_MESSAGES.translationCancelled); + return; + } + + await translationPromise; + }, + ); + }; +} diff --git a/src/features/translation/index.ts b/src/features/translation/index.ts new file mode 100644 index 0000000..36af9eb --- /dev/null +++ b/src/features/translation/index.ts @@ -0,0 +1,9 @@ +export { createTranslateSelectionCommand } from './commands/translateSelection'; +export { createTranslateTextCommand } from './commands/translateText'; +export { TRANSLATION_PROMPT_TEMPLATE } from './prompts/translationPrompt'; +export { extractTranslatableHoverText } from './services/hoverText'; +export { OllamaService } from './services/ollamaService'; +export { FRILINGO_OUTPUT_CHANNEL_NAME } from './services/outputChannel'; +export { createTranslateHoverProvider } from './services/translateHoverProvider'; +export { runTranslateSelection } from './services/translateSelectionCore'; +export type { TranslationResult } from './types/translation'; diff --git a/src/features/translation/prompts/translationPrompt.ts b/src/features/translation/prompts/translationPrompt.ts new file mode 100644 index 0000000..c709603 --- /dev/null +++ b/src/features/translation/prompts/translationPrompt.ts @@ -0,0 +1,24 @@ +// Stores the shared translation prompt in one runtime-safe module. +export const TRANSLATION_PROMPT_TEMPLATE = ` +Translate the text inside into {{targetLanguage}}. + +Output only the translated text. + +Rules: +- Do not explain. +- Do not analyze. +- Do not provide reasoning. +- Do not provide steps. +- Do not provide notes. +- Do not repeat the source text. +- Preserve Markdown. +- Preserve code blocks exactly. +- Preserve URLs. +- Preserve file paths. +- Preserve placeholders such as {{variable}}. +- Preserve line breaks. + + +{{selectedText}} + +`.trim(); diff --git a/src/features/translation/services/hoverText.ts b/src/features/translation/services/hoverText.ts new file mode 100644 index 0000000..f8e7a99 --- /dev/null +++ b/src/features/translation/services/hoverText.ts @@ -0,0 +1,130 @@ +export interface HoverTextPosition { + line: number; + character: number; +} + +export interface HoverTextRange { + start: HoverTextPosition; + end: HoverTextPosition; +} + +export interface HoverTextDiagnostic { + message: string; + range: HoverTextRange; +} + +export interface HoverTextDocument { + lineAt(line: number): { text: string }; +} + +function isPositionWithinRange(position: HoverTextPosition, range: HoverTextRange): boolean { + if (position.line < range.start.line || position.line > range.end.line) { + return false; + } + + if (position.line === range.start.line && position.character < range.start.character) { + return false; + } + + if (position.line === range.end.line && position.character > range.end.character) { + return false; + } + + return true; +} + +function extractCommentText(lineText: string, positionCharacter: number): string | undefined { + const commentPrefixes = ['//', '#']; + + for (const prefix of commentPrefixes) { + const commentIndex = lineText.indexOf(prefix); + if (commentIndex >= 0 && positionCharacter >= commentIndex) { + return lineText.slice(commentIndex + prefix.length).trim(); + } + } + + return undefined; +} + +function extractBlockCommentText( + lineText: string, + positionCharacter: number, +): string | undefined { + const startIndex = lineText.indexOf('/*'); + + if (startIndex < 0 || positionCharacter < startIndex) { + return undefined; + } + + const endIndex = lineText.indexOf('*/', startIndex + 2); + const commentEnd = endIndex >= 0 ? endIndex : lineText.length; + + if (positionCharacter > commentEnd) { + return undefined; + } + + return lineText + .slice(startIndex + 2, commentEnd) + .replace(/^\*+/, '') + .trim(); +} + +function extractStringLiteralText( + lineText: string, + positionCharacter: number, +): string | undefined { + const quoteCharacters = [`'`, `"`, '`']; + + for (const quoteCharacter of quoteCharacters) { + const startIndex = lineText.lastIndexOf(quoteCharacter, positionCharacter); + + if (startIndex < 0) { + continue; + } + + const endIndex = lineText.indexOf(quoteCharacter, startIndex + 1); + + if (endIndex < 0 || positionCharacter > endIndex) { + continue; + } + + return lineText.slice(startIndex + 1, endIndex).trim(); + } + + return undefined; +} + +export function extractTranslatableHoverText( + document: HoverTextDocument, + position: HoverTextPosition, + diagnostics: readonly HoverTextDiagnostic[], +): string | undefined { + const diagnosticMessage = diagnostics.find((diagnostic) => + isPositionWithinRange(position, diagnostic.range), + )?.message.trim(); + + if (diagnosticMessage) { + return diagnosticMessage; + } + + const lineText = document.lineAt(position.line).text; + const blockCommentText = extractBlockCommentText(lineText, position.character); + + if (blockCommentText) { + return blockCommentText; + } + + const commentText = extractCommentText(lineText, position.character); + + if (commentText) { + return commentText; + } + + const stringLiteralText = extractStringLiteralText(lineText, position.character); + + if (stringLiteralText) { + return stringLiteralText; + } + + return undefined; +} diff --git a/src/features/translation/services/ollamaService.ts b/src/features/translation/services/ollamaService.ts new file mode 100644 index 0000000..10f0a65 --- /dev/null +++ b/src/features/translation/services/ollamaService.ts @@ -0,0 +1,150 @@ +import { GenerateRequest, Ollama } from 'ollama'; + +import { FrilingoOllamaConfig, OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { TranslationResult } from '../types/translation'; +import { OllamaServiceError } from '../../ollama/types/ollama'; + +// Builds the primary generate prompt with both the instruction and selected text. +export function buildTranslationPrompt( + selectedText: string, + targetLanguage: string, +): string { + return OLLAMA_CONFIG.promptTemplate + .replace('{{targetLanguage}}', targetLanguage) + .replace('{{selectedText}}', selectedText); +} + +// Builds a shorter fallback generate prompt for models that ignore the primary instruction. +export function buildFallbackTranslationPrompt( + selectedText: string, + targetLanguage: string, +): string { + return [ + `Translate to ${targetLanguage}.`, + 'Output only translated text.', + 'Do not explain.', + 'Do not analyze.', + 'Do not reason.', + '', + selectedText, + ].join('\n'); +} + +// Removes common reasoning / wrapper text produced by local models. +function cleanTranslationText(text: string): string { + return text + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/^Translation:\s*/i, '') + .replace(/^Translated text:\s*/i, '') + .trim(); +} + +export class OllamaService { + private readonly client: Pick; + private readonly getConfig: () => FrilingoOllamaConfig; + + // Accepts a minimal Ollama client so tests can inject a stable fake implementation. + public constructor( + client?: Pick, + getConfig: () => FrilingoOllamaConfig = () => OLLAMA_CONFIG, + ) { + this.getConfig = getConfig; + this.client = client ?? new Ollama({ host: this.getConfig().host }); + } + + // Extracts and cleans the model output before returning it to the extension. + private extractTranslationText(response: { + response?: string; + }): string | undefined { + const translatedText = response.response; + + if (!translatedText) { + return undefined; + } + + const cleaned = cleanTranslationText(translatedText); + + if (!cleaned) { + return undefined; + } + + return cleaned; + } + + // Creates the shared generate request body so both primary and fallback prompts reuse the same options. + private buildGenerateRequest( + prompt: string, + ): GenerateRequest & { stream: false } { + const config = this.getConfig(); + + return { + model: config.model, + prompt, + think: config.generate.think, + keep_alive: config.generate.keep_alive, + options: config.generate.options, + stream: false, + }; + } + + // Sends a single non-streaming generate translation request to the configured Ollama model. + public async translateToKorean(text: string): Promise { + const config = this.getConfig(); + + try { + const primaryResponse = await this.client.generate( + this.buildGenerateRequest( + buildTranslationPrompt(text, config.targetLanguage), + ), + ); + + const primaryTranslation = this.extractTranslationText(primaryResponse); + + if (primaryTranslation) { + return { + model: config.model, + text: primaryTranslation, + totalDurationNs: primaryResponse.total_duration, + loadDurationNs: primaryResponse.load_duration, + promptEvalCount: primaryResponse.prompt_eval_count, + evalCount: primaryResponse.eval_count, + usedFallbackPrompt: false, + }; + } + + const fallbackResponse = await this.client.generate( + this.buildGenerateRequest( + buildFallbackTranslationPrompt(text, config.targetLanguage), + ), + ); + + const fallbackTranslation = this.extractTranslationText(fallbackResponse); + + if (!fallbackTranslation) { + throw new OllamaServiceError( + 'invalid_response', + 'Ollama returned an invalid response.', + ); + } + + return { + model: config.model, + text: fallbackTranslation, + totalDurationNs: fallbackResponse.total_duration, + loadDurationNs: fallbackResponse.load_duration, + promptEvalCount: fallbackResponse.prompt_eval_count, + evalCount: fallbackResponse.eval_count, + usedFallbackPrompt: true, + }; + } catch (error) { + if (error instanceof OllamaServiceError) { + throw error; + } + + throw new OllamaServiceError( + 'connection_failed', + `Failed to connect to Ollama: ${String(error)}`, + ); + } + } +} diff --git a/src/features/translation/services/outputChannel.ts b/src/features/translation/services/outputChannel.ts new file mode 100644 index 0000000..872d7f5 --- /dev/null +++ b/src/features/translation/services/outputChannel.ts @@ -0,0 +1,55 @@ +import * as vscode from 'vscode'; + +import { TranslationResult } from '../types/translation'; + +export const FRILINGO_OUTPUT_CHANNEL_NAME = 'Frilingo'; + +function formatDurationNs(durationNs: number | undefined): string { + if (!durationNs || durationNs <= 0) { + return 'n/a'; + } + + return `${(durationNs / 1_000_000_000).toFixed(2)}s`; +} + +// Appends a compact translation summary so slow model loads are visible to the user. +export function appendTranslationResult( + outputChannel: vscode.OutputChannel, + sourceText: string, + result: TranslationResult, +): void { + outputChannel.appendLine(`[Frilingo] Model: ${result.model}`); + outputChannel.appendLine(`[Frilingo] Source: ${sourceText}`); + outputChannel.appendLine(`[Frilingo] Translation: ${result.text}`); + outputChannel.appendLine( + `[Frilingo] Load duration: ${formatDurationNs(result.loadDurationNs)}`, + ); + outputChannel.appendLine( + `[Frilingo] Total duration: ${formatDurationNs(result.totalDurationNs)}`, + ); + + if (typeof result.promptEvalCount === 'number') { + outputChannel.appendLine(`[Frilingo] Prompt tokens: ${result.promptEvalCount}`); + } + + if (typeof result.evalCount === 'number') { + outputChannel.appendLine(`[Frilingo] Output tokens: ${result.evalCount}`); + } + + if (result.usedFallbackPrompt) { + outputChannel.appendLine('[Frilingo] Fallback prompt: used'); + } + + outputChannel.appendLine(''); +} + +// Appends a single failure line so API errors stay visible after notifications disappear. +export function appendTranslationError( + outputChannel: vscode.OutputChannel, + sourceText: string, + errorMessage: string, +): void { + outputChannel.appendLine(`[Frilingo] Source: ${sourceText}`); + outputChannel.appendLine(`[Frilingo] Error: ${errorMessage}`); + outputChannel.appendLine(''); +} diff --git a/src/features/translation/services/translateHoverProvider.ts b/src/features/translation/services/translateHoverProvider.ts new file mode 100644 index 0000000..3fb912c --- /dev/null +++ b/src/features/translation/services/translateHoverProvider.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; + +import { OllamaRuntimeService } from '../../ollama/services/ollamaRuntimeService'; +import { extractTranslatableHoverText } from './hoverText'; + +function createCommandHover(markdownText: string): vscode.Hover { + const markdown = new vscode.MarkdownString(markdownText); + + markdown.isTrusted = true; + + return new vscode.Hover(markdown); +} + +// Shows translation actions only when Ollama is actually ready for the configured model. +export function createTranslateHoverProvider( + runtimeService: Pick, +): vscode.HoverProvider { + return { + async provideHover(document, position) { + const diagnostics = vscode.languages.getDiagnostics(document.uri).map((diagnostic) => ({ + message: diagnostic.message, + range: { + start: { + line: diagnostic.range.start.line, + character: diagnostic.range.start.character, + }, + end: { + line: diagnostic.range.end.line, + character: diagnostic.range.end.character, + }, + }, + })); + const hoverText = extractTranslatableHoverText(document, position, diagnostics); + + if (!hoverText) { + return undefined; + } + + const cachedState = runtimeService.getState(); + const runtimeState = + cachedState.status === 'checking' + ? await runtimeService.refreshState() + : cachedState; + + if (runtimeState.status === 'cli_missing') { + return createCommandHover( + '[Frilingo: Ollama CLI is not available. Show install guidance](command:frilingo.showOllamaModels)', + ); + } + + if (runtimeState.status === 'not_running') { + return createCommandHover( + [ + '[Start Ollama with Frilingo](command:frilingo.startOllama)', + '', + '[Show local Ollama status](command:frilingo.showOllamaModels)', + ].join('\n'), + ); + } + + if (runtimeState.status === 'no_model') { + return createCommandHover( + [ + `[Pull ${runtimeState.configuredModel} with Frilingo](command:frilingo.pullRecommendedModel)`, + '', + '[Show local Ollama models](command:frilingo.showOllamaModels)', + ].join('\n'), + ); + } + + if (runtimeState.status !== 'ready') { + return undefined; + } + + return createCommandHover( + `[Translate with Frilingo](command:frilingo.translateText?${encodeURIComponent( + JSON.stringify([hoverText]), + )})`, + ); + }, + }; +} diff --git a/src/features/translation/services/translateSelectionCore.ts b/src/features/translation/services/translateSelectionCore.ts new file mode 100644 index 0000000..126bcad --- /dev/null +++ b/src/features/translation/services/translateSelectionCore.ts @@ -0,0 +1,67 @@ +import { FRILINGO_MESSAGES } from '../../../shared/constants/messages'; +import { TranslationResult } from '../types/translation'; +import { OllamaServiceError } from '../../ollama/types/ollama'; + +export type TranslateSelectionCoreDependencies = { + selectedText: string | undefined; + translateToKorean: (text: string) => Promise; + showInformationMessage: (message: string) => Promise | unknown; + showErrorMessage: (message: string) => Promise | unknown; + isCancelled?: () => boolean; + onTranslationResult?: (sourceText: string, result: TranslationResult) => void; + onTranslationError?: (sourceText: string, errorMessage: string) => void; +}; + +// Runs the shared translation flow used by both selection and hover entry points. +export async function runTranslateSelection( + dependencies: TranslateSelectionCoreDependencies, +): Promise { + const selectedText = dependencies.selectedText?.trim(); + + if (!selectedText) { + await dependencies.showInformationMessage(FRILINGO_MESSAGES.noTextSelected); + return; + } + + try { + const translation = await dependencies.translateToKorean(selectedText); + + // Ignores stale model results after the user explicitly cancels the progress notification. + if (dependencies.isCancelled?.()) { + return; + } + + dependencies.onTranslationResult?.(selectedText, translation); + await dependencies.showInformationMessage( + FRILINGO_MESSAGES.translatedResult(translation.text), + ); + } catch (error) { + // Ignores late failures after the user has already canceled the translation request. + if (dependencies.isCancelled?.()) { + return; + } + + if (error instanceof OllamaServiceError) { + if (error.code === 'connection_failed') { + dependencies.onTranslationError?.( + selectedText, + FRILINGO_MESSAGES.failedToConnectToOllama, + ); + await dependencies.showErrorMessage(FRILINGO_MESSAGES.failedToConnectToOllama); + return; + } + + if (error.code === 'invalid_response') { + dependencies.onTranslationError?.( + selectedText, + FRILINGO_MESSAGES.invalidOllamaResponse, + ); + await dependencies.showErrorMessage(FRILINGO_MESSAGES.invalidOllamaResponse); + return; + } + } + + dependencies.onTranslationError?.(selectedText, FRILINGO_MESSAGES.translationFailed); + await dependencies.showErrorMessage(FRILINGO_MESSAGES.translationFailed); + } +} diff --git a/src/features/translation/tests/hoverText.test.ts b/src/features/translation/tests/hoverText.test.ts new file mode 100644 index 0000000..6bbb2a0 --- /dev/null +++ b/src/features/translation/tests/hoverText.test.ts @@ -0,0 +1,70 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { extractTranslatableHoverText } from '../services/hoverText'; + +function createDocument(lineText: string) { + return { + lineAt: () => ({ text: lineText }), + }; +} + +suite('Hover Text Extraction', () => { + test('extracts a diagnostic message before code text', () => { + const document = createDocument('const x = foo();'); + const diagnostics = [ + { + message: 'Cannot find name foo', + range: { + start: { line: 0, character: 10 }, + end: { line: 0, character: 13 }, + }, + }, + ]; + + const result = extractTranslatableHoverText( + document, + { line: 0, character: 11 }, + diagnostics, + ); + + assert.strictEqual(result, 'Cannot find name foo'); + }); + + test('extracts inline comment text', () => { + const document = createDocument('const value = 1; // important warning'); + + const result = extractTranslatableHoverText( + document, + { line: 0, character: 22 }, + [], + ); + + assert.strictEqual(result, 'important warning'); + }); + + test('extracts string literal text', () => { + const document = createDocument('throw new Error("match expression must be exhaustive");'); + + const result = extractTranslatableHoverText( + document, + { line: 0, character: 20 }, + [], + ); + + assert.strictEqual(result, 'match expression must be exhaustive'); + }); + + test('returns undefined for ordinary code', () => { + const document = createDocument('const result = left + right;'); + + const result = extractTranslatableHoverText( + document, + { line: 0, character: 8 }, + [], + ); + + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/features/translation/tests/ollamaService.test.ts b/src/features/translation/tests/ollamaService.test.ts new file mode 100644 index 0000000..3322bc0 --- /dev/null +++ b/src/features/translation/tests/ollamaService.test.ts @@ -0,0 +1,180 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; +import { GenerateRequest, Ollama } from 'ollama'; + +import { OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { + buildFallbackTranslationPrompt, + buildTranslationPrompt, + OllamaService, +} from '../services/ollamaService'; +import { OllamaServiceError } from '../../ollama/types/ollama'; + +suite('Ollama Service', () => { + test('builds a prompt with the selected text and target language placeholders resolved', () => { + // Expected: the runtime prompt keeps the wrapper and injects both dynamic values. + const sourceText = 'match expression must be exhaustive'; + const prompt = buildTranslationPrompt( + sourceText, + OLLAMA_CONFIG.targetLanguage, + ); + + assert.ok(prompt.includes(OLLAMA_CONFIG.targetLanguage)); + assert.ok(prompt.includes('')); + assert.ok(prompt.includes(sourceText)); + assert.ok(prompt.includes('')); + }); + + test('sends the expected Ollama generate request for translation', async () => { + // Expected: the service sends one primary generate request with the configured prompt and limits. + const requests: GenerateRequest[] = []; + const client = { + generate: async (request: GenerateRequest & { stream?: false }) => { + requests.push(request); + + return { + response: '번역 결과', + } as never; + }, + } as unknown as Pick; + + const service = new OllamaService(client); + const translation = await service.translateToKorean('hello world'); + + assert.deepStrictEqual(translation, { + model: OLLAMA_CONFIG.model, + text: '번역 결과', + totalDurationNs: undefined, + loadDurationNs: undefined, + promptEvalCount: undefined, + evalCount: undefined, + usedFallbackPrompt: false, + }); + assert.strictEqual(requests.length, 1); + assert.strictEqual(requests[0]?.model, OLLAMA_CONFIG.model); + assert.strictEqual(requests[0]?.think, OLLAMA_CONFIG.generate.think); + assert.strictEqual( + requests[0]?.keep_alive, + OLLAMA_CONFIG.generate.keep_alive, + ); + assert.strictEqual(requests[0]?.stream, false); + assert.ok(requests[0]?.prompt?.includes('hello world')); + assert.deepStrictEqual(requests[0]?.options, OLLAMA_CONFIG.generate.options); + }); + + test('returns the first non-empty response without forcing a fallback retry', async () => { + // Expected: any non-empty model output is returned so the user can verify end-to-end behavior first. + const requests: GenerateRequest[] = []; + const client = { + generate: async (request: GenerateRequest & { stream?: false }) => { + requests.push(request); + + return { + response: "Okay, let's tackle this translation request.", + } as never; + }, + } as unknown as Pick; + + const service = new OllamaService(client); + const translation = await service.translateToKorean('hello world'); + + assert.strictEqual(requests.length, 1); + assert.deepStrictEqual(translation, { + model: OLLAMA_CONFIG.model, + text: "Okay, let's tackle this translation request.", + totalDurationNs: undefined, + loadDurationNs: undefined, + promptEvalCount: undefined, + evalCount: undefined, + usedFallbackPrompt: false, + }); + }); + + test('throws an invalid response error when Ollama returns no text', async () => { + // Expected: blank model output is rejected instead of shown to the user. + const client = { + generate: async () => + ({ + response: ' ', + }) as never, + } as unknown as Pick; + const service = new OllamaService(client); + + await assert.rejects( + async () => service.translateToKorean('hello world'), + (error: unknown) => + error instanceof OllamaServiceError && + error.code === 'invalid_response', + ); + }); + + test('retries with the fallback prompt after a blank primary response', async () => { + // Expected: a blank primary response triggers one fallback request before the service gives up. + const requests: GenerateRequest[] = []; + const client = { + generate: async (request: GenerateRequest & { stream?: false }) => { + requests.push(request); + + if (requests.length === 1) { + return { + response: ' ', + } as never; + } + + return { + response: '경계선의 총 길이는 약 400입니다.', + } as never; + }, + } as unknown as Pick; + const service = new OllamaService(client); + + const translation = await service.translateToKorean('hello world'); + + assert.strictEqual(requests.length, 2); + assert.strictEqual( + requests[1]?.prompt, + buildFallbackTranslationPrompt( + 'hello world', + OLLAMA_CONFIG.targetLanguage, + ), + ); + assert.deepStrictEqual(translation, { + model: OLLAMA_CONFIG.model, + text: '경계선의 총 길이는 약 400입니다.', + totalDurationNs: undefined, + loadDurationNs: undefined, + promptEvalCount: undefined, + evalCount: undefined, + usedFallbackPrompt: true, + }); + }); + + test('throws a connection error when the ollama client request fails', async () => { + // Expected: low-level client failures are mapped to the extension connection error. + const client = { + generate: async () => { + throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); + }, + } as unknown as Pick; + const service = new OllamaService(client); + + await assert.rejects( + async () => service.translateToKorean('hello world'), + (error: unknown) => + error instanceof OllamaServiceError && + error.code === 'connection_failed', + ); + }); + + test('uses the configured fixed generation options for every request', () => { + // Expected: the shared Ollama defaults stay centralized in the config module. + assert.deepStrictEqual(OLLAMA_CONFIG.generate.options, { + num_ctx: 2048, + num_predict: 512, + temperature: 0, + top_p: 0.1, + repeat_penalty: 1.05, + }); + }); +}); diff --git a/src/features/translation/tests/ollamaSmoke.ts b/src/features/translation/tests/ollamaSmoke.ts new file mode 100644 index 0000000..ba93ba3 --- /dev/null +++ b/src/features/translation/tests/ollamaSmoke.ts @@ -0,0 +1,80 @@ +import { OLLAMA_CONFIG } from '../../../shared/config/ollama'; +import { + buildTranslationPrompt, + OllamaService, +} from '../services/ollamaService'; + +// Uses a stable sample so local smoke runs can validate the real Ollama round trip quickly. +const DEFAULT_SOURCE_TEXT = + 'I am writing to follow up on our previous discussion.'; + +function formatDurationNs(durationNs: number | undefined): string { + if (!durationNs || durationNs <= 0) { + return 'n/a'; + } + + return `${(durationNs / 1_000_000_000).toFixed(2)}s`; +} + +// Reads a manual smoke input from CLI args while keeping a predictable default for quick checks. +function getSourceText(): string { + const cliText = process.argv.slice(2).join(' ').trim(); + + if (cliText) { + return cliText; + } + + return DEFAULT_SOURCE_TEXT; +} + +// Runs one real Ollama request and prints the prompt and response details for local debugging. +async function main(): Promise { + // Expected: the smoke run prints the active config so local model selection is easy to verify. + const sourceText = getSourceText(); + const prompt = buildTranslationPrompt( + sourceText, + OLLAMA_CONFIG.targetLanguage, + ); + const service = new OllamaService(); + + console.log('[Frilingo][Smoke] Host:', OLLAMA_CONFIG.host); + console.log('[Frilingo][Smoke] Model:', OLLAMA_CONFIG.model); + console.log('[Frilingo][Smoke] Source:', sourceText); + console.log( + '[Frilingo][Smoke] Configured num_predict:', + OLLAMA_CONFIG.generate.options?.num_predict ?? 'n/a', + ); + console.log('[Frilingo][Smoke] Prompt:'); + console.log(prompt); + console.log(''); + + try { + const result = await service.translateToKorean(sourceText); + + console.log('[Frilingo][Smoke] Response:'); + console.log(result.text); + console.log(''); + console.log( + '[Frilingo][Smoke] Load duration:', + formatDurationNs(result.loadDurationNs), + ); + console.log( + '[Frilingo][Smoke] Total duration:', + formatDurationNs(result.totalDurationNs), + ); + console.log( + '[Frilingo][Smoke] Prompt tokens:', + result.promptEvalCount ?? 'n/a', + ); + console.log('[Frilingo][Smoke] Output tokens:', result.evalCount ?? 'n/a'); + console.log( + '[Frilingo][Smoke] Fallback prompt:', + result.usedFallbackPrompt ? 'used' : 'not used', + ); + } catch (error) { + console.error('[Frilingo][Smoke] Error:', String(error)); + process.exitCode = 1; + } +} + +void main(); diff --git a/src/features/translation/tests/translateSelection.test.ts b/src/features/translation/tests/translateSelection.test.ts new file mode 100644 index 0000000..3a436f3 --- /dev/null +++ b/src/features/translation/tests/translateSelection.test.ts @@ -0,0 +1,201 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +import { suite, test } from 'mocha'; + +import { createTranslateSelectionCommand } from '../commands/translateSelection'; +import { OllamaServiceError } from '../../ollama/types/ollama'; + +function createMockCancellationToken(): vscode.CancellationToken { + // Expected: command tests run with a stable non-cancelled token unless a test overrides it explicitly. + return { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => undefined }), + } as unknown as vscode.CancellationToken; +} + +suite('Translate Selection Command', () => { + test('shows an error when there is no active editor', async () => { + // Expected: the command fails fast when there is no active VSCode editor. + const shownErrors: string[] = []; + const command = createTranslateSelectionCommand( + { + translateToKorean: async () => ({ + model: 'qwen3:4b', + text: 'unused', + }), + } as never, + { + getActiveTextEditor: () => undefined, + getActiveModelName: () => 'gemma3:4b', + showInformationMessage: async () => undefined, + showErrorMessage: async (message: string) => { + shownErrors.push(message); + return undefined; + }, + withProgress: async ( + _title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => task(createMockCancellationToken()), + outputChannel: { + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + dispose: () => undefined, + hide: () => undefined, + replace: () => undefined, + show: () => undefined, + name: 'Frilingo', + } as vscode.OutputChannel, + }, + ); + + await command(); + + assert.deepStrictEqual(shownErrors, ['Frilingo: No active editor.']); + }); + + test('shows an information message when the selection is empty', async () => { + // Expected: empty selections do not call Ollama and show the empty-selection message. + const shownMessages: string[] = []; + const command = createTranslateSelectionCommand( + { + translateToKorean: async () => ({ + model: 'qwen3:4b', + text: 'unused', + }), + } as never, + { + getActiveTextEditor: () => + ({ + document: { + getText: () => ' ', + }, + selection: {} as vscode.Selection, + }) as vscode.TextEditor, + getActiveModelName: () => 'gemma3:4b', + showInformationMessage: async (message: string) => { + shownMessages.push(message); + return undefined; + }, + showErrorMessage: async () => undefined, + withProgress: async ( + _title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => task(createMockCancellationToken()), + outputChannel: { + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + dispose: () => undefined, + hide: () => undefined, + replace: () => undefined, + show: () => undefined, + name: 'Frilingo', + } as vscode.OutputChannel, + }, + ); + + await command(); + + assert.deepStrictEqual(shownMessages, ['Frilingo: No text selected.']); + }); + + test('translates the trimmed selection and shows the result', async () => { + // Expected: the command trims the selection and shows only the translated text. + const shownMessages: string[] = []; + const requestedTexts: string[] = []; + const command = createTranslateSelectionCommand( + { + translateToKorean: async (text: string) => { + requestedTexts.push(text); + return { + model: 'qwen3:4b', + text: '패턴 매칭은 모든 경우를 다뤄야 합니다.', + }; + }, + } as never, + { + getActiveTextEditor: () => + ({ + document: { + getText: () => ' match expression must be exhaustive ', + }, + selection: {} as vscode.Selection, + }) as vscode.TextEditor, + getActiveModelName: () => 'gemma3:4b', + showInformationMessage: async (message: string) => { + shownMessages.push(message); + return undefined; + }, + showErrorMessage: async () => undefined, + withProgress: async ( + _title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => task(createMockCancellationToken()), + outputChannel: { + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + dispose: () => undefined, + hide: () => undefined, + replace: () => undefined, + show: () => undefined, + name: 'Frilingo', + } as vscode.OutputChannel, + }, + ); + + await command(); + + assert.deepStrictEqual(requestedTexts, ['match expression must be exhaustive']); + assert.deepStrictEqual(shownMessages, [ + 'Frilingo: 패턴 매칭은 모든 경우를 다뤄야 합니다.', + ]); + }); + + test('shows a connection error when Ollama is unavailable', async () => { + // Expected: command-level failures map the Ollama connection issue to the user error message. + const shownErrors: string[] = []; + const command = createTranslateSelectionCommand( + { + translateToKorean: async () => { + throw new OllamaServiceError('connection_failed', 'offline'); + }, + } as never, + { + getActiveTextEditor: () => + ({ + document: { + getText: () => 'network failure example', + }, + selection: {} as vscode.Selection, + }) as vscode.TextEditor, + getActiveModelName: () => 'gemma3:4b', + showInformationMessage: async () => undefined, + showErrorMessage: async (message: string) => { + shownErrors.push(message); + return undefined; + }, + withProgress: async ( + _title: string, + task: (cancellationToken: vscode.CancellationToken) => Promise, + ) => task(createMockCancellationToken()), + outputChannel: { + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + dispose: () => undefined, + hide: () => undefined, + replace: () => undefined, + show: () => undefined, + name: 'Frilingo', + } as vscode.OutputChannel, + }, + ); + + await command(); + + assert.deepStrictEqual(shownErrors, ['Frilingo: Failed to connect to Ollama.']); + }); +}); diff --git a/src/features/translation/tests/translateSelectionCore.test.ts b/src/features/translation/tests/translateSelectionCore.test.ts new file mode 100644 index 0000000..30bef55 --- /dev/null +++ b/src/features/translation/tests/translateSelectionCore.test.ts @@ -0,0 +1,71 @@ +import * as assert from 'assert'; + +import { suite, test } from 'mocha'; + +import { runTranslateSelection } from '../services/translateSelectionCore'; +import { OllamaServiceError } from '../../ollama/types/ollama'; + +suite('Translate Selection Core', () => { + test('shows an information message when the selection is empty', async () => { + // Expected: blank or whitespace-only selections do not call the model. + const shownMessages: string[] = []; + + await runTranslateSelection({ + selectedText: ' ', + translateToKorean: async () => ({ + model: 'qwen3:4b', + text: 'unused', + }), + showInformationMessage: async (message: string) => { + shownMessages.push(message); + }, + showErrorMessage: async () => undefined, + }); + + assert.deepStrictEqual(shownMessages, ['Frilingo: No text selected.']); + }); + + test('translates the trimmed selection and shows the result', async () => { + // Expected: the selected text is trimmed before it is sent to Ollama. + const shownMessages: string[] = []; + const requestedTexts: string[] = []; + + await runTranslateSelection({ + selectedText: ' match expression must be exhaustive ', + translateToKorean: async (text: string) => { + requestedTexts.push(text); + return { + model: 'qwen3:4b', + text: '패턴 매칭은 모든 경우를 다뤄야 합니다.', + }; + }, + showInformationMessage: async (message: string) => { + shownMessages.push(message); + }, + showErrorMessage: async () => undefined, + }); + + assert.deepStrictEqual(requestedTexts, ['match expression must be exhaustive']); + assert.deepStrictEqual(shownMessages, [ + 'Frilingo: 패턴 매칭은 모든 경우를 다뤄야 합니다.', + ]); + }); + + test('shows a connection error when Ollama is unavailable', async () => { + // Expected: connection failures are surfaced as a user-facing error message. + const shownErrors: string[] = []; + + await runTranslateSelection({ + selectedText: 'network failure example', + translateToKorean: async () => { + throw new OllamaServiceError('connection_failed', 'offline'); + }, + showInformationMessage: async () => undefined, + showErrorMessage: async (message: string) => { + shownErrors.push(message); + }, + }); + + assert.deepStrictEqual(shownErrors, ['Frilingo: Failed to connect to Ollama.']); + }); +}); diff --git a/src/features/translation/types/translation.ts b/src/features/translation/types/translation.ts new file mode 100644 index 0000000..e045dc8 --- /dev/null +++ b/src/features/translation/types/translation.ts @@ -0,0 +1,10 @@ +// Describes the translation payload returned from the Ollama service layer. +export interface TranslationResult { + model: string; + text: string; + totalDurationNs?: number; + loadDurationNs?: number; + promptEvalCount?: number; + evalCount?: number; + usedFallbackPrompt?: boolean; +} diff --git a/src/shared/config/ollama.ts b/src/shared/config/ollama.ts new file mode 100644 index 0000000..6635701 --- /dev/null +++ b/src/shared/config/ollama.ts @@ -0,0 +1,68 @@ +import type { Config, GenerateRequest } from 'ollama'; +import { TRANSLATION_PROMPT_TEMPLATE } from '../../features/translation/prompts/translationPrompt'; + +// Defines the default model retention window. +// TODO: Replace this with a user setting once per-user Ollama preferences are supported. +const DEFAULT_KEEP_ALIVE: NonNullable = 0; + +// Defines the default context window for short translation requests. +// TODO: Replace this with a user setting once per-user Ollama preferences are supported. +const DEFAULT_NUM_CTX = 2048; + +// Defines the default output budget for a single translation response. +// TODO: Replace this with a user setting once per-user Ollama preferences are supported. +const DEFAULT_NUM_PREDICT = 512; + +// Defines the recommended default Ollama model for the Frilingo MVP. +// TODO: Replace this with a richer model recommendation system once multiple presets are supported. +export const DEFAULT_OLLAMA_MODEL: GenerateRequest['model'] = 'gemma3:4b'; + +// Defines the default deterministic sampling temperature for translation. +// TODO: Replace this with a user setting once per-user Ollama preferences are supported. +const DEFAULT_TEMPERATURE = 0; + +// Disables chain-of-thought style reasoning for faster, direct translation output. +// TODO: Replace this with a user setting once per-user Ollama preferences are supported. +const DEFAULT_THINK = false; + +export type FrilingoOllamaConfig = { + host: NonNullable; + model: GenerateRequest['model']; + targetLanguage: string; + promptTemplate: string; + generate: Pick; +}; + +type FrilingoSettingsReader = { + get(section: string, defaultValue: T): T; +}; + +// Builds the runtime config from defaults plus any VSCode setting overrides. +export function createOllamaConfig( + settings?: FrilingoSettingsReader, +): FrilingoOllamaConfig { + return { + host: 'http://localhost:11434', + model: + settings?.get('ollamaModel', DEFAULT_OLLAMA_MODEL) ?? + DEFAULT_OLLAMA_MODEL, + targetLanguage: 'Korean', + // Keeps the primary translation prompt in one runtime-safe source file. + promptTemplate: TRANSLATION_PROMPT_TEMPLATE, + // Defines the default non-streaming generate options used by the MVP translation flow. + generate: { + think: DEFAULT_THINK, + keep_alive: DEFAULT_KEEP_ALIVE, + options: { + num_ctx: DEFAULT_NUM_CTX, + num_predict: DEFAULT_NUM_PREDICT, + temperature: DEFAULT_TEMPERATURE, + top_p: 0.1, + repeat_penalty: 1.05, + }, + }, + }; +} + +// Exposes the default runtime config for tests and non-VSCode entry points. +export const OLLAMA_CONFIG = createOllamaConfig(); diff --git a/src/shared/constants/messages.ts b/src/shared/constants/messages.ts new file mode 100644 index 0000000..3ff8221 --- /dev/null +++ b/src/shared/constants/messages.ts @@ -0,0 +1,10 @@ +// Centralizes user-facing copy so product wording can be updated in one place. +export const FRILINGO_MESSAGES = { + noActiveEditor: 'Frilingo: No active editor.', + noTextSelected: 'Frilingo: No text selected.', + failedToConnectToOllama: 'Frilingo: Failed to connect to Ollama.', + invalidOllamaResponse: 'Frilingo: Received an invalid response from Ollama.', + translationFailed: 'Frilingo: Translation failed.', + translationCancelled: 'Frilingo: Translation canceled.', + translatedResult: (translation: string) => `Frilingo: ${translation}`, +} as const; diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 4ca0ab4..1cc0cfe 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,15 +1,16 @@ -import * as assert from 'assert'; +import { suite, test } from 'mocha'; -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from 'vscode'; -// import * as myExtension from '../../extension'; +import assert from 'node:assert'; suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); + test('exports the extension lifecycle functions', () => { + // Expected: the extension entry module can be imported without running the VSCode host. + const extensionModule = require('../extension') as { + activate?: unknown; + deactivate?: unknown; + }; - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); + assert.strictEqual(typeof extensionModule.activate, 'function'); + assert.strictEqual(typeof extensionModule.deactivate, 'function'); + }); }); From 00862ef78401dc2e5a30dafa3e8c405a2867de46 Mon Sep 17 00:00:00 2001 From: "heesk0223@gmail.com" Date: Sun, 31 May 2026 23:28:56 +0900 Subject: [PATCH 2/3] fix: add ollama dependency --- package-lock.json | 18 ++++++++++++++++++ package.json | 13 ++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bed8a0..f165d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "frilingo", "version": "0.0.1", + "dependencies": { + "ollama": "^0.6.3" + }, "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "22.x", @@ -4022,6 +4025,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -5472,6 +5484,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f7a0324..716934b 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,18 @@ "test": "vscode-test" }, "devDependencies": { - "@types/vscode": "^1.120.0", "@types/mocha": "^10.0.10", "@types/node": "22.x", - "typescript-eslint": "^8.56.1", - "eslint": "^9.39.3", + "@types/vscode": "^1.120.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", "esbuild": "^0.27.3", + "eslint": "^9.39.3", "npm-run-all": "^4.1.5", "typescript": "^5.9.3", - "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2" + "typescript-eslint": "^8.56.1" + }, + "dependencies": { + "ollama": "^0.6.3" } } From 84326b4cc7bb8e40ebbb128b9481095a4f357c21 Mon Sep 17 00:00:00 2001 From: "heesk0223@gmail.com" Date: Sun, 31 May 2026 23:32:34 +0900 Subject: [PATCH 3/3] fix: add tsconfig's lib - Dom --- tsconfig.json | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index cb35375..d86cde3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,14 @@ { - "compilerOptions": { - "module": "Node16", - "target": "ES2022", - "lib": [ - "ES2022" - ], - "sourceMap": true, - "rootDir": "src", - "strict": true, /* enable all strict type-checking options */ - /* Additional Checks */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "sourceMap": true, + "rootDir": "src", + "strict": true /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + } }