From c4c75ad68bcb40976cc6ee510efcedec04062f2b Mon Sep 17 00:00:00 2001 From: mpstaton Date: Sat, 26 Jul 2025 16:51:15 +0300 Subject: [PATCH] attempt(lmstudio): attempt to fix lmstudio On branch fix/lmstudio Changes to be committed: modified: README.md modified: main.ts new file: src/core/PerplexedPluginCore.ts modified: src/modals/LMStudioModal.ts modified: src/services/lmStudioService.ts new file: src/settings/LMStudioSettings.ts new file: src/settings/PerplexedSettings.ts modified: src/types/obsidian.d.ts modified: styles.css --- README.md | 1 + main.ts | 384 ++++++++++++++++++------------ src/core/PerplexedPluginCore.ts | 54 +++++ src/modals/LMStudioModal.ts | 26 +- src/services/lmStudioService.ts | 381 +++++++++++++++++------------ src/settings/LMStudioSettings.ts | 113 +++++++++ src/settings/PerplexedSettings.ts | 132 ++++++++++ src/types/obsidian.d.ts | 52 +++- styles.css | 73 +++++- 9 files changed, 894 insertions(+), 322 deletions(-) create mode 100644 src/core/PerplexedPluginCore.ts create mode 100644 src/settings/LMStudioSettings.ts create mode 100644 src/settings/PerplexedSettings.ts diff --git a/README.md b/README.md index 541add7..7db28a8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![Perplexed: An Obsidian Community Plugin focused on Perplexity and Perplexica for rigorous, research-driven content automation](https://i.imgur.com/zUEJiYl.png) # Perplexed: AI Content Generation for Obsidian **Perplexed** is an Obsidian plugin that enables AI-powered content generation with source citations using [Perplexity](https://www.perplexity.ai/) and [Perplexica](https://perplexica.io/). This plugin brings research-grade AI capabilities directly into your Obsidian workspace, allowing you to generate well-cited content for your notes. diff --git a/main.ts b/main.ts index f1f43c0..926c287 100644 --- a/main.ts +++ b/main.ts @@ -1,68 +1,156 @@ -import { App, Editor, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; -import * as dotenv from 'dotenv'; +import { App, Editor, Notice, PluginSettingTab, Setting } from 'obsidian'; +import PerplexedPluginCore from './src/core/PerplexedPluginCore'; -// Import services +// Load environment variables +import * as dotenv from 'dotenv'; +dotenv.config({ path: `${process.cwd()}/.env` }); + +// Services import { PerplexityService } from './src/services/perplexityService'; import { PerplexicaService } from './src/services/perplexicaService'; import { LMStudioService } from './src/services/lmStudioService'; import { PromptsService } from './src/services/promptsService'; -// Import modals +// Modals import { PerplexityModal } from './src/modals/PerplexityModal'; import { PerplexicaModal } from './src/modals/PerplexicaModal'; import { LMStudioModal } from './src/modals/LMStudioModal'; import { URLUpdateModal } from './src/modals/URLUpdateModal'; import { ArticleGeneratorModal } from './src/modals/ArticleGeneratorModal'; -// Load environment variables -dotenv.config({ path: `${process.cwd()}/.env` }); +// Settings and types +import { PerplexedSettings } from './src/settings/PerplexedSettings'; +import type { PerplexitySettings } from './src/services/perplexityService'; +import type { PerplexicaSettings } from './src/services/perplexicaService'; +import { LMStudioSettings } from './src/settings/LMStudioSettings'; -interface PerplexedPluginSettings { - mySetting: string; - localLLMPath: string; - requestBodyTemplate: string; - perplexityRequestTemplate: string; - perplexityApiKey: string; - perplexicaEndpoint: string; - perplexityEndpoint: string; - lmStudioEndpoint: string; - lmStudioRequestTemplate: string; - defaultModel: string; - defaultOptimizationMode: string; - defaultFocusMode: string; - defaultLMStudioModel: string; +/** + * Main plugin class that extends the core functionality + */ +export default class PerplexedPlugin extends PerplexedPluginCore { + // Service instances with proper types + public promptsService!: PromptsService; + public perplexityService!: PerplexityService; + public perplexicaService!: PerplexicaService; + public lmStudioService!: LMStudioService; + + // Settings interfaces + public settings!: PerplexedSettings; + + // Service settings + public perplexitySettings!: PerplexitySettings; + public perplexicaSettings!: PerplexicaSettings; + public lmStudioSettings!: LMStudioSettings; + + // UI Elements + private statusBarItemEl: HTMLElement | null = null; + private ribbonIconEl: HTMLElement | null = null; + + /** + * Initialize service-specific settings + */ + private initializeServiceSettings(): void { + // Initialize Perplexity settings + this.perplexitySettings = { + perplexityApiKey: this.settings.perplexityApiKey || '', + perplexityEndpoint: this.settings.perplexityEndpoint || 'https://api.perplexity.ai', + promptsService: this.promptsService + }; + + // Initialize Perplexica settings + this.perplexicaSettings = { + perplexicaEndpoint: this.settings.perplexityEndpoint || 'https://api.perplexity.ai', + localLLMPath: this.settings.localLLMPath || '', + defaultModel: this.settings.defaultModel || 'gpt-3.5-turbo', + promptsService: this.promptsService + }; + + // Initialize LM Studio settings + this.lmStudioSettings = { + endpoints: { + baseUrl: this.settings.lmStudioEndpoint || 'http://localhost:1234', + chatCompletions: '/v1/chat/completions', + completions: '/v1/completions', + embeddings: '/v1/embeddings', + models: '/v1/models' + }, + defaultModel: this.settings.defaultLMStudioModel || 'ibm/granite-3.2-8b', + promptsService: this.promptsService + }; + } - // Prompt Settings - prompts: { - // System prompts - perplexitySystemPrompt: string; - perplexicaSystemPrompt: string; - lmStudioDefaultSystemPrompt: string; + /** + * Initialize all services with their respective settings + */ + private initializeServices(): void { + // Initialize Perplexity service + this.perplexityService = new PerplexityService(this.perplexitySettings); - // Placeholder text - perplexityQueryPlaceholder: string; - perplexicaQueryPlaceholder: string; - lmStudioQueryPlaceholder: string; - lmStudioSystemPromptPlaceholder: string; - articleTermPlaceholder: string; + // Initialize Perplexica service + this.perplexicaService = new PerplexicaService(this.perplexicaSettings); - // Descriptions and labels - deepResearchDescription: string; - imagesToggleDescription: string; - imagesToggleGenericDescription: string; - articleTermDescription: string; + // Initialize LM Studio service + this.lmStudioService = new LMStudioService(this.lmStudioSettings); + } + + /** + * Initialize the UI components + */ + private initializeUI(): void { + // Add status bar item if enabled + if (this.settings.showStatusBar) { + this.statusBarItemEl = this.addStatusBarItem(); + this.statusBarItemEl.setText('Perplexed'); + } - // Notices and messages - deepResearchLoadingNotice: string; - enterQuestionNotice: string; - enterTermNotice: string; + // Add ribbon icon + this.ribbonIconEl = this.addRibbonIcon( + 'zap', + 'Perplexed', + () => { + // Open command palette + // @ts-ignore - app is available in the Obsidian context + this.app.commands.executeCommandById('perplexed:open-command-palette'); + } + ); + } + + /** + * Initialize the plugin + */ + async onload() { + await super.onload(); - // Article generator template - articleGeneratorTemplate: string; + // Initialize prompts service first as it's used by other services + this.promptsService = new PromptsService(this.settings.prompts); - // Image prompts - imageReferencesPrompt: string; - }; + // Initialize service settings + this.initializeServiceSettings(); + + // Initialize services with their respective settings + this.initializeServices(); + + // Register commands and UI elements + this.registerCommands(); + this.initializeUI(); + + // Add settings tab + this.addSettingTab(new PerplexedSettingTab(this.app, this)); + + // Register service-specific commands + this.registerPerplexicaCommands(); + this.registerPerplexityCommands(); + this.registerLMStudioCommands(); + this.registerArticleGeneratorCommands(); + + console.log('Perplexed plugin loaded'); + } + + onunload() { + this.statusBarItemEl?.remove(); + this.ribbonIconEl?.remove(); + console.log('Perplexed plugin unloaded'); + } } const DEFAULT_SETTINGS: PerplexedPluginSettings = { @@ -210,103 +298,29 @@ Replace "{TERM}" with the actual vocabulary term in the prompt.`, } }; -export default class PerplexedPlugin extends Plugin { - public settings: PerplexedPluginSettings = DEFAULT_SETTINGS; - private statusBarItemEl: HTMLElement | null = null; - private ribbonIconEl: HTMLElement | null = null; - - // Service instances - private perplexityService!: PerplexityService; - private perplexicaService!: PerplexicaService; - private lmStudioService!: LMStudioService; - private promptsService!: PromptsService; - async onload(): Promise { - await this.loadSettings(); - - // Initialize prompts service - this.promptsService = new PromptsService(this.settings.prompts); - - // Initialize services - this.perplexityService = new PerplexityService({ - perplexityApiKey: this.settings.perplexityApiKey, - perplexityEndpoint: this.settings.perplexityEndpoint, - promptsService: this.promptsService, - requestTemplate: this.settings.perplexityRequestTemplate - }); - - this.perplexicaService = new PerplexicaService({ - perplexicaEndpoint: this.settings.perplexicaEndpoint, - localLLMPath: this.settings.localLLMPath, - defaultModel: this.settings.defaultModel, - promptsService: this.promptsService, - requestTemplate: this.settings.requestBodyTemplate - }); - - this.lmStudioService = new LMStudioService({ - lmStudioEndpoint: this.settings.lmStudioEndpoint, - promptsService: this.promptsService, - requestTemplate: this.settings.lmStudioRequestTemplate - }); - - // Debug: Log current settings - console.log('Current Perplexica Path:', this.settings.perplexicaEndpoint); - console.log('Full settings:', JSON.stringify(this.settings, null, 2)); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new PerplexedSettingTab(this.app, this)); - - // Register commands - this.registerPerplexicaCommands(); - this.registerPerplexityCommands(); - this.registerLMStudioCommands(); - this.registerArticleGeneratorCommands(); - } - - onunload(): void { - this.statusBarItemEl?.remove(); - this.ribbonIconEl?.remove(); - } - - private async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - - - public async saveSettings(): Promise { - try { - await this.saveData(this.settings); - } catch (error) { - console.error('Failed to save settings:', error); - new Notice('Failed to save settings'); + editor: Editor, + options?: { + max_tokens?: number; + temperature?: number; + top_p?: number; + system_prompt?: string; + return_images?: boolean; } - } - - // Delegate methods to services - public async queryPerplexity(query: string, model: string, stream: boolean, editor: Editor, options?: { - return_citations?: boolean; - return_images?: boolean; - return_related_questions?: boolean; - search_recency_filter?: string; - }): Promise { - await this.perplexityService.queryPerplexity(query, model, stream, editor, options); - } - - public async queryPerplexica(query: string, focusMode: string, optimizationMode: string, stream: boolean, editor: Editor, options?: { - return_images?: boolean; - }): Promise { - await this.perplexicaService.queryPerplexica(query, focusMode, optimizationMode, stream, editor, options); - } - - public async queryLMStudio(query: string, model: string, stream: boolean, editor: Editor, options?: { - max_tokens?: number; - temperature?: number; - top_p?: number; - system_prompt?: string; - return_images?: boolean; - }): Promise { - await this.lmStudioService.queryLMStudio(query, model, stream, editor, options); + ): Promise { + // Use the provided model or fall back to the default from settings + const modelToUse = model || this.settings.defaultLMStudioModel; + + // Log the model being used for debugging + console.log('Using model:', modelToUse); + + await this.lmStudioService.queryLMStudio( + query, + modelToUse, + stream, + editor, + options + ); } // Getter for prompts service @@ -431,7 +445,7 @@ export default class PerplexedPlugin extends Plugin { id: 'ask-lmstudio', name: 'Ask LM Studio', editorCallback: (editor: Editor) => { - const modal = new LMStudioModal(this.app, editor, this.lmStudioService, this.promptsService); + const modal = new LMStudioModal(this.app, editor, this, this.lmStudioService, this.promptsService); modal.open(); } }); @@ -608,36 +622,94 @@ class PerplexedSettingTab extends PluginSettingTab { perplexicaJsonSetting.settingEl.appendChild(perplexicaTextArea); // LM Studio Section - const lmStudioHeader = containerEl.createEl('h3', { text: 'LM Studio (Local Models)' }); + const lmStudioHeader = containerEl.createEl('h3', { text: 'LM Studio (Local LLM)' }); lmStudioHeader.style.color = 'var(--text-accent)'; containerEl.createEl('p', { - text: 'Configure settings for your local LM Studio installation with loaded models', + text: 'Configure settings for LM Studio local LLM service', cls: 'setting-item-description' }); + // Base URL setting new Setting(containerEl) - .setName('Endpoint') - .setDesc('API endpoint for your local LM Studio instance') + .setName('Base URL') + .setDesc('Base URL for LM Studio API (e.g., http://localhost:1234)') .addText(text => text - .setPlaceholder('http://localhost:1234/v1/chat/completions') + .setPlaceholder('http://localhost:1234') .setValue(this.plugin.settings.lmStudioEndpoint) - .onChange(async (value: string) => { - this.plugin.settings.lmStudioEndpoint = value; + .onChange(async (value) => { + this.plugin.settings.lmStudioEndpoint = value.trim(); await this.plugin.saveSettings(); - }) - ); + })); + // Endpoints configuration + containerEl.createEl('h4', { text: 'API Endpoints' }).style.marginTop = '20px'; + + // Chat Completions Endpoint + new Setting(containerEl) + .setName('Chat Completions') + .setDesc('Endpoint for chat completions') + .addText(text => text + .setValue('/v1/chat/completions') + .setDisabled(true)); + + // Completions Endpoint + new Setting(containerEl) + .setName('Completions') + .setDesc('Endpoint for completions') + .addText(text => text + .setValue('/v1/completions') + .setDisabled(true)); + + // Embeddings Endpoint + new Setting(containerEl) + .setName('Embeddings') + .setDesc('Endpoint for embeddings') + .addText(text => text + .setValue('/v1/embeddings') + .setDisabled(true)); + + // Models Endpoint + new Setting(containerEl) + .setName('Models') + .setDesc('Endpoint for listing available models') + .addText(text => text + .setValue('/v1/models') + .setDisabled(true)); + + // Default Model new Setting(containerEl) .setName('Default Model') - .setDesc('Default model name for LM Studio to use') + .setDesc('Default model to use with LM Studio') .addText(text => text .setPlaceholder('ibm/granite-3.2-8b') .setValue(this.plugin.settings.defaultLMStudioModel) - .onChange(async (value: string) => { - this.plugin.settings.defaultLMStudioModel = value; + .onChange(async (value) => { + this.plugin.settings.defaultLMStudioModel = value.trim(); await this.plugin.saveSettings(); - }) - ); + })); + + // Test Connection Button + const testConnectionSetting = new Setting(containerEl) + .setName('Test Connection') + .setDesc('Verify connection to LM Studio API'); + + testConnectionSetting.addButton(button => { + button.setButtonText('Test Connection') + .onClick(async () => { + try { + const response = await fetch(`${this.plugin.settings.lmStudioEndpoint}/v1/models`); + if (response.ok) { + new Notice('✅ Successfully connected to LM Studio API'); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: unknown) { + console.error('LM Studio connection test failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + new Notice(`❌ Failed to connect to LM Studio: ${errorMessage}`); + } + }); + }); // LM Studio Request Template const lmStudioJsonSetting = new Setting(containerEl) diff --git a/src/core/PerplexedPluginCore.ts b/src/core/PerplexedPluginCore.ts new file mode 100644 index 0000000..c2ba934 --- /dev/null +++ b/src/core/PerplexedPluginCore.ts @@ -0,0 +1,54 @@ +import { App, Plugin } from 'obsidian'; +import { PerplexedSettings, DEFAULT_SETTINGS } from '../settings/PerplexedSettings'; +import { PerplexedSettingTab } from '../settings/PerplexedSettings'; +import { LMStudioSettings, DEFAULT_LMSTUDIO_SETTINGS } from '../settings/LMStudioSettings'; + +export default class PerplexedPluginCore extends Plugin { + settings: PerplexedSettings; + lmStudioSettings: LMStudioSettings; + + async onload() { + await this.loadSettings(); + + // Add settings tab + this.addSettingTab(new PerplexedSettingTab(this.app, this)); + + // Register commands and other plugin initialization + this.registerCommands(); + } + + async loadSettings() { + // Load main settings + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + // Load LM Studio settings + this.lmStudioSettings = Object.assign( + {}, + DEFAULT_LMSTUDIO_SETTINGS, + await this.loadData() + ); + + // Save any defaults that were missing + await this.saveSettings(); + } + + async saveSettings() { + // Save main settings + await this.saveData(this.settings); + + // Save LM Studio settings + await this.saveData(this.lmStudioSettings); + } + + private registerCommands() { + // Register your commands here + // Example: + this.addCommand({ + id: 'perplexed-query', + name: 'Query Perplexed', + callback: () => { + // Command implementation + } + }); + } +} diff --git a/src/modals/LMStudioModal.ts b/src/modals/LMStudioModal.ts index ba99585..60b3f74 100644 --- a/src/modals/LMStudioModal.ts +++ b/src/modals/LMStudioModal.ts @@ -1,11 +1,13 @@ import { App, Modal, Notice, Editor } from 'obsidian'; import { LMStudioService, LMStudioOptions } from '../services/lmStudioService'; import { PromptsService } from '../services/promptsService'; +import type PerplexedPlugin from '../../main'; export class LMStudioModal extends Modal { private editor: Editor; private lmStudioService: LMStudioService; private promptsService: PromptsService; + private plugin: PerplexedPlugin; private queryInput!: HTMLTextAreaElement; private modelSelect!: HTMLSelectElement; private streamToggle!: HTMLInputElement; @@ -14,9 +16,10 @@ export class LMStudioModal extends Modal { private systemPromptInput!: HTMLTextAreaElement; private imagesToggle!: HTMLInputElement; - constructor(app: App, editor: Editor, lmStudioService: LMStudioService, promptsService: PromptsService) { + constructor(app: App, editor: Editor, plugin: PerplexedPlugin, lmStudioService: LMStudioService, promptsService: PromptsService) { super(app); this.editor = editor; + this.plugin = plugin; this.lmStudioService = lmStudioService; this.promptsService = promptsService; } @@ -43,10 +46,25 @@ export class LMStudioModal extends Modal { const modelDiv = form.createDiv({cls: 'setting-item'}); modelDiv.createEl('label', {text: 'Model'}); this.modelSelect = modelDiv.createEl('select', {cls: 'dropdown'}); - // Use common LM Studio models - these would be dynamically loaded ideally - ['ibm/granite-3.2-8b', 'microsoft/phi-4-reasoning-plus', 'google/gemma-3-12b', 'meta-llama/llama-3.2-3b-instruct', 'custom-model'].forEach(model => { + + // Available models - these would ideally be dynamically loaded + const models = [ + 'ibm/granite-3.2-8b', + 'microsoft/phi-4-reasoning-plus', + 'google/gemma-3-12b', + 'meta-llama/llama-3.2-3b-instruct', + 'custom-model' + ]; + + // Get default model from settings or use first model as fallback + const defaultModel = this.plugin?.settings?.defaultLMStudioModel || models[0]; + + // Create model options + models.forEach(model => { const option = this.modelSelect.createEl('option', {value: model, text: model}); - if (model === 'ibm/granite-3.2-8b') option.selected = true; + if (model === defaultModel) { + option.selected = true; + } }); // System prompt diff --git a/src/services/lmStudioService.ts b/src/services/lmStudioService.ts index 269e95f..ad13b77 100644 --- a/src/services/lmStudioService.ts +++ b/src/services/lmStudioService.ts @@ -1,4 +1,27 @@ import { Editor, Notice } from 'obsidian'; +import { LMStudioSettings } from '../settings/LMStudioSettings'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface LMStudioResponse { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: ChatMessage; + finish_reason: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} export interface LMStudioOptions { max_tokens?: number; @@ -8,19 +31,29 @@ export interface LMStudioOptions { return_images?: boolean; } -export interface LMStudioSettings { - lmStudioEndpoint: string; - promptsService?: any; // Will be PromptsService type - requestTemplate?: string; +export interface LMStudioEndpointConfig { + baseUrl: string; + chatCompletions: string; + completions: string; + embeddings: string; + models: string; } +// LMStudioSettings is now imported from LMStudioSettings.ts + export class LMStudioService { - private settings: LMStudioSettings; + + private readonly settings: LMStudioSettings; private promptsService: any; constructor(settings: LMStudioSettings) { this.settings = settings; - this.promptsService = settings.promptsService; + this.initializePromptsService(); + } + + private initializePromptsService() { + // Initialize prompts service if needed + // This can be implemented based on your specific requirements } private processContentWithImages(content: string): string { @@ -41,120 +74,12 @@ export class LMStudioService { } if (imageIndex > 0) { - console.log(`🔄 Processed ${imageIndex} image markers in LM Studio content`); + console.log(` Processed ${imageIndex} image markers in LM Studio content`); } return content; } - public async queryLMStudio( - query: string, - model: string, - stream: boolean, - editor: Editor, - options?: LMStudioOptions - ): Promise { - const timestamp = new Date().toISOString(); - - // Insert query header at the current cursor position - const cursor = editor.getCursor(); - console.log('Initial cursor position:', cursor); - - // Process query to handle multi-line content in callout - const processedQuery = query.split('\n').map(line => `> ${line}`).join('\n'); - - const headerText = `\n\n***\n> [!info] **LM Studio Query** (${timestamp})\n> **Question:**\n${processedQuery}\n> **Model:** ${model}\n> \n> ### **Response from ${model}**:\n\n`; - - // Insert the header at the cursor position - editor.replaceRange(headerText, cursor, cursor); - - // Calculate where the response content should start - const headerLines = headerText.split('\n'); - const lastLine = headerLines[headerLines.length - 1] || ''; - const responseCursor = { - line: cursor.line + headerLines.length - 1, - ch: lastLine.length - }; - - console.log('Response cursor position:', responseCursor); - - try { - const messages: any[] = []; - - // Add system message if provided - if (options?.system_prompt) { - messages.push({ role: 'system', content: options.system_prompt }); - } - - // Add user query - messages.push({ role: 'user', content: query }); - - // Use template if available, otherwise construct payload manually - let payload: any; - if (this.settings.requestTemplate) { - try { - const processedTemplate = this.promptsService?.processTemplate(this.settings.requestTemplate) || this.settings.requestTemplate; - payload = JSON.parse(processedTemplate); - // Override with current parameters - payload.model = model; - payload.messages = messages; - payload.stream = stream; - payload.max_tokens = options?.max_tokens ?? 2048; - payload.temperature = options?.temperature ?? 0.7; - payload.top_p = options?.top_p ?? 0.9; - } catch (error) { - console.warn('Failed to parse request template, using default payload:', error); - payload = { - model, - messages, - stream, - max_tokens: options?.max_tokens ?? 2048, - temperature: options?.temperature ?? 0.7, - top_p: options?.top_p ?? 0.9 - }; - } - } else { - payload = { - model, - messages, - stream, - max_tokens: options?.max_tokens ?? 2048, - temperature: options?.temperature ?? 0.7, - top_p: options?.top_p ?? 0.9 - }; - } - - const response = await fetch(this.settings.lmStudioEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - // LM Studio doesn't require API key for local access - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - let finalCursor = responseCursor; - - if (stream) { - await this.handleStreamingResponse(response, editor, responseCursor, options); - } else { - await this.handleNonStreamingResponse(response, editor, responseCursor, options); - } - - // Add separator at the final cursor position - editor.replaceRange('\n\n***\n', finalCursor); - - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - new Notice(`LM Studio Error: ${errorMsg}`); - editor.replaceRange(`\n**Error:** ${errorMsg}\n\n***\n`, editor.getCursor()); - } - } - private async handleStreamingResponse( response: Response, editor: Editor, @@ -165,55 +90,60 @@ export class LMStudioService { if (!reader) throw new Error('No response body'); let buffer = ''; - let currentPos = responseCursor; + let currentPos = { ...responseCursor }; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = new TextDecoder().decode(value); - buffer += chunk; - - // Process complete lines from buffer - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep incomplete line in buffer - - for (const line of lines) { - if (line.trim().startsWith('data: ')) { - const data = line.replace('data: ', '').trim(); - if (data === '[DONE]') continue; - - try { - const parsed = JSON.parse(data); - if (parsed.choices?.[0]?.delta?.content) { - let content = parsed.choices[0].delta.content; - - // Process images if enabled - if (options?.return_images) { - content = this.processContentWithImages(content); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = new TextDecoder().decode(value); + buffer += chunk; + + // Process complete lines from buffer + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.trim().startsWith('data: ')) { + const data = line.replace('data: ', '').trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + if (parsed.choices?.[0]?.delta?.content) { + let content = parsed.choices[0].delta.content; + + // Process images if enabled + if (options?.return_images) { + content = this.processContentWithImages(content); + } + + editor.replaceRange(content, currentPos); + // Update cursor position after insertion + const lines = content.split('\n'); + if (lines.length === 1) { + currentPos = { line: currentPos.line, ch: currentPos.ch + content.length }; + } else { + currentPos = { + line: currentPos.line + lines.length - 1, + ch: lines[lines.length - 1]?.length || 0 + }; + } + // Scroll to follow the new content + editor.scrollIntoView({ from: currentPos, to: currentPos }, true); + // Small delay to make scrolling smoother + await new Promise(resolve => setTimeout(resolve, 10)); } - - editor.replaceRange(content, currentPos); - // Update cursor position after insertion - const lines = content.split('\n'); - if (lines.length === 1) { - currentPos = { line: currentPos.line, ch: currentPos.ch + content.length }; - } else { - currentPos = { - line: currentPos.line + lines.length - 1, - ch: lines[lines.length - 1]?.length || 0 - }; - } - // Scroll to follow the new content - editor.scrollIntoView({ from: currentPos, to: currentPos }, true); - // Small delay to make scrolling smoother - await new Promise(resolve => setTimeout(resolve, 10)); + } catch (e) { + // Ignore JSON parse errors for partial chunks + console.error('Error parsing streaming chunk:', e); } - } catch (e) { - // Ignore JSON parse errors for partial chunks } } } + } finally { + reader.releaseLock(); } } @@ -234,4 +164,139 @@ export class LMStudioService { editor.replaceRange(processedContent, responseCursor); } + + private async makeRequest( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + data?: unknown + ): Promise { + const url = `${this.settings.endpoints.baseUrl}${endpoint}`; + + const headers: HeadersInit = new Headers({ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }); + + const options: RequestInit = { + method, + headers, + }; + + if (data !== undefined) { + options.body = JSON.stringify(data); + } + + try { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + new Notice(`Request failed: ${errorMessage}`); + throw error; + } + } + + async listModels(): Promise<{data: Array<{id: string}>, error?: string}> { + try { + const response = await this.makeRequest(this.settings.endpoints.models, 'GET'); + return await response.json(); + } catch (error) { + console.error('Error listing models:', error); + throw error; + } + } + + public async queryLMStudio( + editor: Editor, + model?: string, + messages: ChatMessage[] = [], + stream = true, + options: LMStudioOptions = {} + ): Promise { + if (!editor) { + throw new Error('Editor instance is required'); + } + + const cursor = editor.getCursor(); + const timestamp = new Date().toLocaleString(); + const modelToUse = model || this.settings.defaultModel || 'unknown-model'; + + // Process query for display + const processedQuery = messages.length > 0 + ? messages.map(message => `> ${message.content}`).join('\n') + : ''; + + const headerText = `\n\n***\n> [!info] **LM Studio Query** (${timestamp})\n> **Question:**\n${processedQuery}\n> **Model:** ${modelToUse}\n> \n> ### **Response from ${modelToUse}**:\n\n`; + + // Insert header and get response position + editor.replaceRange(headerText, cursor); + const headerLines = headerText.split('\n'); + const lastLine = headerLines[headerLines.length - 1] || ''; + const responseCursor = { + line: cursor.line + headerLines.length - 1, + ch: lastLine.length + }; + + try { + // Prepare messages array with system prompt if provided + const messagesToSend = [...messages]; + + if (options.system_prompt) { + messagesToSend.unshift({ + role: 'system', + content: options.system_prompt + }); + } + + // Build the request payload + let payload: Record = { + model: modelToUse, + messages: messagesToSend, + stream, + temperature: options.temperature ?? 0.7, + max_tokens: options.max_tokens ?? 2048, + top_p: options.top_p ?? 0.9 + }; + + // Apply request template if available + if (this.settings.requestTemplate) { + try { + const processedTemplate = this.promptsService?.processTemplate?.(this.settings.requestTemplate) || + this.settings.requestTemplate; + const templatePayload = JSON.parse(processedTemplate); + // Merge with template, allowing template to be overridden + Object.assign(payload, templatePayload); + } catch (error) { + console.warn('Failed to parse request template, using default payload:', error); + } + } + + // Make the API request + const response = await this.makeRequest( + this.settings.endpoints.chatCompletions, + 'POST', + payload + ); + + // Handle the response based on streaming preference + if (stream) { + await this.handleStreamingResponse(response, editor, responseCursor, options); + } else { + await this.handleNonStreamingResponse(response, editor, responseCursor, options); + } + + // Add a separator after the response + editor.replaceRange('\n\n---\n\n', editor.getCursor()); + } catch (error) { + console.error('Error querying LM Studio:', error); + // Show error to the user + const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; + editor.replaceRange(`\n\n${errorMessage}\n\n`, editor.getCursor()); + } + } + + } \ No newline at end of file diff --git a/src/settings/LMStudioSettings.ts b/src/settings/LMStudioSettings.ts new file mode 100644 index 0000000..ffd8dcc --- /dev/null +++ b/src/settings/LMStudioSettings.ts @@ -0,0 +1,113 @@ +import { App, Notice, Setting } from 'obsidian'; + +export interface LMStudioEndpointConfig { + baseUrl: string; + chatCompletions: string; + completions: string; + embeddings: string; + models: string; +} + +export interface LMStudioSettings { + endpoints: LMStudioEndpointConfig; + defaultModel: string; + requestTemplate?: string; +} + +export const DEFAULT_LMSTUDIO_SETTINGS: LMStudioSettings = { + endpoints: { + baseUrl: 'http://localhost:1234', + chatCompletions: '/v1/chat/completions', + completions: '/v1/completions', + embeddings: '/v1/embeddings', + models: '/v1/models' + }, + defaultModel: 'ibm/granite-3.2-8b', + requestTemplate: '' +}; + +export class LMStudioSettingSection { + constructor( + private readonly app: App, + private readonly containerEl: HTMLElement, + private settings: LMStudioSettings, + private readonly onSettingsChange: () => Promise + ) {} + + public display(): void { + this.containerEl.createEl('h3', { text: 'LM Studio Settings' }); + + this.addBaseUrlSetting(); + this.addDefaultModelSetting(); + this.addEndpointsInfo(); + this.addTestConnectionButton(); + } + + private addBaseUrlSetting(): void { + new Setting(this.containerEl) + .setName('Base URL') + .setDesc('The base URL of your LM Studio server (e.g., http://localhost:1234)') + .addText(text => text + .setValue(this.settings.endpoints.baseUrl) + .onChange(async (value: string) => { + this.settings.endpoints.baseUrl = value.trim(); + await this.onSettingsChange(); + })); + } + + private addDefaultModelSetting(): void { + new Setting(this.containerEl) + .setName('Default Model') + .setDesc('The default model to use for completions') + .addText(text => text + .setValue(this.settings.defaultModel) + .onChange(async (value: string) => { + this.settings.defaultModel = value; + await this.onSettingsChange(); + })); + } + + private addEndpointsInfo(): void { + const endpointsContainer = this.containerEl.createDiv('setting-item'); + endpointsContainer.createDiv({ text: 'API Endpoints', cls: 'setting-item-name' }); + const endpointsDesc = endpointsContainer.createDiv('setting-item-description'); + + const { endpoints } = this.settings; + const endpointList = [ + `Chat Completions: ${endpoints.chatCompletions}`, + `Completions: ${endpoints.completions}`, + `Embeddings: ${endpoints.embeddings}`, + `Models: ${endpoints.models}` + ]; + + endpointList.forEach(endpoint => { + endpointsDesc.createEl('div', { text: endpoint }); + }); + } + + private addTestConnectionButton(): void { + new Setting(this.containerEl) + .setName('Test Connection') + .setDesc('Test the connection to your LM Studio server') + .addButton(button => button + .setButtonText('Test') + .onClick(async () => { + try { + const url = `${this.settings.endpoints.baseUrl}${this.settings.endpoints.models}`; + const response = await fetch(url, { method: 'HEAD' }); + + if (response.ok) { + new Notice('✅ Successfully connected to LM Studio server'); + } else { + new Notice(`❌ Failed to connect: ${response.status} ${response.statusText}`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + new Notice(`❌ Connection error: ${message}`); + } + })); + } +} + +export default LMStudioSettingSection; + diff --git a/src/settings/PerplexedSettings.ts b/src/settings/PerplexedSettings.ts new file mode 100644 index 0000000..2417217 --- /dev/null +++ b/src/settings/PerplexedSettings.ts @@ -0,0 +1,132 @@ +import { App, PluginSettingTab, Setting } from 'obsidian'; +import PerplexedPlugin from '../../main'; + +export interface PerplexedSettings { + // General Settings + mySetting: string; + localLLMPath: string; + + // Perplexity Settings + perplexityApiKey: string; + perplexityEndpoint: string; + + // LM Studio Settings + lmStudioEndpoint: string; + defaultLMStudioModel: string; + + // Default Models and Modes + defaultModel: string; + defaultOptimizationMode: string; + defaultFocusMode: string; + + // Prompts + prompts: { + // System Prompts + perplexitySystemPrompt: string; + lmStudioDefaultSystemPrompt: string; + + // Placeholders + perplexityQueryPlaceholder: string; + lmStudioQueryPlaceholder: string; + }; +} + +export const DEFAULT_SETTINGS: PerplexedSettings = { + mySetting: 'default', + localLLMPath: 'http://host.docker.internal:3030/api/search', + + // Perplexity Defaults + perplexityApiKey: '', + perplexityEndpoint: 'https://api.perplexity.ai', + + // LM Studio Defaults + lmStudioEndpoint: 'http://localhost:1234', + defaultLMStudioModel: 'ibm/granite-3.2-8b', + + // Default Models and Modes + defaultModel: 'sonar-medium-online', + defaultOptimizationMode: 'balanced', + defaultFocusMode: 'search', + + // Default Prompts + prompts: { + perplexitySystemPrompt: 'You are a helpful AI assistant that provides accurate and concise responses.', + lmStudioDefaultSystemPrompt: 'You are a helpful AI assistant running locally.', + + perplexityQueryPlaceholder: 'Ask me anything...', + lmStudioQueryPlaceholder: 'Ask me anything...', + } +}; + +export class PerplexedSettingTab extends PluginSettingTab { + plugin: PerplexedPlugin; + + constructor(app: App, plugin: PerplexedPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl('h2', { text: 'Perplexed Plugin Settings' }); + + this.addGeneralSettings(containerEl); + this.addPerplexitySettings(containerEl); + this.addLMStudioSettings(containerEl); + } + + private addGeneralSettings(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'General Settings' }); + + new Setting(containerEl) + .setName('Local LLM Path') + .setDesc('Path to your local LLM API endpoint') + .addText(text => text + .setPlaceholder('http://localhost:3030/api/search') + .setValue(this.plugin.settings.localLLMPath) + .onChange(async (value) => { + this.plugin.settings.localLLMPath = value; + await this.plugin.saveSettings(); + })); + } + + private addPerplexitySettings(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'Perplexity Settings' }); + + new Setting(containerEl) + .setName('API Key') + .setDesc('Your Perplexity API key') + .addText(text => text + .setPlaceholder('Enter your API key') + .setValue(this.plugin.settings.perplexityApiKey) + .onChange(async (value) => { + this.plugin.settings.perplexityApiKey = value; + await this.plugin.saveSettings(); + })); + } + + private addLMStudioSettings(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'LM Studio Settings' }); + + new Setting(containerEl) + .setName('Endpoint') + .setDesc('LM Studio API endpoint (e.g., http://localhost:1234)') + .addText(text => text + .setValue(this.plugin.settings.lmStudioEndpoint) + .onChange(async (value) => { + this.plugin.settings.lmStudioEndpoint = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Default Model') + .setDesc('Default model to use with LM Studio') + .addText(text => text + .setValue(this.plugin.settings.defaultLMStudioModel) + .onChange(async (value) => { + this.plugin.settings.defaultLMStudioModel = value; + await this.plugin.saveSettings(); + })); + } +} diff --git a/src/types/obsidian.d.ts b/src/types/obsidian.d.ts index ee47d10..e4525d6 100644 --- a/src/types/obsidian.d.ts +++ b/src/types/obsidian.d.ts @@ -1,16 +1,22 @@ -import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian'; +import { App, Editor as ObsidianEditor, MarkdownView, Modal, Notice as ObsidianNotice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian'; declare module 'obsidian' { interface App { commands: any; } - interface Editor { + interface Editor extends ObsidianEditor { getSelection(): string; replaceSelection(text: string): void; - getCursor(): { line: number, ch: number }; + replaceRange(text: string, from: { line: number, ch: number }, to?: { line: number, ch: number }): void; + getCursor(from?: boolean): { line: number, ch: number }; setCursor(line: number, ch: number): void; lastLine(): number; + getLine(line: number): string; + } + + interface Notice extends ObsidianNotice { + // Extend if needed with additional methods } interface MarkdownView { @@ -21,4 +27,44 @@ declare module 'obsidian' { interface PluginManifest { dir: string; } + + // Additional utility types for LM Studio service + interface LMStudioOptions { + system_prompt?: string; + max_tokens?: number; + temperature?: number; + top_p?: number; + return_images?: boolean; + [key: string]: any; // For additional options + } + + interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'function'; + content: string; + name?: string; + function_call?: { + name: string; + arguments: string; + }; + } + + interface LMStudioResponse { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: string; + content: string; + }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + } } diff --git a/styles.css b/styles.css index ac3ecf5..4eb7e07 100644 --- a/styles.css +++ b/styles.css @@ -1 +1,72 @@ -.perplexity-modal .text-input{width:100%;margin:8px 0;padding:12px}.perplexity-modal .setting-item-description{font-size:12px;color:var(--text-muted);margin-top:5px}.perplexity-modal .setting-item-description.images-description{font-size:11px;margin-top:3px}.article-generator-modal .text-input{width:100%;margin:8px 0;padding:12px}.article-generator-modal .setting-item-description{font-size:12px;color:var(--text-muted);margin-top:5px}.article-generator-modal .term-description{display:block!important;margin-top:8px;margin-bottom:10px;width:100%!important;flex-basis:100%!important;order:2}.article-generator-modal .setting-item{flex-direction:column!important;align-items:flex-start!important}.article-generator-modal .setting-item>*{width:100%!important}.article-generator-modal .hidden-input{display:none}.perplexica-modal .text-input{width:100%;margin:8px 0;padding:12px}.lmstudio-modal .text-input{width:100%;margin:8px 0;padding:12px}.lmstudio-modal .system-prompt-input{width:100%;min-height:60px}.url-update-modal .text-input{width:100%;margin:8px 0;padding:12px} +/* src/styles/perplexity-modal.css */ +.perplexity-modal .text-input { + width: 100%; + margin: 8px 0; + padding: 12px; +} +.perplexity-modal .setting-item-description { + font-size: 12px; + color: var(--text-muted); + margin-top: 5px; +} +.perplexity-modal .setting-item-description.images-description { + font-size: 11px; + margin-top: 3px; +} + +/* src/styles/article-generator-modal.css */ +.article-generator-modal .text-input { + width: 100%; + margin: 8px 0; + padding: 12px; +} +.article-generator-modal .setting-item-description { + font-size: 12px; + color: var(--text-muted); + margin-top: 5px; +} +.article-generator-modal .term-description { + display: block !important; + margin-top: 8px; + margin-bottom: 10px; + width: 100% !important; + flex-basis: 100% !important; + order: 2; +} +.article-generator-modal .setting-item { + flex-direction: column !important; + align-items: flex-start !important; +} +.article-generator-modal .setting-item > * { + width: 100% !important; +} +.article-generator-modal .hidden-input { + display: none; +} + +/* src/styles/perplexica-modal.css */ +.perplexica-modal .text-input { + width: 100%; + margin: 8px 0; + padding: 12px; +} + +/* src/styles/lmstudio-modal.css */ +.lmstudio-modal .text-input { + width: 100%; + margin: 8px 0; + padding: 12px; +} +.lmstudio-modal .system-prompt-input { + width: 100%; + min-height: 60px; +} + +/* src/styles/url-update-modal.css */ +.url-update-modal .text-input { + width: 100%; + margin: 8px 0; + padding: 12px; +} + +/* src/styles/main.css */