diff --git a/api/openai-compatible-service.ts b/api/openai-compatible-service.ts new file mode 100644 index 0000000..89488ed --- /dev/null +++ b/api/openai-compatible-service.ts @@ -0,0 +1,223 @@ +import { Notice, requestUrl } from 'obsidian'; +import { OPENAI_COMPATIBLE, API } from '../ui/constants'; +import SpaceforgePlugin from '../main'; +import { MCQQuestion, MCQSet } from '../models/mcq'; +import { IMCQGenerationService } from './mcq-generation-service'; +import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; + +interface OpenAICompatibleResponse { + choices: Array<{ message: { content: string } }>; +} + +interface OpenAICompatibleErrorResponse { + error?: { message?: string }; + message?: string; +} + +export class OpenAICompatibleService implements IMCQGenerationService { + plugin: SpaceforgePlugin; + + constructor(plugin: SpaceforgePlugin) { + this.plugin = plugin; + } + + async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise { + if (!settings.openaiCompatibleApiUrl) { + new Notice(`${OPENAI_COMPATIBLE} ${API} URL not set in settings.`); + return null; + } + if (!settings.openaiCompatibleModel) { + new Notice(`${OPENAI_COMPATIBLE} model not set in settings.`); + return null; + } + + try { + new Notice(`Generating questions using ${OPENAI_COMPATIBLE}...`); + + let numQuestionsToGenerate: number; + if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + } + + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + + if (questions.length === 0) { + new Notice('Failed to generate valid questions. Try again.'); + return null; + } + + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch { + new Notice('Failed to generate questions. Check console for details.'); + return null; + } + } + + private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + + let basePrompt = ""; + if (promptType === 'basic') { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`; + } + + if (difficulty === MCQDifficulty.Basic) { + basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt}\n\nNote Content:\n${noteContent}`; + } + + private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise { + const apiUrl = settings.openaiCompatibleApiUrl.endsWith('/') + ? settings.openaiCompatibleApiUrl.slice(0, -1) + : settings.openaiCompatibleApiUrl; + const apiKey = settings.openaiCompatibleApiKey; + const model = settings.openaiCompatibleModel; + const difficulty = settings.mcqDifficulty; + + const systemPrompt = difficulty === MCQDifficulty.Basic + ? settings.mcqBasicSystemPrompt + : settings.mcqAdvancedSystemPrompt; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await requestUrl({ + url: `${apiUrl}/chat/completions`, + method: 'POST', + headers, + body: JSON.stringify({ + model: model, + max_tokens: 2048, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ] + }) + }); + + if (response.status !== 200) { + const errorData: OpenAICompatibleErrorResponse = response.json ?? { message: response.text }; + throw new Error(`API request failed (${response.status}): ${errorData.error?.message ?? errorData.message ?? 'Unknown error'}`); + } + + const data = response.json as OpenAICompatibleResponse; + if (!data.choices?.length || !data.choices[0]?.message?.content) { + throw new Error('Invalid API response format - missing content'); + } + return data.choices[0].message.content; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + new Notice(`OpenAI Compatible API error: ${msg}`); + throw error; + } + } + + private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] { + const questions: MCQQuestion[] = []; + try { + let questionBlocks: string[] = response.split(/\n\d+\.\s+/).filter(block => block.trim().length > 0); + + if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) { + if (!/^\d+\.\s+/.test(questionBlocks[0])) { + if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) { + questionBlocks = response.split(/\n(?=\d+\.\s)/); + if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) { + const potentialBlocks = response.split(/\n\n+/); + if (potentialBlocks.some(pb => /^\d+\.\s/.test(pb.trimStart()))) { + questionBlocks = potentialBlocks; + } + } + } + } + } + + if (questionBlocks.length === 0 || (questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1."))) { + const lines = response.split('\n'); + let currentQuestionBlock = ''; + const tempBlocks = []; + for (const line of lines) { + if (/^\d+\.\s+/.test(line.trim())) { + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + currentQuestionBlock = line + '\n'; + } else if (currentQuestionBlock.length > 0) { + currentQuestionBlock += line + '\n'; + } else if (tempBlocks.length === 0 && line.trim().length > 0) { + currentQuestionBlock = line + '\n'; + } + } + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + if (tempBlocks.length > 0) questionBlocks = tempBlocks; + } + + for (const block of questionBlocks) { + const lines = block.split('\n').filter(line => line.trim().length > 0); + if (lines.length < 2) continue; + + let questionText = lines[0].replace(/^\d+\.\s*/, '').trim(); + questionText = questionText.replace(//g, '').replace(/<\/think>/g, ''); + + const choices: string[] = []; + let correctAnswerIndex = -1; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes('[CORRECT]'); + const cleanedLine = line.replace(/\[CORRECT\]/gi, '') + .replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, '') + .trim(); + if (cleanedLine.length > 0) { + choices.push(cleanedLine); + if (isCorrect) correctAnswerIndex = choices.length - 1; + } + } + + if (correctAnswerIndex === -1 && choices.length > 0) { + for (let i = 0; i < choices.length; i++) { + if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) { + choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim(); + correctAnswerIndex = i; + break; + } + } + if (correctAnswerIndex === -1) correctAnswerIndex = 0; + } + + if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } else if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + return questions.slice(0, numQuestionsToGenerate); + } catch { + new Notice(`Error parsing response from ${OPENAI_COMPATIBLE}. Try again.`); + return []; + } + } +} diff --git a/main.ts b/main.ts index cd581f8..743b310 100644 --- a/main.ts +++ b/main.ts @@ -18,6 +18,7 @@ import { OllamaService } from './api/ollama-service'; import { GeminiService } from './api/gemini-service'; import { ClaudeService } from './api/claude-service'; import { TogetherService } from './api/together-service'; +import { OpenAICompatibleService } from './api/openai-compatible-service'; import { IMCQGenerationService } from './api/mcq-generation-service'; import { ReviewScheduleService } from './services/review-schedule-service'; import { ReviewHistoryService } from './services/review-history-service'; @@ -714,6 +715,12 @@ export default class SpaceforgePlugin extends Plugin { return undefined; } return new TogetherService(this); + case ApiProvider.OpenAICompatible: + if (!this.settings.openaiCompatibleApiUrl || !this.settings.openaiCompatibleModel) { + new Notice('OpenAI Compatible API URL or model is not set in settings'); + return undefined; + } + return new OpenAICompatibleService(this); default: // It's good practice to handle unexpected enum values, even if TypeScript provides some safety. // This could happen if settings data is corrupted or from an older version. diff --git a/manifest.json b/manifest.json index 1444a5d..044652c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "spaceforge", "name": "Spaceforge", - "version": "1.0.5", + "version": "1.0.6", "minAppVersion": "1.8.7", "description": "Enhance knowledge retention with spaced repetition using FSRS & SM-2 algorithms, AI-powered MCQ generation, integrated Pomodoro timer, and calendar event manager.", "author": "dralkh", diff --git a/models/settings.ts b/models/settings.ts index 5da81c0..3a5e642 100644 --- a/models/settings.ts +++ b/models/settings.ts @@ -250,6 +250,24 @@ export interface SpaceforgeSettings { * Default: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8' */ togetherModel: string; + + /** + * OpenAI Compatible API URL + * Default: '' + */ + openaiCompatibleApiUrl: string; + + /** + * OpenAI Compatible API Key + * Default: '' + */ + openaiCompatibleApiKey: string; + + /** + * OpenAI Compatible Model + * Default: '' + */ + openaiCompatibleModel: string; /** * System prompt for basic difficulty MCQs @@ -358,7 +376,8 @@ export enum ApiProvider { Ollama = 'ollama', Gemini = 'gemini', Claude = 'claude', - Together = 'together' + Together = 'together', + OpenAICompatible = 'openai-compatible' } /** @@ -394,6 +413,9 @@ export const DEFAULT_SETTINGS: SpaceforgeSettings = { claudeModel: 'claude-3-sonnet-20240229', togetherApiKey: '', togetherModel: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8', + openaiCompatibleApiUrl: '', + openaiCompatibleApiKey: '', + openaiCompatibleModel: '', mcqPromptType: 'detailed', mcqQuestionsPerNote: 4, mcqChoicesPerQuestion: 5, diff --git a/package.json b/package.json index 5b2a6a2..e96f8d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spaceforge", - "version": "1.0.5", + "version": "1.0.6", "description": "A spaced repetition plugin for efficient knowledge review in Obsidian", "main": "main.js", "scripts": { diff --git a/ui/constants.ts b/ui/constants.ts index 244700d..9e9468f 100644 --- a/ui/constants.ts +++ b/ui/constants.ts @@ -13,6 +13,7 @@ export const CLAUDE = 'Claude'; export const GEMINI = 'Gemini'; export const OLLAMA = 'Ollama'; export const OPENAI = 'OpenAI'; +export const OPENAI_COMPATIBLE = 'OpenAI Compatible'; export const TOGETHER_AI = 'Together AI'; export const OPENROUTER = 'OpenRouter'; export const WPM = 'WPM'; diff --git a/ui/settings-tab.ts b/ui/settings-tab.ts index a9acedf..444b4ca 100644 --- a/ui/settings-tab.ts +++ b/ui/settings-tab.ts @@ -3,7 +3,7 @@ import { ConfirmationModal } from './confirmation-modal'; import SpaceforgePlugin from '../main'; import { ApiProvider, DEFAULT_SETTINGS, MCQQuestionAmountMode, MCQDifficulty, SpaceforgeSettings } from '../models/settings'; import { SpaceforgePluginData, DEFAULT_PLUGIN_STATE_DATA, PluginStateData } from '../models/plugin-data'; -import { SM2, FSRS, POMODORO, MCQS, API, SPACEFORGE, CLAUDE, GEMINI, OLLAMA, TOGETHER_AI, WPM } from './constants'; +import { SM2, FSRS, POMODORO, MCQS, API, SPACEFORGE, CLAUDE, GEMINI, OLLAMA, OPENAI_COMPATIBLE, TOGETHER_AI, WPM } from './constants'; /** * Settings tab for the plugin @@ -800,6 +800,7 @@ export class SpaceforgeSettingTab extends PluginSettingTab { .addOption(ApiProvider.Gemini, 'Gemini') .addOption(ApiProvider.Claude, 'Claude') .addOption(ApiProvider.Together, 'Together AI') + .addOption(ApiProvider.OpenAICompatible, 'OpenAI Compatible') .setValue(this.plugin.settings.mcqApiProvider) .onChange(async (value: ApiProvider) => { this.plugin.settings.mcqApiProvider = value; @@ -967,6 +968,41 @@ export class SpaceforgeSettingTab extends PluginSettingTab { this.plugin.settings.togetherModel = value; await this.plugin.savePluginData(); })); + } else if (provider === ApiProvider.OpenAICompatible) { + new Setting(mcqSection) + .setName("OpenAI Compatible configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('API URL') + .setDesc(`Base URL of the ${OPENAI_COMPATIBLE} API (e.g., http://localhost:8000/v1)`) + .addText(text => text + .setPlaceholder('http://localhost:8000/v1') + .setValue(this.plugin.settings.openaiCompatibleApiUrl) + .onChange(async (value: string) => { + this.plugin.settings.openaiCompatibleApiUrl = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('API key') + .setDesc(`API key for ${OPENAI_COMPATIBLE} (optional).`) + .addText(text => text + .setPlaceholder(`Enter ${OPENAI_COMPATIBLE} ${API} key`) + .setValue(this.plugin.settings.openaiCompatibleApiKey) + .onChange(async (value: string) => { + this.plugin.settings.openaiCompatibleApiKey = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('Model') + .setDesc(`Model name to use (e.g., gpt-4, local-model-name)`) + .addText(text => text + .setPlaceholder(`Enter model name`) + .setValue(this.plugin.settings.openaiCompatibleModel) + .onChange(async (value: string) => { + this.plugin.settings.openaiCompatibleModel = value; + await this.plugin.savePluginData(); + })); } // Question generation settings (common to all providers) diff --git a/versions.json b/versions.json index 0486b47..221f3b2 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "1.0.2": "0.15.0", "1.0.3": "0.15.0", "1.0.5": "1.8.7", - "1.0.4": "0.15.0" + "1.0.4": "0.15.0", + "1.0.6": "1.8.7" } \ No newline at end of file