mirror of
https://github.com/dralkh/spaceforge.git
synced 2026-07-22 06:45:03 +00:00
release: v1.0.6
This commit is contained in:
parent
e80dfda20d
commit
572f096c25
8 changed files with 295 additions and 5 deletions
223
api/openai-compatible-service.ts
Normal file
223
api/openai-compatible-service.ts
Normal file
|
|
@ -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<MCQSet | null> {
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
'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(/<think>/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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
7
main.ts
7
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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue