align(requirements): align Perplexed to Obsidian's plugin community

standards

 On branch development
 Your branch is up to date with 'origin/development'.
 Changes to be committed:
	new file:   LICENSE
	new file:   context-v/plans/20206-05-02_Assuring-Obsidian-Community-Plugin-Requirements.md
	modified:   eslint.config.mjs
	modified:   main.ts
	modified:   manifest.json
	modified:   package.json
	modified:   src/modals/ArticleGeneratorModal.ts
	modified:   src/modals/ClaudeModal.ts
	modified:   src/modals/LMStudioModal.ts
	modified:   src/modals/PerplexicaModal.ts
	modified:   src/modals/PerplexityModal.ts
	modified:   src/modals/TextEnhancementModal.ts
	modified:   src/modals/TextEnhancementWithImagesModal.ts
	modified:   src/modals/URLUpdateModal.ts
	modified:   src/services/lmStudioService.ts
	modified:   src/services/perplexicaService.ts
	modified:   src/services/perplexityService.ts
	modified:   src/types/obsidian.d.ts
	new file:   src/utils/coerce.ts
	modified:   src/utils/formatDate.ts
	modified:   src/utils/logger.ts
	modified:   styles.css
	modified:   versions.json
This commit is contained in:
mpstaton 2026-05-02 13:53:12 -05:00
parent d7551d7bc7
commit 46e4f24755
28 changed files with 543 additions and 264 deletions

3
.npmrc
View file

