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
This commit is contained in:
mpstaton 2025-07-26 16:51:15 +03:00
parent 8b3149ee67
commit c4c75ad68b
9 changed files with 894 additions and 322 deletions

View file

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

384
main.ts
View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.lmStudioService.queryLMStudio(query, model, stream, editor, options);
): Promise<void> {
// 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)

View file

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

View file

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

View file

@ -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<void> {
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<Response> {
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<void> {
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<string, unknown> = {
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());
}
}
}

View file

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

View file

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

View file

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

View file

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