diff --git a/README.md b/README.md index dba5a25..e31ac9a 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Test your knowledge with AI-generated questions based on your Obsidian notes. Th - **AI-Generated Questions**: Automatically create test questions based on your notes using multiple LLM providers: - OpenAI (GPT-3.5, GPT-4, GPT-4o) - - Anthropic Claude (Claude 3 Opus, Sonnet, Haiku, Claude 3.5 Sonnet) - Mistral AI (Tiny, Small, Medium, Large) - Google Gemini (Pro, 1.5 Pro, 1.5 Flash) - DeepSeek (Chat, Coder) + - Ollama (Local models like Llama 3, Gemma, etc.) - **Knowledge Assessment**: Test your understanding with customized questions at different difficulty levels - **Instant Feedback**: Get immediate feedback on your answers - **Score Tracking**: Track your progress with detailed scoring @@ -37,17 +37,16 @@ Test your knowledge with AI-generated questions based on your Obsidian notes. Th To use this plugin, you'll need an API key from one of the supported providers: - **OpenAI**: Get your API key from [OpenAI Platform](https://platform.openai.com/account/api-keys) -- **Anthropic Claude**: Get your API key from [Anthropic Console](https://console.anthropic.com/settings/keys) - **Mistral AI**: Get your API key from [Mistral Console](https://console.mistral.ai/api-keys/) - **Google Gemini**: Get your API key from [Google AI Studio](https://makersuite.google.com/app/apikey) - **DeepSeek**: Get your API key from the DeepSeek website +- **Ollama**: No API key required. Download and install Ollama from [Ollama.com](https://ollama.com) and run the models locally ## Model Selection You can choose from various models for each provider: - **OpenAI**: GPT-3.5 Turbo, GPT-4, GPT-4 Turbo, GPT-4o -- **Anthropic Claude**: Claude 3 Opus, Claude 3 Sonnet, Claude 3 Haiku, Claude 3.5 Sonnet - **Mistral AI**: Mistral Tiny, Mistral Small, Mistral Medium, Mistral Large - **Google Gemini**: Gemini 1.5 Pro, Gemini 1.5 Flash - **DeepSeek**: DeepSeek Chat, DeepSeek Coder diff --git a/main.ts b/main.ts index ee7f91c..d988481 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,4 @@ -import { Notice, Plugin } from "obsidian"; +import { Notice, Plugin, WorkspaceLeaf } from "obsidian"; import TestDashboardView, { VIEW_TYPE as DASHBOARD_VIEW_TYPE } from "./src/ui/DashboardView"; import QuestionDocumentView, { QUESTION_VIEW_TYPE } from "./src/ui/QuestionView"; import SettingsTab from "./src/ui/SettingsTab"; @@ -16,7 +16,7 @@ export interface IndexedNote { testStatus: TestStatus; } -export type LLMProvider = "openai" | "anthropic" | "deepseek" | "gemini" | "mistral"; +export type LLMProvider = "openai" | "anthropic" | "deepseek" | "gemini" | "mistral" | "ollama"; interface ObsidianTestPluginSettings { mySetting: string; @@ -27,6 +27,7 @@ interface ObsidianTestPluginSettings { deepseek: string; gemini: string; mistral: string; + ollama: string; }; models: { openai: string; @@ -34,6 +35,10 @@ interface ObsidianTestPluginSettings { deepseek: string; gemini: string; mistral: string; + ollama: string; + }; + ollamaSettings: { + url: string; }; } @@ -45,14 +50,19 @@ const DEFAULT_SETTINGS: ObsidianTestPluginSettings = { anthropic: "", deepseek: "", gemini: "", - mistral: "" + mistral: "", + ollama: "" }, models: { openai: "gpt-4", anthropic: "claude-3-opus-20240229", deepseek: "deepseek-chat", gemini: "gemini-pro", - mistral: "mistral-medium" + mistral: "mistral-medium", + ollama: "llama3" + }, + ollamaSettings: { + url: "http://localhost:11434" } }; @@ -186,7 +196,7 @@ export default class ObsidianTestPlugin extends Plugin { const response = this.testDocuments[filePath]; const existingLeaves = this.app.workspace.getLeavesOfType(QUESTION_VIEW_TYPE); - let leaf; + let leaf: WorkspaceLeaf; if (existingLeaves.length > 0) { leaf = existingLeaves[0]; diff --git a/manifest.json b/manifest.json index 0e5c685..19d3cf2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "llm-test-gen", "name": "LLM Test Generator", - "version": "1.0.2", + "version": "1.1.0", "minAppVersion": "0.15.0", - "description": "Generate AI-powered test questions from your notes with multiple LLM providers (OpenAI, Mistral, Gemini, DeepSeek) to enhance your learning and retention.", + "description": "Generate AI-powered test questions from your notes with multiple LLM providers (OpenAI, Mistral, Gemini, DeepSeek, Ollama) to enhance your learning and retention.", "author": "Aldo E George", "authorUrl": "https://github.com/aldo-g", "isDesktopOnly": false diff --git a/package-lock.json b/package-lock.json index 0bafa22..6e7063a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "ollama": "^0.5.14" }, "devDependencies": { "@types/node": "^16.11.6", @@ -1894,6 +1895,15 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/ollama": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.14.tgz", + "integrity": "sha512-pvOuEYa2WkkAumxzJP0RdEYHkbZ64AYyyUszXVX7ruLvk5L+EiO2G71da2GqEQ4IAk4j6eLoUbGk5arzFT1wJA==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2358,6 +2368,12 @@ "license": "MIT", "peer": true }, + "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 52050ed..b6de3be 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "4.7.4" }, "dependencies": { - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "ollama": "^0.5.14" } } diff --git a/src/services/llm.ts b/src/services/llm.ts index 99dee0a..733e02b 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -27,12 +27,14 @@ export async function generateTestQuestions( indexedNotes: IndexedNote[], provider: LLMProvider, apiKeys: Record, - models?: Record + models?: Record, + ollamaSettings?: { url: string } ): Promise { const apiKey = getApiKey(provider, apiKeys); const model = models?.[provider] || getDefaultModel(provider); - if (!apiKey) { + // Skip API key check for Ollama + if (provider !== "ollama" && !apiKey) { throw new Error(`Missing API key for ${provider}! Please set it in the plugin settings.`); } @@ -72,6 +74,10 @@ Return JSON in this shape (no extra keys, no markdown fences): case "mistral": responseData = await callMistral(systemInstructions, notesPrompt, apiKey, model); break; + case "ollama": + const ollamaUrl = ollamaSettings?.url || "http://localhost:11434"; + responseData = await callOllama(systemInstructions, notesPrompt, model, 500, ollamaUrl); + break; default: throw new Error(`Unsupported provider: ${provider}`); } @@ -103,12 +109,14 @@ export async function markTestAnswers( }[], provider: LLMProvider, apiKeys: Record, - models?: Record + models?: Record, + ollamaSettings?: { url: string } ): Promise> { const apiKey = getApiKey(provider, apiKeys); const model = models?.[provider] || getDefaultModel(provider); - if (!apiKey) { + // Skip API key check for Ollama + if (provider !== "ollama" && !apiKey) { throw new Error(`Missing API key for ${provider}! Please set it in the plugin settings.`); } @@ -160,6 +168,10 @@ No extra fields, no markdown code blocks.`; case "mistral": responseData = await callMistral(systemMessage, userPrompt, apiKey, model, 1000); break; + case "ollama": + const ollamaUrl = ollamaSettings?.url || "http://localhost:11434"; + responseData = await callOllama(systemMessage, userPrompt, model, 1000, ollamaUrl); + break; default: throw new Error(`Unsupported provider: ${provider}`); } @@ -194,6 +206,8 @@ function getDefaultModel(provider: LLMProvider): string { return "gemini-pro"; case "mistral": return "mistral-medium"; + case "ollama": + return "llama3"; default: return ""; } @@ -476,4 +490,68 @@ async function callMistral( const responseData = await response.json(); return responseData.choices?.[0]?.message?.content || ""; +} + +async function callOllama( + systemMessage: string, + userPrompt: string, + model: string = "llama3", + maxTokens = 500, + ollamaUrl = "http://localhost:11434" +): Promise { + const fullPrompt = `${systemMessage}\n\n${userPrompt}`; + + // Ollama API endpoint for generate + const generateUrl = `${ollamaUrl}/api/generate`; + + const requestBody = { + model: model, + prompt: fullPrompt, + stream: false, + options: { + num_predict: maxTokens + } + }; + + try { + const response = await fetch(generateUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + + try { + errorData = JSON.parse(errorText); + } catch (e) { + // If it's not valid JSON, use the text as is + } + + if (errorText.includes("context window") || errorText.includes("context length")) { + throw new ContextLengthExceededError( + `The document is too large for ${model}'s context window. Please split your document into smaller sections.` + ); + } + + throw new Error(`Ollama API Error: ${response.status} - ${(errorData as any).error || errorText || response.statusText}`); + } + + const responseData = await response.json(); + return responseData.response || ""; + } catch (error) { + if (error instanceof ContextLengthExceededError) { + throw error; + } + + if (error.message?.includes("Failed to fetch")) { + throw new Error(`Could not connect to Ollama server at ${ollamaUrl}. Is it running?`); + } + + throw error; + } } \ No newline at end of file diff --git a/src/ui/DashboardView.ts b/src/ui/DashboardView.ts index 561141a..7c244ee 100644 --- a/src/ui/DashboardView.ts +++ b/src/ui/DashboardView.ts @@ -368,7 +368,7 @@ export default class TestDashboardView extends ItemView { cls: `folder-toggle ${node.expanded ? 'expanded' : 'collapsed'}` }); - this.createFolderToggleIcon(toggleBtn, node.expanded); + this.createFolderToggleIcon(toggleBtn, node.expanded || false); const folderIcon = folderRow.createEl("span", { cls: "folder-icon" }); this.createFolderIcon(folderIcon); @@ -503,7 +503,8 @@ export default class TestDashboardView extends ItemView { const provider = ragPlugin.settings.llmProvider; const apiKey = ragPlugin.settings.apiKeys[provider]; - if (!apiKey) { + // Skip API key check for Ollama + if (provider !== "ollama" && !apiKey) { new Notice(`❌ ${this.getProviderDisplayName(provider)} API Key is missing! Please set it in the plugin settings.`); return; } @@ -536,7 +537,8 @@ export default class TestDashboardView extends ItemView { [note], ragPlugin.settings.llmProvider, ragPlugin.settings.apiKeys, - ragPlugin.settings.models + ragPlugin.settings.models, + ragPlugin.settings.ollamaSettings ); ragPlugin.testDocuments[filePath] = { @@ -573,8 +575,11 @@ export default class TestDashboardView extends ItemView { if (error instanceof ContextLengthExceededError) { new Notice(`❌ ${filePath}: ${error.message}`, 10000); + } else if (error.message?.includes("Failed to parse JSON")) { + const modelName = ragPlugin.settings.models[ragPlugin.settings.llmProvider]; + new Notice(`❌ ${filePath}: Model "${modelName}" failed to generate proper JSON. Please try a more capable model.`, 10000); } else { - new Notice(`❌ Error generating tests for ${filePath}`); + new Notice(`❌ Error generating tests for ${filePath}: ${error.message}`, 5000); } } })()); @@ -611,6 +616,8 @@ export default class TestDashboardView extends ItemView { return "Gemini"; case "mistral": return "Mistral"; + case "ollama": + return "Ollama"; default: return provider.charAt(0).toUpperCase() + provider.slice(1); } @@ -626,7 +633,8 @@ export default class TestDashboardView extends ItemView { const provider = ragPlugin.settings.llmProvider; const apiKeys = ragPlugin.settings.apiKeys; - if (!apiKeys[provider]) { + // Skip API key check for Ollama + if (provider !== "ollama" && !apiKeys[provider]) { new Notice(`❌ ${this.getProviderDisplayName(provider)} API Key is missing! Please set it in the plugin settings.`); return; } @@ -724,7 +732,8 @@ export default class TestDashboardView extends ItemView { qnaPairs, provider, apiKeys, - this.plugin.settings.models + this.plugin.settings.models, + this.plugin.settings.ollamaSettings ); let totalPossibleMarks = 0; diff --git a/src/ui/QuestionView.ts b/src/ui/QuestionView.ts index cbabdd1..c8f935c 100644 --- a/src/ui/QuestionView.ts +++ b/src/ui/QuestionView.ts @@ -235,7 +235,8 @@ export default class QuestionDocumentView extends ItemView { const provider = this.plugin.settings.llmProvider; const apiKeys = this.plugin.settings.apiKeys; - if (!apiKeys[provider]) { + // Skip API key check for Ollama + if (provider !== "ollama" && !apiKeys[provider]) { new Notice(`${this.getProviderDisplayName(provider)} API key missing. Please set it in plugin settings.`); return; } @@ -256,7 +257,8 @@ export default class QuestionDocumentView extends ItemView { qnaPairs, provider, apiKeys, - this.plugin.settings.models + this.plugin.settings.models, + this.plugin.settings.ollamaSettings ); this.markResults = new Array(this.generatedTests.length).fill(null); @@ -331,9 +333,31 @@ export default class QuestionDocumentView extends ItemView { text: "Suggestions: Split your document into smaller parts, or try a different LLM provider with a larger context window.", }); + this.render(); + } else if (err.message?.includes("Failed to parse") || err.message?.includes("JSON")) { + const modelName = this.plugin.settings.models[provider]; + new Notice(`❌ Model Compatibility Error: "${modelName}" failed to generate proper JSON. Try a more advanced model.`, 10000); + + const errorContainer = this.containerEl.createDiv({ + cls: "error-message", + }); + + errorContainer.createEl("h3", { + text: `Model Compatibility Issue: ${modelName}`, + }); + + errorContainer.createEl("p", { + text: "The model failed to generate properly structured output required for test marking.", + }); + + errorContainer.createEl("p", { + cls: "suggestion", + text: "Some models (especially smaller or older ones) struggle with following specific JSON formatting instructions. Try using a more capable model like llama3 or gemma3, which are better at structured outputs.", + }); + this.render(); } else { - new Notice(`Error marking answers with ${this.getProviderDisplayName(provider)}. Check console for details.`); + new Notice(`Error marking answers with ${this.getProviderDisplayName(provider)}: ${err.message}`, 5000); } } finally { this.hideSpinner(spinnerOverlay); @@ -352,6 +376,8 @@ export default class QuestionDocumentView extends ItemView { return "Gemini"; case "mistral": return "Mistral"; + case "ollama": + return "Ollama"; default: return provider.charAt(0).toUpperCase() + provider.slice(1); } diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index 48348af..4289d4d 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -23,6 +23,7 @@ export default class SettingsTab extends PluginSettingTab { .addOption("deepseek", "DeepSeek") .addOption("gemini", "Google (Gemini)") .addOption("mistral", "Mistral AI") + .addOption("ollama", "Ollama (Local)") .setValue(this.plugin.settings.llmProvider === "anthropic" ? "openai" : this.plugin.settings.llmProvider) .onChange(async (value: LLMProvider) => { this.plugin.settings.llmProvider = value; @@ -270,6 +271,56 @@ export default class SettingsTab extends PluginSettingTab { }); }); } + + // Ollama Settings + if (this.plugin.settings.llmProvider === "ollama") { + // Ollama Server URL + new Setting(containerEl) + .setName("Ollama server URL") + .setDesc("Enter the URL of your Ollama server") + .addText((text) => + text + .setPlaceholder("http://localhost:11434") + .setValue(this.plugin.settings.ollamaSettings?.url || "http://localhost:11434") + .onChange(async (value) => { + if (!this.plugin.settings.ollamaSettings) { + this.plugin.settings.ollamaSettings = { url: value }; + } else { + this.plugin.settings.ollamaSettings.url = value; + } + await this.plugin.saveSettings(); + }) + ); + + // Model selection + new Setting(containerEl) + .setName("Ollama model") + .setDesc("Enter the name of the Ollama model to use") + .addText((text) => + text + .setPlaceholder("llama3") + .setValue(this.plugin.settings.models.ollama || "llama3") + .onChange(async (value) => { + this.plugin.settings.models.ollama = value; + await this.plugin.saveSettings(); + }) + ); + + // Add information about Ollama models + const providerInfoDiv = containerEl.createDiv({ cls: "provider-info" }); + providerInfoDiv.createEl("p", { + text: "Ollama lets you run LLMs locally. Make sure Ollama is installed and running before using this option." + }); + providerInfoDiv.createEl("p", { + text: "Download Ollama from: https://ollama.com/" + }); + providerInfoDiv.createEl("p", { + text: "Common models: llama3, mistral, gemma, codellama, llama3:8b, etc." + }); + providerInfoDiv.createEl("p", { + text: "Run 'ollama pull [model]' in your terminal to download models before using them with this plugin." + }); + } // About section new Setting(containerEl).setName("About API keys").setHeading(); diff --git a/versions.json b/versions.json index 5842672..908e85c 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "0.15.0" + "1.0.0": "0.15.0", + "1.1.0": "0.15.0" } \ No newline at end of file