added Ollama capabilities and error handling

This commit is contained in:
Alastair Grant 2025-04-08 10:18:45 +01:00
parent 5004b428b6
commit 4c61c6ca5d
10 changed files with 217 additions and 26 deletions

View file

@ -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

20
main.ts
View file

@ -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];

View file

@ -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

18
package-lock.json generated
View file

@ -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",

View file

@ -23,6 +23,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"dotenv": "^16.4.7"
"dotenv": "^16.4.7",
"ollama": "^0.5.14"
}
}

View file

@ -27,12 +27,14 @@ export async function generateTestQuestions(
indexedNotes: IndexedNote[],
provider: LLMProvider,
apiKeys: Record<string, string>,
models?: Record<string, string>
models?: Record<string, string>,
ollamaSettings?: { url: string }
): Promise<TestQuestionsResponse> {
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<string, string>,
models?: Record<string, string>
models?: Record<string, string>,
ollamaSettings?: { url: string }
): Promise<Array<{ questionNumber: number; marks: number; maxMarks: number; feedback: string }>> {
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<string> {
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;
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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();

View file

@ -1,3 +1,4 @@
{
"1.0.0": "0.15.0"
"1.0.0": "0.15.0",
"1.1.0": "0.15.0"
}