mirror of
https://github.com/aldo-g/obsidian-llm-test.git
synced 2026-07-22 05:42:19 +00:00
added Ollama capabilities and error handling
This commit is contained in:
parent
5004b428b6
commit
4c61c6ca5d
10 changed files with 217 additions and 26 deletions
|
|
@ -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
20
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];
|
||||
|
|
|
|||
|
|
@ -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
18
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7"
|
||||
"dotenv": "^16.4.7",
|
||||
"ollama": "^0.5.14"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
"1.0.0": "0.15.0",
|
||||
"1.1.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue