LlmPreferencePage.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.internal.ui.preferences;

import java.io.IOException;

import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.equinox.security.storage.StorageException;
import org.eclipse.jface.preference.BooleanFieldEditor;
import org.eclipse.jface.preference.ComboFieldEditor;
import org.eclipse.jface.preference.FieldEditor;
import org.eclipse.jface.preference.FieldEditorPreferencePage;
import org.eclipse.jface.preference.StringFieldEditor;
import org.eclipse.jface.util.PropertyChangeEvent;
import org.eclipse.swt.SWT;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Text;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.IWorkbenchPreferencePage;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.preferences.ScopedPreferenceStore;
import org.osgi.service.prefs.BackingStoreException;
import org.sandbox.jdt.triggerpattern.mining.llm.EclipseLlmService;

/** Preference page for the LLM settings actually consumed by rule inference. */
public class LlmPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage {

	public static final String PLUGIN_ID = "sandbox_triggerpattern"; //$NON-NLS-1$
	private static final String PREFIX = "org.sandbox.jdt.triggerpattern.llm."; //$NON-NLS-1$

	public static final String PREF_PROVIDER = PREFIX + "provider"; //$NON-NLS-1$
	/** Legacy ordinary-preference key. New versions migrate it to Secure Storage. */
	@Deprecated
	public static final String PREF_API_KEY = PREFIX + "apiKey"; //$NON-NLS-1$
	public static final String PREF_MODEL_NAME = PREFIX + "modelName"; //$NON-NLS-1$

	@Deprecated
	public static final String PREF_MAX_TOKENS = PREFIX + "maxTokens"; //$NON-NLS-1$
	@Deprecated
	public static final String PREF_TEMPERATURE = PREFIX + "temperature"; //$NON-NLS-1$

	private static final String WIZARD_PREFIX = "org.sandbox.jdt.triggerpattern.wizard."; //$NON-NLS-1$
	public static final String PREF_WIZARD_AUTO_AI = WIZARD_PREFIX + "autoInferOnSelection"; //$NON-NLS-1$
	@Deprecated
	public static final String PREF_WIZARD_DEFAULT_FOLDER = WIZARD_PREFIX + "defaultHintFolder"; //$NON-NLS-1$

	private static final String[][] PROVIDER_ENTRIES = {
			{ "Gemini", "GEMINI" }, //$NON-NLS-1$ //$NON-NLS-2$
			{ "OpenAI", "OPENAI" }, //$NON-NLS-1$ //$NON-NLS-2$
			{ "DeepSeek", "DEEPSEEK" }, //$NON-NLS-1$ //$NON-NLS-2$
			{ "Qwen", "QWEN" }, //$NON-NLS-1$ //$NON-NLS-2$
			{ "Llama", "LLAMA" }, //$NON-NLS-1$ //$NON-NLS-2$
			{ "Mistral", "MISTRAL" } //$NON-NLS-1$ //$NON-NLS-2$
	};

	private Text apiKeyText;
	private ComboFieldEditor providerField;
	private String selectedProvider;

	public LlmPreferencePage() {
		super(GRID);
		setPreferenceStore(new ScopedPreferenceStore(InstanceScope.INSTANCE, PLUGIN_ID));
		setDescription("Configure the provider used for AI-assisted rule inference.\n" //$NON-NLS-1$
				+ "API keys are encrypted in Eclipse Secure Storage and bound to the selected provider. " //$NON-NLS-1$
				+ "Leave the key empty to use the provider-specific environment variable; leave Model name empty to use the provider default."); //$NON-NLS-1$
	}

	@Override
	public void createFieldEditors() {
		PlatformUI.getWorkbench().getHelpSystem().setHelp(getFieldEditorParent(),
				"sandbox_triggerpattern.preferences"); //$NON-NLS-1$

		selectedProvider = getPreferenceStore().getString(PREF_PROVIDER);
		providerField = new ComboFieldEditor(
				PREF_PROVIDER,
				"&Provider:", //$NON-NLS-1$
				PROVIDER_ENTRIES,
				getFieldEditorParent());
		addField(providerField);

		createSecureApiKeyField(getFieldEditorParent());

		addField(new StringFieldEditor(
				PREF_MODEL_NAME,
				"&Model name (optional):", //$NON-NLS-1$
				getFieldEditorParent()));

		addField(new BooleanFieldEditor(
				PREF_WIZARD_AUTO_AI,
				"Automatically generate rule with AI when opening wizard from selection", //$NON-NLS-1$
				getFieldEditorParent()));
	}

	private void createSecureApiKeyField(Composite parent) {
		Label label = new Label(parent, SWT.NONE);
		label.setText("&API Key (Secure Storage):"); //$NON-NLS-1$

		apiKeyText = new Text(parent, SWT.BORDER | SWT.SINGLE | SWT.PASSWORD);
		apiKeyText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
		apiKeyText.setText(LlmSecureCredentials.loadApiKey(selectedProvider));

		Label explanation = new Label(parent, SWT.WRAP);
		explanation.setText("Stored encrypted by Eclipse Equinox Secure Storage for the selected provider; environment-variable fallback remains available."); //$NON-NLS-1$
		GridData explanationData = new GridData(SWT.FILL, SWT.CENTER, true, false);
		explanationData.horizontalSpan = 2;
		explanation.setLayoutData(explanationData);
	}

	@Override
	public void propertyChange(PropertyChangeEvent event) {
		if (event.getSource() == providerField && FieldEditor.VALUE.equals(event.getProperty())) {
			selectedProvider = String.valueOf(event.getNewValue());
			if (apiKeyText != null && !apiKeyText.isDisposed()) {
				apiKeyText.setText(LlmSecureCredentials.loadApiKey(selectedProvider));
			}
		}
		super.propertyChange(event);
	}

	@Override
	public boolean performOk() {
		try {
			LlmSecureCredentials.storeApiKey(selectedProvider,
					apiKeyText != null ? apiKeyText.getText() : ""); //$NON-NLS-1$
		} catch (StorageException | IOException | BackingStoreException e) {
			setErrorMessage("Could not save the API key in Eclipse Secure Storage: " + e.getMessage()); //$NON-NLS-1$
			return false;
		}
		boolean result = super.performOk();
		if (result) {
			EclipseLlmService.reset();
		}
		return result;
	}

	@Override
	protected void performDefaults() {
		super.performDefaults();
		if (apiKeyText != null && !apiKeyText.isDisposed()) {
			apiKeyText.setText(""); //$NON-NLS-1$
		}
	}

	@Override
	public void init(IWorkbench workbench) {
		// Defaults are set by LlmPreferenceInitializer.
	}
}