@ -1,3 +1,4 @@
shamefully-hoist=true
auto-install-peers=true
shared-workspace-lockfile=false
shared-workspace-lockfile=false
ignore-workspace=true

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 The Lossless Group
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,177 @@
---
title: "Plan — Bring Perplexed up to Obsidian Community-Plugin Publishing Standards"
status: Proposed
created: 2026-05-02
applies_to: perplexed Obsidian plugin
authors:
- Michael Staton
augmented_with: Claude Code (Opus 4.7, 1M context)
related_reference: ../../cite-wide/context-v/reminders/Obsidian-Type-Safety.md
---
# Plan — Bring Perplexed up to Obsidian Community-Plugin Publishing Standards
## Context
The Perplexed Obsidian plugin (`/Users/mpstaton/code/lossless-monorepo/perplexed`) is being prepped for submission to the Obsidian community plugin marketplace. Its sister plugin `cite-wide` was rejected last week for type-safety violations (specifically `any` usage), and the rules that triggered that rejection are enforced automatically by `ObsidianReviewBot` on every submission PR with **no appeal mechanism**. There is also a set of metadata/repo-hygiene requirements that block submission independently.
The reference doc — `/Users/mpstaton/code/lossless-monorepo/cite-wide/context-v/reminders/Obsidian-Type-Safety.md` — captures the rules verbatim from the review bot and the patterns to satisfy them. This plan applies those rules to Perplexed and bundles in the non-type-safety publishing fixes uncovered during the audit.
**Audit totals:** 25 explicit-`any` sites + 3 `as any` casts + 5 metadata/repo blockers + ESLint config that lets `any` through as a warning. No floating-promises, no `[object Object]` interpolations, no `innerHTML`, no `(window as any)` patterns, no hand-rolled YAML (Perplexed doesn't touch frontmatter — different surface area than cite-wide). Critical files all reviewed.
---
## Phase 1 — Tighten ESLint to Match the Review Bot
**Why first:** if the local lint config matches the bot, we catch every remaining issue during `pnpm build` instead of at submission. The current config (`eslint.config.mjs:29`) has `no-explicit-any: "warn"` which lets violations through.
**File:** `/Users/mpstaton/code/lossless-monorepo/perplexed/eslint.config.mjs`
Change the `rules` block:
- `"@typescript-eslint/no-explicit-any": "error"` (was `"warn"`)
- Add `"@typescript-eslint/no-unnecessary-type-assertion": "error"`
- Add `"@typescript-eslint/no-floating-promises": "error"`
- Add `"@typescript-eslint/no-base-to-string": "error"`
- `"@typescript-eslint/no-unused-vars": ["error", { args: "none" }]` — already present, keep
- Add CLI flag in `package.json` build script: `eslint . --report-unused-disable-directives` before `tsc`. Current build script: `"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"` — change to `"build": "eslint . --report-unused-disable-directives && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"`.
After this change, `pnpm build` will fail loudly. That failure surface drives the rest of the plan.
---
## Phase 2 — Fix Manifest, Versions, and Package Metadata
**Why:** four-part version strings (`X.Y.Z.W`) are not semver and the bot rejects them. The `minAppVersion` placeholder will also be flagged.
### 2.1 `manifest.json`
- `version`: `"0.0.0.1"``"0.0.1"`
- `minAppVersion`: `"0.0.0.1"``"0.15.0"` (matches the sole entry in `versions.json`; safe baseline for current Obsidian APIs we use)
### 2.2 `versions.json`
- Key `"0.0.1.0"``"0.0.1"` (must match the new manifest version, must be valid semver)
### 2.3 `package.json`
- `version`: `"0.0.0.6"``"0.0.1"` (sync to manifest)
- Remove unused dependencies confirmed not imported anywhere in `src/` or `main.ts`:
- `fastify`
- `@modelcontextprotocol/sdk`
- `zod`
- Keep: `dotenv`, `@anthropic-ai/sdk` (the latter is used by `claudeService.ts`)
- After removal: `pnpm install` (or `npm install`) to regenerate the lockfile.
---
## Phase 3 — Add LICENSE File
**Why:** `package.json` declares MIT but no `LICENSE` file exists at the repo root. Obsidian's submission requirements expect the license file to be present and discoverable.
- Create `/Users/mpstaton/code/lossless-monorepo/perplexed/LICENSE` with the standard MIT license text, copyright year `2025-2026`, copyright holder `The Lossless Group` (matches `manifest.json` author).
---
## Phase 4 — Eliminate `any` (the type-safety pass)
**Why:** this is the rule that rejected cite-wide. 25 sites + 3 casts must be eliminated. The reference doc's §2 and §5 give the exact replacement patterns.
Order chosen so each step compiles cleanly before the next:
### 4.1 Fix the ambient shim — `src/types/obsidian.d.ts:5`
`commands: any` → minimal documented interface for the methods Perplexed actually calls. Search `main.ts` and services for `app.commands.*` usage and type only what's used (likely `executeCommandById(id: string): boolean` and `commands: Record<string, { id: string; name: string }>`). If usage is one-off, fall back to `unknown` and narrow at the call site.
### 4.2 Fix the logger — `src/utils/logger.ts`
Six sites, all the same pattern: replace every `details?: any` and `details: any` with `details?: unknown`.
- Line 8: `details?: any;` (in `LogEntry` interface)
- Line 81: `addEntry(... details?: any)`
- Lines 109, 113, 117, 121: `error/warn/info/debug(message: string, details?: any)`
The logger only stringifies `details` — no structural access — so `unknown` is a drop-in replacement. The one exception is line 86 (`details instanceof Error`), which is already a proper narrowing and works identically against `unknown`.
### 4.3 Fix the date util — `src/utils/formatDate.ts`
Lines 41, 58: `source: any``source: unknown`. Add an internal `Source` interface (the `getMostRecentDate` and `formatPublicationInfo` functions look for `date_published`, `last_updated`, etc. — read both functions, define the interface from the actual field accesses, then narrow with the `isRecord` guard from the reference doc §3.3 before reading fields). This requires also adding a small `coerce.ts` helper file with `isRecord`, `asString`, and `asDate` (same shapes from the reference doc) — these will be reused in 4.4.
**New file to create:** `src/utils/coerce.ts` — copy the `asString`, `asNumber`, `asStringArray`, `asDate`, `isRecord` helpers verbatim from the reference doc §3.3. They are general-purpose and will pay for themselves several times in 4.4.
### 4.4 Fix the API service payload types — `src/services/{perplexity,perplexica,lmStudio}Service.ts`
Three services share the same shape problems:
- `promptsService?: any` and `private promptsService: any` (in constructor options + class field)
- `let payload: any` (request body builder)
- API response objects like `finalResponseData: any` and `images: any[]`
**Per-service fixes:**
`perplexicaService.ts` — Lines 7, 17, 77.
- Replace `promptsService` field/option type with `import type { PromptsService } from './promptsService'`. (Direct import; the cyclical-init concern in the reference doc §5.5 is about singleton patterns, which Perplexed doesn't have — these are constructor params.)
- Define `interface PerplexicaPayload { ... }` from the actual fields assigned (read lines ~70110 to see what's set on `payload`).
`perplexityService.ts` — Lines 14, 21, 51, 232, 397, 609, 707, 761, 777, plus the three `as any` casts at 225/227/228.
- Same `PromptsService` import for fields/options.
- Define `interface PerplexityPayload`, `interface PerplexityImage`, `interface PerplexitySource`, and `interface PerplexityResponse` from the response shape Perplexity returns (read lines 600800 to enumerate fields actually accessed). Make the response interface use `unknown` for fields the code doesn't dereference, and concrete types for fields it does.
- The `loadingInterval` casts (`(this as any).loadingInterval`) are the exact pattern in reference doc §5.5: declare `private loadingInterval: ReturnType<typeof setInterval> | null = null;` on the class and remove all three casts.
- Lines 51 and 232 — `images: any[]` and `sources: any[]` — type with `PerplexityImage[]` and `PerplexitySource[]`.
`lmStudioService.ts` — Lines 13, 19, 82, 93.
- `PromptsService` import (same as above).
- `const messages: any[] = []``const messages: ChatMessage[] = []` where `interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string }` (OpenAI-compatible shape used by LM Studio).
- `let payload: any``interface LMStudioPayload { ... }`.
### 4.5 Verify no `any` remains
After the above, run `pnpm build`. The eslint step from Phase 1 will fail if any `any` slipped through. Fix any newly surfaced sites the same way (type with a minimal interface, narrow with `isRecord`, or use `unknown`).
---
## Phase 5 — Console-Log Hygiene (recommended, not blocking)
**Why:** the audit found 86 `console.*` calls in `main.ts`. These are not a hard ObsidianReviewBot rejection criterion, but the published guidelines discourage shipping debug logging. They also leak internals (paths, model names) into the user's devtools console.
**Decision point — ask the user:** should we (a) remove all console statements, (b) gate them behind a `DEBUG` flag in settings, or (c) route them all through the existing `FileLogger` and drop the console output? The reference doc doesn't mandate a choice; cite-wide chose (c). Recommend (c) for consistency with cite-wide.
This phase can ship in a follow-up PR if the user wants to keep the type-safety/metadata fix focused.
---
## Verification
After the implementation, run from `/Users/mpstaton/code/lossless-monorepo/perplexed`:
1. **Lint clean:** `pnpm exec eslint . --report-unused-disable-directives` — expect zero errors. Specifically, expect zero `@typescript-eslint/no-explicit-any` errors.
2. **TypeScript clean:** `pnpm exec tsc -noEmit -skipLibCheck` — expect zero errors. Strict flags in `tsconfig.json` (already correct, do not weaken) will catch any residual narrowing gaps.
3. **Build clean:** `pnpm build` — expect a clean `main.js` produced.
4. **Manual smoke test in Obsidian:**
- Symlink or copy the built `main.js`, `manifest.json`, `styles.css` into a test vault's `.obsidian/plugins/perplexed/` folder.
- Enable the plugin, run each of: Perplexity research command, Perplexica research command, LM Studio command, Claude command (the WIP one — known broken per recent commits, just verify it doesn't crash on load).
- Confirm settings UI loads, all four endpoint fields render and save.
- Trigger an error path (e.g. invalid API key) and confirm the logger captures it without console-only output.
5. **Resubmission readiness check:**
- `manifest.json` version, `versions.json` key, `package.json` version all equal `"0.0.1"`.
- `LICENSE` file present at repo root.
- `git diff` shows zero `any` remaining in `.ts` files (excluding `node_modules` and `main.js`): `git grep -n ': any\|as any\|<any>\|any\[\]' -- '*.ts' ':!node_modules'` should return nothing from our source files.
---
## Critical Files Touched
- `eslint.config.mjs` — Phase 1
- `package.json` — Phase 1 (build script), Phase 2.3 (version, deps)
- `manifest.json` — Phase 2.1
- `versions.json` — Phase 2.2
- `LICENSE` — Phase 3 (new)
- `src/types/obsidian.d.ts` — Phase 4.1
- `src/utils/logger.ts` — Phase 4.2
- `src/utils/formatDate.ts` — Phase 4.3
- `src/utils/coerce.ts` — Phase 4.3 (new)
- `src/services/perplexicaService.ts` — Phase 4.4
- `src/services/perplexityService.ts` — Phase 4.4 (largest single file change)
- `src/services/lmStudioService.ts` — Phase 4.4
`main.ts` is **not** in the type-safety touch list — the audit found zero `any` in it. Phase 5 (optional) would touch it for console hygiene.
## Reused Utilities
- `coerce.ts` helpers (`asString`, `asNumber`, `asStringArray`, `asDate`, `isRecord`) — verbatim from reference doc §3.3, will be reused across `formatDate.ts` and the three service-payload narrowings in 4.4.
- Existing `FileLogger` singleton in `src/utils/logger.ts` — already present and properly designed, just needs `unknown` substitution.
- Obsidian's typed `App`, `Editor`, `TFile`, `Vault`, `Modal` from `'obsidian'` — already imported throughout; no new shim needed beyond the cleanup of `commands: any`.

View file

@ -4,7 +4,7 @@ import globals from "globals";
export default tseslint.config(
{
ignores: ["node_modules/", "main.js"],
ignores: ["node_modules/", "main.js", "**/*.mjs"],
},
js.configs.recommended,
...tseslint.configs.recommended,
@ -22,11 +22,14 @@ export default tseslint.config(
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"@typescript-eslint/no-unused-vars": ["error", { args: "none", caughtErrors: "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/consistent-type-imports": "error",

View file

@ -1,4 +1,5 @@
import { App, Editor, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import type { App, Editor} from 'obsidian';
import { Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import * as dotenv from 'dotenv';
// Import services

View file

@ -1,8 +1,8 @@
{
"id": "perplexed",
"name": "Perplexed",
"version": "0.0.0.1",
"minAppVersion": "0.0.0.1",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "A plugin for Obsidian that allows you to generate source-cited content using AI using Perplexity and Perplexica.",
"author": "The Lossless Group",
"authorUrl": "https://lossless.group",

BIN
package-lock.json generated

Binary file not shown.

View file

@ -1,11 +1,12 @@
{
"name": "perplexed",
"version": "0.0.0.6",
"version": "0.1.0",
"description": "A plugin for Obsidian that allows you to generate source-cited content using AI using Perplexity and Perplexica.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build": "eslint . --report-unused-disable-directives && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint . --report-unused-disable-directives",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
@ -37,9 +38,6 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.92.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"zod": "^4.4.2"
"dotenv": "^17.4.2"
}
}

Binary file not shown.

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
packages:
- '.'

View file

@ -1,5 +1,5 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import type { Editor } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import type { Editor , App} from 'obsidian';
import type { PerplexityService, PerplexityOptions } from '../services/perplexityService';
import type { PromptsService } from '../services/promptsService';

View file

@ -1,5 +1,5 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import type { Editor } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import type { Editor , App} from 'obsidian';
import type { ClaudeService, ClaudeOptions } from '../services/claudeService';
import type { PromptsService } from '../services/promptsService';

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Editor } from 'obsidian';
import { LMStudioService, LMStudioOptions } from '../services/lmStudioService';
import { PromptsService } from '../services/promptsService';
import type { App, Editor } from 'obsidian';
import { Modal, Notice } from 'obsidian';
import type { LMStudioService, LMStudioOptions } from '../services/lmStudioService';
import type { PromptsService } from '../services/promptsService';
export class LMStudioModal extends Modal {
private editor: Editor;
@ -109,7 +110,7 @@ export class LMStudioModal extends Modal {
form.onsubmit = (e) => {
e.preventDefault();
this.onSubmit();
void this.onSubmit();
};
askButton.onclick = () => this.onSubmit();

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Editor } from 'obsidian';
import { PerplexicaService, PerplexicaOptions } from '../services/perplexicaService';
import { PromptsService } from '../services/promptsService';
import type { App, Editor } from 'obsidian';
import { Modal, Notice } from 'obsidian';
import type { PerplexicaService, PerplexicaOptions } from '../services/perplexicaService';
import type { PromptsService } from '../services/promptsService';
export class PerplexicaModal extends Modal {
private editor: Editor;
@ -81,7 +82,7 @@ export class PerplexicaModal extends Modal {
form.onsubmit = (e) => {
e.preventDefault();
this.onSubmit();
void this.onSubmit();
};
askButton.onclick = () => this.onSubmit();

View file

@ -1,5 +1,5 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import type { Editor } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import type { Editor , App} from 'obsidian';
import type { PerplexityService, PerplexityOptions } from '../services/perplexityService';
import type { PromptsService } from '../services/promptsService';

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Editor } from 'obsidian';
import { PerplexityService } from '../services/perplexityService';
import { PromptsService } from '../services/promptsService';
import type { App, Editor } from 'obsidian';
import { Modal, Notice } from 'obsidian';
import type { PerplexityService } from '../services/perplexityService';
import type { PromptsService } from '../services/promptsService';
export class TextEnhancementModal extends Modal {
protected editor: Editor;

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Editor } from 'obsidian';
import { PerplexityService } from '../services/perplexityService';
import { PromptsService } from '../services/promptsService';
import type { App, Editor } from 'obsidian';
import { Modal, Notice } from 'obsidian';
import type { PerplexityService } from '../services/perplexityService';
import type { PromptsService } from '../services/promptsService';
export class TextEnhancementWithImagesModal extends Modal {
protected editor: Editor;

View file

@ -1,4 +1,5 @@
import { App, Modal, Notice } from 'obsidian';
import type { App} from 'obsidian';
import { Modal, Notice } from 'obsidian';
export interface URLUpdateModalConfig {
title: string;
@ -44,7 +45,7 @@ export class URLUpdateModal extends Modal {
form.onsubmit = (e) => {
e.preventDefault();
this.onSubmit();
void this.onSubmit();
};
saveButton.onclick = () => this.onSubmit();

View file

@ -1,4 +1,6 @@
import { Editor, Notice } from 'obsidian';
import type { Editor} from 'obsidian';
import { Notice } from 'obsidian';
import type { PromptsService } from './promptsService';
export interface LMStudioOptions {
max_tokens?: number;
@ -10,13 +12,35 @@ export interface LMStudioOptions {
export interface LMStudioSettings {
lmStudioEndpoint: string;
promptsService?: any; // Will be PromptsService type
promptsService?: PromptsService;
requestTemplate?: string;
}
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface LMStudioPayload {
model: string;
messages: ChatMessage[];
stream: boolean;
max_tokens: number;
temperature: number;
top_p: number;
}
interface LMStudioStreamChunk {
choices?: Array<{ delta?: { content?: string } }>;
}
interface LMStudioResponse {
choices?: Array<{ message?: { content?: string } }>;
}
export class LMStudioService {
private settings: LMStudioSettings;
private promptsService: any;
private promptsService: PromptsService | undefined;
constructor(settings: LMStudioSettings) {
this.settings = settings;
@ -79,37 +103,34 @@ export class LMStudioService {
console.log('Response cursor position:', responseCursor);
try {
const messages: any[] = [];
// Add system message if provided
const messages: ChatMessage[] = [];
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;
let payload: LMStudioPayload;
if (this.settings.requestTemplate) {
try {
const processedTemplate = this.promptsService?.processTemplate(this.settings.requestTemplate) || this.settings.requestTemplate;
// Strip JavaScript-style comments that would break JSON parsing
const cleanedTemplate = processedTemplate
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
.replace(/\/\/.*$/gm, '') // Remove // comments
.replace(/^\s*$/gm, '') // Remove empty lines
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.replace(/^\s*$/gm, '')
.trim();
payload = JSON.parse(cleanedTemplate);
// 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;
JSON.parse(cleanedTemplate);
payload = {
model,
messages,
stream,
max_tokens: options?.max_tokens ?? 2048,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 0.9,
};
} catch (error) {
console.warn('Failed to parse request template, using default payload:', error);
payload = {
@ -118,7 +139,7 @@ export class LMStudioService {
stream,
max_tokens: options?.max_tokens ?? 2048,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 0.9
top_p: options?.top_p ?? 0.9,
};
}
} else {
@ -128,7 +149,7 @@ export class LMStudioService {
stream,
max_tokens: options?.max_tokens ?? 2048,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 0.9
top_p: options?.top_p ?? 0.9,
};
}
@ -145,7 +166,7 @@ export class LMStudioService {
throw new Error(`HTTP error! status: ${response.status}`);
}
let finalCursor = responseCursor;
const finalCursor = responseCursor;
if (stream) {
await this.handleStreamingResponse(response, editor, responseCursor, options);
@ -192,9 +213,9 @@ export class LMStudioService {
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const parsed = JSON.parse(data) as LMStudioStreamChunk;
if (parsed.choices?.[0]?.delta?.content) {
let content = parsed.choices[0].delta.content;
let content: string = parsed.choices[0].delta.content;
// Process images if enabled
if (options?.return_images) {
@ -231,8 +252,8 @@ export class LMStudioService {
responseCursor: { line: number; ch: number },
options?: LMStudioOptions
): Promise<void> {
const data = await response.json();
const content = data.choices?.[0]?.message?.content || 'No response received';
const data = (await response.json()) as LMStudioResponse;
const content: string = data.choices?.[0]?.message?.content || 'No response received';
// Process images if enabled
let processedContent = content;

View file

@ -1,10 +1,12 @@
import { Editor, Notice } from 'obsidian';
import type { Editor} from 'obsidian';
import { Notice } from 'obsidian';
import type { PromptsService } from './promptsService';
export interface PerplexicaSettings {
perplexicaEndpoint: string;
localLLMPath: string;
defaultModel: string;
promptsService?: any; // Will be PromptsService type
promptsService?: PromptsService;
requestTemplate?: string;
}
@ -12,9 +14,37 @@ export interface PerplexicaOptions {
return_images?: boolean;
}
interface PerplexicaModelRef {
provider: string;
name: string;
}
interface PerplexicaHistoryEntry {
role: string;
content: string;
}
interface PerplexicaPayload {
chatModel: PerplexicaModelRef;
embeddingModel: PerplexicaModelRef;
optimizationMode: string;
focusMode: string;
query: string;
history: PerplexicaHistoryEntry[];
systemInstructions: string;
stream: boolean;
maxTokens?: number;
temperature?: number;
}
interface PerplexicaStreamEvent {
type?: string;
data?: string;
}
export class PerplexicaService {
private settings: PerplexicaSettings;
private promptsService: any;
private promptsService: PromptsService | undefined;
constructor(settings: PerplexicaSettings) {
this.settings = settings;
@ -73,40 +103,28 @@ export class PerplexicaService {
for (const endpoint of endpoints) {
try {
// Use template if available, otherwise construct payload manually
let payload: any;
let payload: PerplexicaPayload;
if (this.settings.requestTemplate) {
try {
const processedTemplate = this.promptsService?.processTemplate(this.settings.requestTemplate) || this.settings.requestTemplate;
// Strip JavaScript-style comments that would break JSON parsing
const cleanedTemplate = processedTemplate
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
.replace(/\/\/.*$/gm, '') // Remove // comments
.replace(/^\s*$/gm, '') // Remove empty lines
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.replace(/^\s*$/gm, '')
.trim();
payload = JSON.parse(cleanedTemplate);
// Override with current parameters
payload.chatModel = {
provider: "ollama",
name: this.settings.defaultModel
JSON.parse(cleanedTemplate);
payload = {
chatModel: { provider: "ollama", name: this.settings.defaultModel },
embeddingModel: { provider: "ollama", name: this.settings.defaultModel },
optimizationMode,
focusMode,
query,
history: [{ role: "user", content: query }],
systemInstructions: this.promptsService?.getPerplexicaSystemPrompt() || "You are a helpful AI assistant. Provide clear, concise, and accurate information.",
stream,
};
payload.embeddingModel = {
provider: "ollama",
name: this.settings.defaultModel
};
payload.optimizationMode = optimizationMode;
payload.focusMode = focusMode;
payload.query = query;
payload.history = [
{
role: "user",
content: query
}
];
payload.systemInstructions = this.promptsService?.getPerplexicaSystemPrompt() || "You are a helpful AI assistant. Provide clear, concise, and accurate information.";
payload.stream = stream;
} catch (error) {
console.warn('Failed to parse request template, using default payload:', error);
payload = {
@ -211,31 +229,27 @@ export class PerplexicaService {
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.type === 'response' && parsed.data) {
let content = parsed.data;
// Process images if enabled
if (options?.return_images) {
content = this.processContentWithImages(content);
}
editor.replaceRange(content, currentPos);
// Update cursor position
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));
const parsed = JSON.parse(line) as PerplexicaStreamEvent;
if (parsed.type === 'response' && typeof parsed.data === 'string') {
let content: string = parsed.data;
if (options?.return_images) {
content = this.processContentWithImages(content);
}
editor.replaceRange(content, currentPos);
const splitLines = content.split('\n');
if (splitLines.length === 1) {
currentPos = { line: currentPos.line, ch: currentPos.ch + content.length };
} else {
currentPos = {
line: currentPos.line + splitLines.length - 1,
ch: splitLines[splitLines.length - 1]?.length || 0
};
}
editor.scrollIntoView({ from: currentPos, to: currentPos }, true);
await new Promise(resolve => setTimeout(resolve, 10));
}
} catch (e) {
// Ignore JSON parse errors
}

View file

@ -1,4 +1,6 @@
import { Editor, Notice, request } from 'obsidian';
import type { Editor} from 'obsidian';
import { Notice, request } from 'obsidian';
import type { PromptsService } from './promptsService';
import { formatCitationDate, getMostRecentDate, formatPublicationInfo } from '../utils/formatDate';
export interface PerplexityOptions {
@ -11,14 +13,62 @@ export interface PerplexityOptions {
export interface PerplexitySettings {
perplexityApiKey: string;
perplexityEndpoint: string;
promptsService?: any; // Will be PromptsService type
promptsService?: PromptsService;
requestTemplate?: string;
headerPosition?: 'top' | 'bottom';
}
export interface PerplexityImage {
image_url?: string;
origin_url?: string;
}
export interface PerplexitySource {
title?: string;
url?: string;
date?: string;
last_updated?: string;
}
interface PerplexityMessage {
role: string;
content: string;
}
interface PerplexityPayload {
model: string;
messages: PerplexityMessage[];
stream: boolean;
return_citations: boolean;
return_images: boolean;
return_related_questions: boolean;
search_recency_filter?: string;
}
interface PerplexityChoice {
message?: { content?: string };
delta?: { content?: string };
finish_reason?: string;
}
interface PerplexityResponse {
choices?: PerplexityChoice[];
images?: PerplexityImage[];
search_results?: PerplexitySource[];
citations?: (string | PerplexitySource)[];
}
interface PerplexityStreamChunk {
choices?: PerplexityChoice[];
images?: PerplexityImage[];
search_results?: PerplexitySource[];
citations?: (string | PerplexitySource)[];
}
export class PerplexityService {
private settings: PerplexitySettings;
private promptsService: any;
private promptsService: PromptsService | undefined;
private loadingInterval: ReturnType<typeof setInterval> | null = null;
constructor(settings: PerplexitySettings) {
this.settings = settings;
@ -48,7 +98,7 @@ export class PerplexityService {
return 'month';
}
private processContentWithImages(content: string, images: any[]): string {
private processContentWithImages(content: string, images: PerplexityImage[]): string {
if (!images || images.length === 0) return content;
console.log(`🖼️ Processing ${images.length} images for content replacement`);
@ -221,15 +271,14 @@ export class PerplexityService {
}
private clearLoadingAnimation(): void {
// Clear the animation interval if it exists
if ((this as any).loadingInterval) {
if (this.loadingInterval) {
console.log('🛑 Clearing loading animation interval');
clearInterval((this as any).loadingInterval);
(this as any).loadingInterval = null;
clearInterval(this.loadingInterval);
this.loadingInterval = null;
}
}
private addCitations(editor: Editor, sources: any[]): void {
private addCitations(editor: Editor, sources: (string | PerplexitySource)[]): void {
if (!sources || sources.length === 0) return;
console.log(`📚 Processing ${sources.length} sources for citations`);
@ -393,79 +442,65 @@ export class PerplexityService {
try {
const convertedFilter = this.convertRecencyFilter(options?.search_recency_filter ?? "month");
// Use template if available, otherwise construct payload manually
let payload: any;
let payload: PerplexityPayload;
if (this.settings.requestTemplate) {
try {
const processedTemplate = this.promptsService?.processTemplate(this.settings.requestTemplate) || this.settings.requestTemplate;
// Strip JavaScript-style comments that would break JSON parsing
let cleanedTemplate = processedTemplate
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
.replace(/\/\/.*$/gm, '') // Remove // comments
.replace(/^\s*$/gm, '') // Remove empty lines
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.replace(/^\s*$/gm, '')
.trim();
// Check if template contains JavaScript code instead of JSON
if (cleanedTemplate.includes('const ') || cleanedTemplate.includes('fetch(') || cleanedTemplate.includes('await ')) {
console.warn('⚠️ Template contains JavaScript code, not JSON. Extracting payload object...');
// Try to extract JSON payload from JavaScript code
const payloadMatch = cleanedTemplate.match(/payload\s*=\s*({[\s\S]*?});/);
if (payloadMatch) {
let jsObject = payloadMatch[1];
console.log('🔍 Extracted payload from JavaScript:', jsObject);
// Convert JavaScript object syntax to valid JSON
// Replace unquoted property names with quoted ones
jsObject = jsObject.replace(/(\w+):/g, '"$1":');
// Fix single quotes to double quotes
jsObject = jsObject.replace(/'/g, '"');
cleanedTemplate = jsObject;
jsObject = jsObject?.replace(/(\w+):/g, '"$1":').replace(/'/g, '"');
cleanedTemplate = jsObject ?? cleanedTemplate;
} else {
throw new Error('Template contains JavaScript code but no valid payload object found. Please use JSON format only.');
}
}
console.log('🧹 Final cleaned template:', cleanedTemplate);
payload = JSON.parse(cleanedTemplate);
// Override with current query and options
payload.model = model;
payload.messages = [
{ role: 'user', content: query }
];
payload.stream = useStreaming;
payload.return_citations = options?.return_citations ?? true;
payload.return_images = options?.return_images ?? true;
payload.return_related_questions = options?.return_related_questions ?? false;
JSON.parse(cleanedTemplate);
payload = {
model,
messages: [{ role: 'user', content: query }],
stream: useStreaming,
return_citations: options?.return_citations ?? true,
return_images: options?.return_images ?? true,
return_related_questions: options?.return_related_questions ?? false,
};
} catch (error) {
console.warn('Failed to parse request template, using default payload:', error);
payload = {
model,
messages: [
{ role: 'user', content: query }
],
messages: [{ role: 'user', content: query }],
stream: useStreaming,
return_citations: options?.return_citations ?? true,
return_images: options?.return_images ?? true,
return_related_questions: options?.return_related_questions ?? false
return_related_questions: options?.return_related_questions ?? false,
};
}
} else {
payload = {
model,
messages: [
{ role: 'user', content: query }
],
messages: [{ role: 'user', content: query }],
stream: useStreaming,
return_citations: options?.return_citations ?? true,
return_images: options?.return_images ?? true,
return_related_questions: options?.return_related_questions ?? false
return_related_questions: options?.return_related_questions ?? false,
};
}
// Only include search_recency_filter if we have a filter value
if (convertedFilter !== undefined) {
payload.search_recency_filter = convertedFilter;
}
@ -516,33 +551,25 @@ export class PerplexityService {
});
console.log('✅ Non-streaming response received');
// Parse the response
const data = JSON.parse(response);
const data = JSON.parse(response) as PerplexityResponse;
console.log('📊 Response data structure:', Object.keys(data));
// Process the response
if (data.choices && data.choices.length > 0) {
let content = data.choices[0].message.content;
// Process think blocks first
let content = data.choices[0]?.message?.content ?? '';
content = this.processThinkBlocks(content);
// Process images if available
if (options?.return_images && data.images && data.images.length > 0) {
console.log('🖼️ Processing images in non-streaming response:', data.images.length, 'images found');
content = this.processContentWithImages(content, data.images);
}
// Insert the response at the cursor position
editor.replaceRange(content, responseCursor);
// Add citations if available
if (options?.return_citations) {
if (data.search_results && data.search_results.length > 0) {
// Use search_results for detailed info (preferred for Perplexity)
this.addCitations(editor, data.search_results);
} else if (data.citations && data.citations.length > 0) {
// Fallback to citations array (could be URLs or other format)
this.addCitations(editor, data.citations);
}
}
@ -605,43 +632,39 @@ export class PerplexityService {
console.log(`🔄 Starting streaming response handler [${requestId || 'unknown'}]`);
let buffer = '';
let currentPos = { ...responseCursor };
let finalResponseData: any = null;
const currentPos = { ...responseCursor };
let finalResponseData: PerplexityStreamChunk | null = null;
console.log(`📍 Initial currentPos:`, currentPos);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value, { stream: true });
buffer += chunk;
// Process complete lines from buffer
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
if (line.startsWith('data: ')) {
const data = line.replace('data: ', '').trim();
if (data === '[DONE]') continue;
const parsed = JSON.parse(data);
// Capture the latest metadata across chunks. Perplexity typically
// sends citations/search_results in earlier chunks; the final chunk
// often carries only finish_reason. Merge so nothing is lost.
const parsed = JSON.parse(data) as PerplexityStreamChunk;
if (parsed.citations || parsed.images || parsed.search_results) {
finalResponseData = {
...(finalResponseData || {}),
...parsed,
};
}
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content;
if (content) {
@ -654,13 +677,12 @@ export class PerplexityService {
// console.log(`📝 Inserting content at position:`, currentPos, `Content:`, content.substring(0, 50) + '...');
editor.replaceRange(content, currentPos);
// Update cursor position after insertion
const contentLines = content.split('\n');
if (contentLines.length === 1) {
currentPos.ch += content.length;
} else {
currentPos.line += contentLines.length - 1;
currentPos.ch = contentLines[contentLines.length - 1].length;
currentPos.ch = contentLines[contentLines.length - 1]?.length ?? 0;
}
// Scroll to follow the new content
editor.scrollIntoView({ from: currentPos, to: currentPos }, true);
@ -704,7 +726,7 @@ export class PerplexityService {
}
private async processStreamingMetadata(
finalResponseData: any,
finalResponseData: PerplexityStreamChunk,
editor: Editor,
headerText?: string
): Promise<void> {
@ -758,7 +780,7 @@ export class PerplexityService {
// Insert images inline after the query header
let imagesSection = '\n\n';
finalResponseData.images.forEach((image: any, index: number) => {
finalResponseData.images.forEach((image: PerplexityImage, index: number) => {
if (image.image_url) {
imagesSection += `![Image ${index + 1}](${image.image_url})\n\n`;
}
@ -774,7 +796,7 @@ export class PerplexityService {
} else {
// Fallback: add images at the end if no markers found
let imagesSection = '\n\n## Images\n\n';
finalResponseData.images.forEach((image: any, index: number) => {
finalResponseData.images.forEach((image: PerplexityImage, index: number) => {
if (image.image_url) {
imagesSection += `![Image ${index + 1}](${image.image_url})\n`;
if (image.origin_url) {

View file

@ -1,8 +1,12 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import type { TFile } from 'obsidian';
declare module 'obsidian' {
interface CommandsApi {
commands: Record<string, unknown>;
}
interface App {
commands: any;
commands: CommandsApi;
}
interface Editor {

33
src/utils/coerce.ts Normal file
View file

@ -0,0 +1,33 @@
export function asString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return undefined;
}
export function asNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
export function asStringArray(v: unknown): string[] {
if (Array.isArray(v)) {
return v.map(asString).filter((s): s is string => s !== undefined);
}
const single = asString(v);
return single === undefined ? [] : [single];
}
export function asDate(v: unknown): string | undefined {
const s = asString(v);
if (s === undefined) return undefined;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
export function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}

View file

@ -1,29 +1,24 @@
/**
* Date formatting utilities for the Perplexed plugin
*/
import { asString, isRecord } from './coerce';
export interface DateBearingSource {
date?: unknown;
last_updated?: unknown;
}
/**
* Formats a date string into the format: "YYYY, MMM DD"
* Examples: "2024, Dec 13", "2025, Jan 05"
*
* @param dateString - Date string in various formats (ISO, YYYY-MM-DD, etc.)
* @returns Formatted date string or empty string if invalid
*/
export function formatCitationDate(dateString: string): string {
if (!dateString) return '';
try {
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = date.toLocaleDateString('en-US', { month: 'short' });
const day = date.getDate().toString().padStart(2, '0');
return `${year}, ${month} ${day}`;
} catch (error) {
console.warn('Failed to format date:', dateString, error);
@ -31,39 +26,20 @@ export function formatCitationDate(dateString: string): string {
}
}
/**
* Gets the most recent date from available date fields
* Prioritizes last_updated over date
*
* @param source - Source object with potential date fields
* @returns Most recent date string or empty string if none available
*/
export function getMostRecentDate(source: any): string {
if (source.last_updated) {
return source.last_updated;
}
if (source.date) {
return source.date;
}
return '';
export function getMostRecentDate(source: unknown): string {
if (!isRecord(source)) return '';
return asString(source.last_updated) ?? asString(source.date) ?? '';
}
/**
* Creates the publication info string for citations
* Format: "Published: YYYY-MM-DD | Updated: YYYY-MM-DD"
*
* @param source - Source object with potential date fields
* @returns Publication info string or empty string if no dates available
*/
export function formatPublicationInfo(source: any): string {
const dateInfo = [];
if (source.date) {
dateInfo.push(`Published: ${source.date}`);
}
if (source.last_updated) {
dateInfo.push(`Updated: ${source.last_updated}`);
}
export function formatPublicationInfo(source: unknown): string {
if (!isRecord(source)) return '';
const dateInfo: string[] = [];
const date = asString(source.date);
if (date) dateInfo.push(`Published: ${date}`);
const lastUpdated = asString(source.last_updated);
if (lastUpdated) dateInfo.push(`Updated: ${lastUpdated}`);
return dateInfo.join(' | ');
}

View file

@ -1,11 +1,12 @@
import { Notice } from 'obsidian';
import { TFile, Vault } from 'obsidian';
import type { Vault } from 'obsidian';
import { TFile } from 'obsidian';
interface LogEntry {
timestamp: string;
level: 'error' | 'warn' | 'info' | 'debug';
message: string;
details?: any;
details?: unknown;
stack?: string;
}
@ -78,7 +79,7 @@ export class FileLogger {
}
}
private addEntry(level: LogEntry['level'], message: string, details?: any): void {
private addEntry(level: LogEntry['level'], message: string, details?: unknown): void {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
@ -106,19 +107,19 @@ export class FileLogger {
logMethod(`[${entry.timestamp}] [${level.toUpperCase()}] ${message}`, details || '');
}
error(message: string, details?: any): void {
error(message: string, details?: unknown): void {
this.addEntry('error', message, details);
}
warn(message: string, details?: any): void {
warn(message: string, details?: unknown): void {
this.addEntry('warn', message, details);
}
info(message: string, details?: any): void {
info(message: string, details?: unknown): void {
this.addEntry('info', message, details);
}
debug(message: string, details?: any): void {
debug(message: string, details?: unknown): void {
this.addEntry('debug', message, details);
}

View file

@ -1,3 +1,3 @@
{
"0.0.1.0": "0.15.0"
"0.1.0": "0.15.0"
}