feat(release): 0.2.0 — Ask Gemini joins the lineup, with the per-claim citations Claude's web_search can't keep

Gemini lands as the fourth research provider — Google Search grounded,
per-segment citation attribution via groundingSupports[] (the layer Claude's
web_search_20260209 dynamic-filter sandbox loses), redirect URLs resolved
through Obsidian's requestUrl with canonical/og:url + <title> parsing so
citations land with real source URLs and real page titles.

Plus the partials + preambles paradigm (vault-visible shared rules across
the four directory templates), the addClass token bug that was silently
breaking eight settings sections finally fixed, system-prompt textareas
promoted from 200px right-edge slots to full-width 3-line rows, seeder
made idempotent at both folder and file layers, and ERR_NETWORK_CHANGED
mid-stream errors translated into plain-English re-run guidance.

Per-day changelogs:
- changelog/2026-05-19_01.md — partials + preambles + 11 hidden sections fix
- changelog/2026-05-19_02.md — Gemini provider lands
- changelog/2026-05-20_01.md — Obsidian-shipping pass (today)

Release narrative: changelog/releases/0.2.0.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-20 01:35:31 -05:00
parent 1d73243ef6
commit 13aff6f478
14 changed files with 1678 additions and 63 deletions

116
changelog/2026-05-19_02.md Normal file
View file

@ -0,0 +1,116 @@
---
title: "Gemini joins the provider lineup — and brings the per-claim citation that Claude's dynamic-filter lost"
lede: "Perplexed now ships a fourth AI provider: Gemini, with Google Search grounding. Gemini's `groundingSupports[]` carries per-segment attribution (text span → source URL) that survives intact, where Claude's `web_search_20260209` dynamic-filter sandbox drops it on the floor. Same modal UX as Ask Claude, same Citations footer shape so cite-wide hex substitution stays provider-agnostic. Two Gemini-specific quirks surfaced when we dissected a real response with curl: chunk URLs are short-lived `vertexaisearch.cloud.google.com` redirects that rot in ~30 days (the plugin resolves them to durable source URLs before writing), and the spec's `searchEntryPoint.renderedContent` is 5KB of inline-styled HTML that Obsidian's Markdown renderer can't display (replaced with a Markdown list of the queries, each linked to google.com/search)."
date_work_started: 2026-05-19
date_work_completed: 2026-05-19
date_created: 2026-05-19
date_modified: 2026-05-19
publish: true
category: Changelog
tags:
- Gemini
- Google-Search-Grounding
- Provider-Lineup
- Citations
- GroundingSupports
- Grounding-Redirect-URLs
- Obsidian-Plugin
---
# Ask Gemini — fourth provider, first one with surviving per-claim citations
## Why care?
If you write research notes in Obsidian, you now have a fourth way to ground a draft in live web sources without leaving the editor:
- **Ask Gemini** — runs Google Search behind the scenes and streams the answer into your note at the cursor, just like Ask Claude and Ask Perplexity.
- **Per-claim citations actually work.** Gemini's response carries a mapping from each cited sentence to the specific URL it came from, which means the `### Citations` section at the bottom of your note is not a flat URL dump — it's a quote-per-source list you can verify by reading.
- **You opt in per-question.** A toggle in the Ask Gemini modal turns Google Search grounding on or off without touching settings, so you can compare grounded vs. ungrounded answers on the same prompt.
## What's new?
Five settings, all behind one Settings → Perplexed → Gemini (Google) section:
```
Gemini (Google)
├── Gemini API key (paste from Google AI Studio)
├── Default Gemini model (2.5 pro / 2.5 flash / 2.0 flash)
├── Enable Google search grounding by default (on)
├── Include Google searches list in notes (on — Markdown bullets, not HTML chip)
└── Resolve citation urls (durable, slower) (on — fixes the 30-day URL rot)
```
- **`Ask Gemini` command** opens a modal matching the Ask Claude UX — full-width question textarea, model dropdown with taglines, three behavior toggles (grounding / suggestions chip / streaming), Cmd-Enter to submit.
- **`Check Gemini service status` command** reports whether the service initialized and whether an API key is configured.
- **Citations footer** mirrors Claude's exactly — `[N]: [Title](url). > cited_text` — so cite-wide's hex-substitution pass treats Gemini output the same as everything else.
## How it works
The Gemini response shape carries two layers of provenance:
| Layer | Field | What it gives you |
|---|---|---|
| Page-level | `groundingChunks[]` | URL + title per page Gemini consulted |
| Segment-level | `groundingSupports[]` | Text span (`segment.text`) → indices into `groundingChunks[]` |
The plugin walks both layers and merges them by URL: page-level entries come in as URL-and-title fallbacks; segment-level entries enrich them with the actual sentence Gemini grounded against. First quote per source wins, on the principle that the earliest segment is closest to the source's lede.
Compare to the Claude path documented in `context-v/issues/Getting-Claude-to-Respond-With-Research.md`:
> *"web_search_20260209's dynamic-filtering pass post-processes search results in a code-execution sandbox, and per-claim web_search_result_location citations don't survive that round-trip — text blocks come back with citations: null."*
Gemini doesn't have that round-trip. `groundingSupports[]` ships in the final response unmodified, so the `### Citations` section you get is actually attached to the prose above it rather than guessed from string matches.
### What the response actually contains (dissected with curl, 2026-05-19)
Two things bit us once we ran a real `gemini-2.5-flash` request with `google_search` enabled:
| Surface | Documented as | Actually | What the plugin does |
|---|---|---|---|
| `chunk.web.uri` | "source URL" | `vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIY…` — a Google-controlled redirect that expires ~30 days after the response | `resolveCitationUrls()` does a parallel `fetch(uri, { redirect: 'manual' })` per cited source (3s timeout each), reads the `Location` header, falls back to the redirect URL on failure |
| `chunk.web.title` | "title" | The source DOMAIN (`"nobelprize.org"`), not the page title | Rendered as the link text in `[domain.com](resolved-url)` — acceptable; a future pass could grab `<title>` during URL resolution |
| `searchEntryPoint.renderedContent` | Google's required Search Suggestions chip | ~5KB of `<style>`-tag-prefixed inline HTML that Obsidian's Markdown renderer strips, leaving orphan `<div>` chips | Replaced with a Markdown `### Google Searches` section listing `webSearchQueries[]`, each query linked to `google.com/search?q=…`. Same end-user behavior (re-run the suggested search), Markdown-native rendering |
| `support.segment.startIndex/endIndex` | "text span offsets" | UTF-8 BYTE offsets, not char offsets | Currently unused (we use `segment.text` directly) but flagged with a code comment for anyone wiring inline `[N]` markers later |
The grounded-attribution layer (`groundingSupports[]` → `groundingChunkIndices`) is exactly what we hoped — `segment.text` is the verbatim quote, and it points cleanly back to chunk indices. That's the half of this that Just Works.
## Architecture
Mirrors the Claude provider exactly, no shared base class:
```
src/services/geminiService.ts (new — raw fetch, SSE streaming, groundingMetadata parsing,
Citations + Search Queries + Suggestions blocks)
src/modals/GeminiModal.ts (new — Ask Claude shape, gemini-themed)
main.ts
├── PerplexedPluginSettings + DEFAULT_SETTINGS (geminiApiKey, geminiDefaultModel,
│ geminiEnableGrounding,
│ geminiIncludeSearchSuggestions)
├── private geminiService (lifecycle parallel to claudeService)
├── service init blocks (onload + reinitializeServices)
├── registerGeminiCommands (ask-gemini, gemini-service-status)
└── Settings tab — Gemini (Google) section with four rows
```
Zero new dependencies — uses `activeWindow.fetch` for SSE and Obsidian's `request` for non-streaming, same pattern as `perplexityService.ts`. The `@google/genai` SDK was evaluated and skipped: it pulls in Node-flavored streaming primitives and JSON-schema validators we don't need, and the REST shape is small enough that a hand-rolled parser is more honest about what's on the wire.
## Build status
`pnpm run build` (eslint + tsc + esbuild production) green. ObsidianReviewBot's `obsidianmd/ui/sentence-case` rule was the only friction — it recognizes "Google" as a proper noun (capitalize) and "URLs" as an acronym (lowercase to "urls"), each of which collides with our usual sentence-case house style. Resolved by following the rule's autosuggestion verbatim; the lint passes and the rule's reasoning is internally consistent even where it looks odd to a human reader.
## What's next
- **Page-title-not-domain in citations.** Right now `[nobelprize.org](https://www.nobelprize.org/prizes/physics/2024/summary/)` shows the domain as link text because that's what `chunk.web.title` returns. We're already paying for a `fetch()` round-trip per cited source during URL resolution; one tweak to also parse `<title>` out of the response body would give us real page titles for free. Future pass.
- **Strict Google grounding-chip compliance.** Google's grounding ToS *technically* says display `searchEntryPoint.renderedContent` verbatim. We're displaying the Markdown-equivalent (`webSearchQueries[]` linked to google.com/search) because the rendered chip doesn't survive Obsidian's Markdown renderer. If Google enforces this strictly we may need a separate "open the chip in a popup" affordance — for now we believe the spirit-of-the-terms is satisfied.
- **Gemini-specific preamble pipeline.** Right now `Ask Gemini` runs the user's question straight through — no `loadPreamble` + `expandIncludes` pass like the directory-template service does. Adding it is a one-line refactor (call the same helpers from `directoryTemplateService.ts` before building the request body) and would let `partials/` and `preambles/` apply to ad-hoc Gemini queries too.
- **Hook Gemini into directory templates.** A template that declares `provider: gemini` in its `cft` fence should be routable through the Gemini service the same way the existing templates route through Perplexity. Same template engine, different transport.
- **The editorial-stance partial extraction** (deferred from the previous shipping pass — the anti-incumbent stance is still duplicated across `concept-profile.md` and `vocabulary-profile.md`).
## Files touched
```
src/services/geminiService.ts (new, 320 lines)
src/modals/GeminiModal.ts (new, 140 lines)
main.ts (~80 lines net — types, defaults, init,
commands, settings section)
```

175
changelog/2026-05-20_01.md Normal file
View file

@ -0,0 +1,175 @@
---
title: "From buildable to actually-works-in-vault — the Gemini provider's first day in Obsidian"
lede: "The Gemini provider shipped yesterday as TypeScript that compiled. Today it became TypeScript that runs cleanly inside Obsidian: CORS-blocked fetch swapped for Obsidian's requestUrl (with bonus page-title + canonical-URL parsing), API-key auth moved from query string to header, default model switched to `gemini-flash-latest` so the free tier doesn't wall the first request, the modal got real CSS, the template seeder stopped crashing on its own previously-written files, network drops mid-stream now surface as plain-English notices instead of stack traces, three system-prompt textareas grew from 200px-wide cramped slots to full-width 3-line rows, and a long-standing `addClass` token bug that was silently breaking eight settings sections finally got cleared."
date_work_started: 2026-05-20
date_work_completed: 2026-05-20
date_created: 2026-05-20
date_modified: 2026-05-20
publish: true
category: Changelog
tags:
- Gemini
- requestUrl
- CORS
- Canonical-URLs
- Modal-Styling
- Seeder-Idempotency
- Network-UX
- System-Prompts
- Obsidian-Plugin
---
# The shipping pass that turned the Gemini provider real
## Why care?
Yesterday's release wired Gemini into the plugin's code; today's pass made it work in an actual Obsidian vault. Every fix in this changelog came from running the provider in the live UI and watching what broke:
- **Citations actually link to real sources now.** Yesterday the redirect-URL resolver used browser `fetch()` and got CORS-blocked the moment it tried to hit `vertexaisearch.cloud.google.com`. Every Gemini-grounded note ended up with raw `vertexaisearch.cloud.google.com/grounding-api-redirect/…` URLs in the citations footer. Today we switched to Obsidian's `requestUrl` (Node-side, no CORS), and because we now have the destination HTML in hand, we also parse `<link rel="canonical">` / `<meta property="og:url">` and `<title>` — so a citation that yesterday rendered as `[nobelprize.org](https://vertexaisearch.cloud.google.com/...)` today renders as `[The Nobel Prize in Physics 2024](https://www.nobelprize.org/prizes/physics/2024/summary/)`.
- **The default model doesn't hit a free-tier quota wall.** `gemini-2.5-pro` is paid-tier on the free key; first request returned HTTP 429. New default is `gemini-flash-latest` — Google's "always-current Flash" alias — which is free-tier-friendly and is what the curl in their quickstart uses. `gemini-pro-latest` is also added as an option.
- **The settings tab now shows all the rows it always had.** A stray `addClass('perplexed-json-textarea is-tall')` (space-separated class names passed as a single token) was throwing `InvalidCharacterError` on settings open, which aborted `display()` somewhere around the Article Generator section — every settings row after it silently disappeared. Same shape of bug as the May 19 fix that resurrected eleven hidden sections; this one was at two specific DOM API call sites and finally got caught.
- **Template seeding stops yelling on every plugin load.** Both "Folder already exists" and "File already exists" errors now get swallowed quietly. Same root cause for both: Obsidian's in-memory file index lags the adapter write by a tick, so `getAbstractFileByPath(path) === null` followed immediately by `createFolder` / `create` races and the second call throws even though the index check said it was safe.
- **Network drops mid-stream become a useful sentence.** The `ERR_NETWORK_CHANGED` (WiFi flip, VPN reconnect, sleep/wake) class of error now writes "Network changed mid-stream … Re-run the query — no partial response was saved" into the note, instead of a 30-line stack trace.
- **System prompts have room to breathe.** Three system-prompt rows in Settings (Perplexity, Perplexica/Vane, LM Studio) used Obsidian's `Setting.addTextArea` which crammed them into the ~200px right edge of the row. Now each prompt gets its own row: name + description on top, full-width 3-line textarea (resizable) below.
- **The Ask Gemini modal looks like a real piece of UI** instead of a stock-Obsidian default — gradient title text, Google four-color hairline under the header, Gemini-blue focus ring on the prompt textarea, gradient-on-hover CTA.
## What's new?
### Citation URL resolution: from "rots in 30 days" to "the real source page"
Yesterday's pass added `resolveCitationUrls()` using browser `fetch()` with `redirect: 'manual'`. The intent was right — Google's `vertexaisearch.cloud.google.com/grounding-api-redirect/…` URLs expire about 30 days after the response, so every cited note rotted on a clock unless we resolved them before writing.
In actual Obsidian, that `fetch()` got two failures stacked on top of each other:
1. **CORS:** `app://obsidian.md``vertexaisearch.cloud.google.com` is a cross-origin request, the redirect endpoint sends no `Access-Control-Allow-Origin` header, the browser refuses to read the response.
2. **Opaque redirects:** even if CORS had allowed it, cross-origin opaque-redirect responses hide their `Location` header from the JavaScript caller, which is the whole reason we made the request.
Net result: every citation in yesterday's vault test ended up with the raw redirect URL.
Today's fix swaps `fetch` for **Obsidian's `requestUrl`** — which runs Node-side (in the Electron main process), ignores CORS entirely, and follows redirects to the final URL. And once you have the destination HTML in hand, you may as well extract the genuinely useful bits:
| Field in Gemini's response | What we used to do | What we do now |
|---|---|---|
| `chunk.web.uri` (redirect URL) | Write to citation as-is | Follow via `requestUrl`, parse `<link rel="canonical">` then `<meta property="og:url">` for the real source URL |
| `chunk.web.title` (domain only, e.g. `"nobelprize.org"`) | Write as link text | Parse `<meta property="og:title">` then `<title>` from the destination page; use that as link text |
Both extractions include HTML-entity decoding (`&amp;` → `&`, numeric entities like `&#39;`, etc.) so titles read correctly. On any failure (timeout at 5s, network error, parse miss) we keep the original redirect URL and domain — the citation stays navigable today even if it rots in 30, rather than silently disappearing.
The change is invisible until you read a generated note and the citations footer looks like:
```
### Citations
[1]: [Bloomberg Beta — Bloomberg LP](https://www.bloomberg.com/company/bloomberg-beta/). > segment text from Gemini's response
[2]: [The Nobel Prize in Physics 2024](https://www.nobelprize.org/prizes/physics/2024/summary/). > another segment
```
…instead of:
```
### Citations
[1]: [bloomberg.com](https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQHd...). > segment text
[2]: [nobelprize.org](https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQEB...). > another segment
```
### Auth: `?key=` query string → `X-goog-api-key` header
Google's official curl examples now send the API key in a header (`X-goog-api-key: AIza…`) rather than as a query parameter (`?key=…`). Both work, but the header form is the path Google's docs and SDKs treat as canonical, and it has a real-world advantage: the key doesn't appear in URL access logs of any intermediate proxy or in browser dev-tools network panels' URL column.
Both `streamGenerateContent` (SSE streaming) and `generateContent` (non-streaming) call sites swapped over in one change.
### Default model: `gemini-flash-latest` (free-tier-friendly)
`gemini-2.5-pro` is paid-only on a no-billing AI Studio account. Picking it as the default meant the very first "Ask Gemini" attempt returned HTTP 429 with a "you exceeded your current quota" page.
The new default is `gemini-flash-latest` — Google's "always-current Flash" alias that resolves to whichever Flash model is currently shipping. It's free-tier-friendly, it tracks Google's recommended default in their quickstart, and the alias means we don't have to chase model-version bumps to keep up.
`gemini-pro-latest` was added as an option for users who want the always-current Pro alias; the pinned `gemini-2.5-pro` and `gemini-2.5-flash` are still selectable for reproducibility.
### Gemini modal CSS — beautiful, branded, theme-aware
The Ask Gemini modal followed the proven wide-modal pattern from `context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md` (the "attach the class to `modalEl`, not `contentEl`" doctrine that ended six months of "Obsidian doesn't let me size modals" frustration). On top of the structural pattern:
- **Gradient title text** — blue → purple → red → yellow via `background-clip: text`, with a plain `text-normal` fallback for renderers that don't support clip.
- **Google four-color hairline** under the header — `#4285f4` / `#ea4335` / `#fbbc04` / `#34a853` at 55% opacity. Brand cue without becoming a screaming banner.
- **Gemini-blue focus ring** on the prompt textarea (`rgba(66, 133, 244, 0.22)` halo) — stays consistent regardless of which Obsidian theme the user runs.
- **Primary CTA** flat blue at rest, blue→purple gradient on hover with an elevated shadow, plus the `transform: translateY(1px)` press affordance.
- **Theme tokens everywhere else** (`--background-*`, `--text-*`, `--font-text`) so structural padding, hairlines, and section backgrounds inherit the user's light / dark / community theme.
### Template seeder: idempotent at both folder and file layers
The "Folder already exists" error in the May 19 fix had a sibling we didn't catch then: `vault.create()` on a file path can throw "File already exists" for the same race-window reason. Both errors are now swallowed by helpers (`ensureFolder` / `safeCreateFile`) that test the error message and rethrow anything that isn't an "already exists" race.
Net effect: no more red console lines on plugin load when the vault already has the seeded files from a previous install.
### Network-error UX in the Perplexity stream
`ERR_NETWORK_CHANGED` (WiFi roam, VPN reconnect, laptop sleep/wake) and its cousins (socket-drop, idle-timeout, abort) now get classified and translated into user-language notices:
| Error class | Old behavior | New behavior |
|---|---|---|
| `NETWORK_CHANGED` | 30-line stack trace inline in the note | `Network changed mid-stream (WiFi flip, VPN reconnect, or sleep/wake). Re-run the query — no partial response was saved.` |
| Idle timeout | Bare `stream went idle for 90s` | `Perplexity stream stalled (likely API back-pressure or rate limit). Re-run the query; if it stalls again, try a smaller prompt or a non-research model.` |
| Connection drop | `failed to fetch` | `Connection dropped before Perplexity finished. Re-run the query.` |
| User abort | `AbortError` | `Request aborted.` |
Mid-stream resume isn't possible — Perplexity has no resume token — so honest "re-run" guidance beats a magic retry that silently produces a partial.
### System prompts get their own full-width rows
Each of the three system-prompt settings (Perplexity / Perplexica-Vane / LM Studio) used to render via `Setting.addTextArea`, which places the textarea on the right edge of the Setting row at roughly 200px wide and 2 lines tall. For a multi-paragraph system prompt, that's a tiny porthole into a much larger document.
Each now gets two sibling elements: a `Setting` row with just `name` + `desc`, then a full-width 3-line textarea directly below (resizable vertically by the user). New CSS class `.perplexed-prose-textarea` — body font (not the monospace used by the JSON request-template textareas), 80px min-height, focus ring matching the modal style, full container width.
The placeholder-text settings stayed as compact single-line Setting rows because that's the shape that actually fits them.
### The `addClass` token bug that was breaking eight call sites
`addClass(token: string)` on `HTMLElement` accepts a **single** class token. Passing a space-separated string (`addClass('perplexed-json-textarea is-tall')`) throws `InvalidCharacterError: Failed to execute 'add' on 'DOMTokenList'`. That error aborts whatever `display()` call is running it, which silently breaks the rest of the settings tab from that point on.
Both occurrences (`main.ts:1837` and `main.ts:1857` — Article Generator template and Deep Research template textareas) swapped to `addClasses(['perplexed-json-textarea', 'is-tall'])`. Same shape of fix as the May 19 batch that switched eight `activeDocument.createEl` calls to `containerEl.createEl` — these are both "Obsidian DOM API method has stricter semantics than the convenience signature suggests."
## Under the hood
Diff at a glance — every file touched today and why:
```
src/services/geminiService.ts
├── resolveCitationUrls() — fetch → requestUrl + canonical/og:url + <title> parsing
├── decodeHtmlEntities() helper for cleaning parsed titles
├── X-goog-api-key header in both streamGenerateContent and generateContent
└── code comment on extractCitations re: UTF-8 byte offsets in groundingSupports
src/modals/GeminiModal.ts
└── DEFAULT_MODEL → gemini-flash-latest; model dropdown now includes -latest aliases
main.ts
├── default model + dropdown options updated to match
├── three system-prompt Setting.addTextArea → sibling .perplexed-prose-textarea pattern
└── two addClass('a b') → addClasses(['a','b'])
src/services/templateSeederService.ts
└── new safeCreateFile() helper; three vault.create call sites swapped to it
src/services/perplexityService.ts
└── handleStreamingResponse catch block — error classification + plain-English notices
src/styles/gemini-modal.css (new — 680px max-width, gradient title,
four-color hairline, branded focus ring)
src/styles/settings-tab.css (new .perplexed-prose-textarea class)
src/styles/main.css (registers gemini-modal.css)
```
## Build status
`pnpm run build` (eslint + tsc + esbuild production) green at every step. ObsidianReviewBot's `obsidianmd/ui/sentence-case` rule continues to be the only friction — it recognizes "Google" as a proper noun (capitalize) and "URLs" as an acronym (lowercase to "urls"), each of which collides with our usual sentence-case house style. Followed the rule's autosuggestions verbatim.
The whole chain was exercised end-to-end against the live API with a free-tier key — `gemini-flash-latest` with `google_search` enabled, response written to `/Users/mpstaton/content-md/lossless/Bloomberg Beta.md`, citations footer landed with real `bloomberg.com` URLs and real page titles instead of grounding redirects.
## What's next
- **The "Google searches" Markdown list could be promoted to a callout** — right now it's a vanilla `### Google Searches` section with linked queries. A `> [!search] Google searches` callout would give it visual separation matching the citations footer pattern.
- **Hook Gemini into directory templates.** A template declaring `provider: gemini` in its `cft` fence should route through the Gemini service the same way existing templates route through Perplexity. The grounding shape (per-segment `groundingSupports[]` with real cited quotes) is actually better-suited to citation-spec output than Perplexity's bare `search_results[]`.
- **Per-domain canonical-URL parse heuristics.** Some sites (Substack, Medium, news aggregators) put the canonical URL behind a paywall redirect or in a non-standard meta tag. A small per-host hint table could improve resolution accuracy on the long tail of grounding sources.
- **The editorial-stance partial extraction** still pending from the May 19 partials+preambles pass — the anti-incumbent stance is duplicated across `concept-profile.md` and `vocabulary-profile.md`; extracting it into `partials/editorial-stance-anti-incumbent.md` is the obvious next move.

165
changelog/releases/0.2.0.md Normal file
View file

@ -0,0 +1,165 @@
---
title: "Perplexed 0.2.0 — Google Gemini joins the lineup, with the per-claim citations Claude's web_search can't keep"
lede: "Ask Gemini lands as the fourth research provider — Google Search grounded, with per-segment citation attribution that survives the full round-trip from model to vault. Templates gain a vault-visible partials/preambles layer so shared rules (mermaid discipline, citation enforcement, image placement) stop being duplicated across files. Eight settings sections that had been silently hidden for weeks finally render again. Three system-prompt textareas grow from 200px cramped slots to full-width rows. And the seeder stops yelling on every plugin load."
date_authored_initial_draft: 2026-05-20
date_authored_current_draft: 2026-05-20
date_first_published: 2026-05-20
date_last_updated: 2026-05-20
at_semantic_version: 0.2.0
status: Published
category: Release
tags:
- Release-Narrative
- Gemini-Provider
- Google-Search-Grounding
- Partials-And-Preambles
- Per-Segment-Citations
- Modal-Polish
- Seeder-Idempotency
- Obsidian-Community-Plugin
authors:
- Michael Staton
augmented_with: Claude Code (Opus 4.7, 1M context)
release_tag: "0.2.0"
release_url: "https://github.com/lossless-group/perplexed-plugin/releases/tag/0.2.0"
prior_release_tag: "0.1.2"
---
## Why care?
If you're filling research notes in Obsidian — concept profiles, source dossiers, vocabulary entries, anything where the source citation matters as much as the body — Perplexed 0.2.0 changes two things that show up the moment you use it:
**You now have a fourth research provider, and it cites better than the three before it.** Ask Gemini joins Ask Perplexity, Ask Claude, and Ask Perplexica with full Google Search grounding turned on by default. Where Claude's newer web_search tool drops per-claim citations on the round-trip through its dynamic-filtering sandbox (sources come back, attribution doesn't), Gemini's `groundingSupports[]` carries the segment-to-source mapping intact — so the `### Citations` footer that lands in your note is actually attached to the prose above it, with the verbatim quote per source.
**Your shared editorial rules stop being duplicated across templates.** Four directory templates (concept, vocabulary, source, toolkit) used to repeat the same mermaid-discipline / citation-enforcement / image-placement / research-framing guidance inside each one, or hardcode it in TypeScript where you couldn't touch it. Now those rules live in vault-visible `partials/` and `preambles/` folders. Fix the mermaid rules once in `partials/mermaid-discipline.md` and every template picks the fix up on the next generation.
A third thing matters in the moment but won't appear in any user-facing copy: the **settings tab** finally renders all of its sections. A one-line DOM-API bug — `addClass` being passed a space-separated string where it expects a single token — was throwing on settings open and aborting `display()` mid-render. Every settings row after that point silently disappeared. For weeks. The fix was eight call sites; the symptom was "Perplexed lost half its settings." Both halves are visible now.
## What's new?
### Ask Gemini — Google Search grounding with per-segment citation attribution
Gemini ships as the fourth provider via a dedicated **Ask Gemini** modal, mirroring the Ask Claude shape (question textarea, model dropdown, behavior toggles) with Gemini-flavored visual identity. Auth is a simple API key from `aistudio.google.com` — no GCP project, no OAuth, no service account. Paste the key into Settings → Perplexed → Gemini (Google), pick your model, ask.
| Provider | Endpoint | Cost | Best for |
|---|---|---|---|
| **Perplexity** | api.perplexity.ai | Paid | Source-cited research with consistent citation formatting |
| **Anthropic Claude** | api.anthropic.com | Paid | Longer-form reasoning with `web_search` and adaptive thinking |
| **Google Gemini** *(new)* | generativelanguage.googleapis.com | Free tier + paid tiers | Google Search grounding with per-segment citation attribution |
| **Perplexica / Vane** | localhost:3030 (self-hosted) | Free | Privacy-sensitive research; runs entirely on your machine |
| **LM Studio** | localhost:1234 (local) | Free | Local-only inference, no network |
The default model is **`gemini-flash-latest`** — Google's "always-current Flash" alias, which is free-tier-friendly and tracks Google's own quickstart default. `gemini-pro-latest`, plus the pinned `gemini-2.5-pro` and `gemini-2.5-flash` versions, are all selectable in the modal and in plugin settings.
#### Why Gemini's citations are different
We dissected an actual `gemini-flash-latest` response with curl before wiring the parser. Two layers carry provenance:
| Layer | Field | What it gives you |
|---|---|---|
| Page-level | `groundingChunks[]` | URL + title per page Gemini consulted |
| Segment-level | `groundingSupports[]` | Text span (`segment.text`) → indices into `groundingChunks[]` |
The plugin walks both. Page-level chunks become URL-and-title fallbacks; segment-level supports enrich them with the verbatim quote Gemini grounded against. Where Claude's `web_search_20260209` post-processes search results in a code-execution sandbox and the per-claim attachment doesn't survive the round-trip (text blocks come back with `citations: null`), Gemini's `groundingSupports[]` ships in the final response unmodified.
#### Two Gemini-specific quirks the curl exposed (and how we handle them)
1. **`chunk.web.uri` is a short-lived `vertexaisearch.cloud.google.com` redirect.** It expires roughly 30 days after the response — so naive "just write the URL we got" means every cited note rots on a clock. The plugin resolves each redirect via Obsidian's `requestUrl` (Node-side, no CORS) to the real destination page before writing, and while it's there parses `<link rel="canonical">` / `<meta property="og:url">` for the durable source URL **and** `<meta property="og:title">` / `<title>` for the real page title. So a citation that would naively render as `[bloomberg.com](vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIY...)` instead lands as `[Bloomberg Beta — Bloomberg LP](https://www.bloomberg.com/company/bloomberg-beta/)`. Falls back to the redirect URL on any failure (timeout at 5s, network error, parse miss) so the citation stays navigable today even if it rots in 30.
2. **`searchEntryPoint.renderedContent` is 5KB of inline-styled HTML.** Google's grounding terms ask that this Search Suggestions chip be displayed when google_search is used; the chip itself is `<style>`-tag-prefixed HTML that Obsidian's Markdown renderer strips, leaving orphan `<div>` shells. The plugin substitutes a Markdown-native `### Google Searches` section: a bullet list of the `webSearchQueries[]` Gemini actually issued, each linked to `google.com/search?q=…` so the user can re-run the suggested search. Same end-user behavior, rendered in a form Obsidian can actually display.
The same `### Citations` footer shape Perplexity and Claude already use means **Cite-Wide's hex-substitution pass works on Gemini output too** — the provider is plug-compatible with the existing citation pipeline.
### Partials + preambles — shared guidance, vault-visible, no more duplicated rules
The four directory templates (`concept`, `vocabulary`, `source`, `toolkit`) historically duplicated their mermaid-discipline / citation-enforcement / image-placement / research-framing guidance inside each template, with some of it hardcoded in TypeScript constants users couldn't edit. The 0.2.0 release moves that guidance into two peer folders alongside `templates/`:
```
zz-cf-lib/
├── templates/ (your four profile templates)
├── partials/ (NEW — referenced from templates via {{include: name}})
│ └── mermaid-discipline.md
└── preambles/ (NEW — auto-attached to every Perplexity request)
├── inline-citation.md (was a hardcoded TS constant)
├── image-placement.md (was a hardcoded TS constant)
└── research-framing.md (was a TS function)
```
- **`{{include: name}}`** — recursive, depth-limited, cycle-detected splice. Reference a partial from any template; missing files surface as inline `[[include: name — file not found]]` markers so typos stay visible.
- **Preambles auto-attach** to every Perplexity request, with bundled defaults as fallback. Settings tab gained four new rows: Partials root, Preambles root, System preambles, User preambles.
- **Per-template overrides** in the `cft` fence: `preambles: { system: [...], skip-user: [...], skip-all: true }`.
Fix the mermaid quoting rule once in `partials/mermaid-discipline.md` — every future template generation picks it up.
### Settings tab — eight hidden sections, back in view
A long-standing `addClass('perplexed-json-textarea is-tall')` bug at two specific DOM API call sites (Article Generator template + Deep Research template textareas) was throwing `InvalidCharacterError: Failed to execute 'add' on 'DOMTokenList': The token provided contains HTML space characters` because `addClass` accepts a single token, not a space-separated string. The throw aborted `display()` mid-render, silently breaking every settings section beneath it.
Same shape of bug as the May 19 fix that converted eight `activeDocument.createEl` calls to `containerEl.createEl` (which resurrected eleven hidden Perplexed / Claude / Perplexica / LM Studio settings sections). Today's fix swaps both `addClass('a b')` to `addClasses(['a', 'b'])`. Sections that hadn't rendered in weeks now render again.
### System-prompt textareas — full-width rows instead of 200px slots
The three system-prompt settings (Perplexity / Perplexica-Vane / LM Studio) used Obsidian's `Setting.addTextArea`, which crammed multi-line prompt content into the ~200px right-edge of the Setting row at 2 lines tall. For a multi-paragraph system prompt, that's a porthole into a much larger document.
Each now gets two sibling elements: a `Setting` row with name + description on top, then a full-width 3-line textarea directly below (resizable vertically). New CSS class `.perplexed-prose-textarea` — body font, 80px min-height, focus ring matching the modal style, full container width.
Placeholder-text settings stayed as compact single-line rows because that's the right shape for them.
### Template seeder — idempotent at both folder and file layers
Two race-window bugs in the seeder were producing red "Folder already exists" / "File already exists" console lines on every plugin load. Same root cause for both: Obsidian's in-memory file index lags the adapter write by a tick, so `getAbstractFileByPath(path) === null` followed immediately by `createFolder` / `create` races and throws — even though the index check said it was safe.
Both errors now get swallowed by helpers (`ensureFolder` / `safeCreateFile`) that test the error message and rethrow anything that isn't an "already exists" race. Seeder is fully idempotent now.
### Network-error UX in the Perplexity stream
`ERR_NETWORK_CHANGED` (WiFi roam, VPN reconnect, sleep/wake) and its cousins (socket-drop, idle-timeout, abort) now get classified and translated into user-language notices:
| Error class | Old behavior | New behavior |
|---|---|---|
| `NETWORK_CHANGED` | 30-line stack trace inline in the note | `Network changed mid-stream … Re-run the query — no partial response was saved.` |
| Idle timeout | Bare `stream went idle for 90s` | `Perplexity stream stalled (likely API back-pressure or rate limit). Re-run the query.` |
| Connection drop | `failed to fetch` | `Connection dropped before Perplexity finished. Re-run the query.` |
| User abort | `AbortError` | `Request aborted.` |
Mid-stream resume isn't possible — Perplexity has no resume token — so honest re-run guidance beats a magic retry that silently produces a partial.
### Ask Gemini modal — real CSS, Google-flavored, theme-aware
The Ask Gemini modal follows the proven wide-modal pattern from `context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md` (the "attach the class to `modalEl`, not `contentEl`" doctrine that ended six months of "Obsidian doesn't let me size modals" frustration). On top of the structural pattern:
- Gradient title text (blue → purple → red → yellow via `background-clip: text`)
- Google four-color hairline under the header (`#4285f4` / `#ea4335` / `#fbbc04` / `#34a853` at 55% opacity)
- Gemini-blue focus ring on the prompt textarea (consistent regardless of user theme)
- Primary CTA flat blue at rest, blue→purple gradient on hover with elevated shadow
- Theme tokens (`--background-*`, `--text-*`, `--font-text`) for everything structural — light, dark, and community theme parity
## Under the hood
Each substantive piece of work shipped in 0.2.0 has its own per-day changelog under `changelog/`:
| Date | Topic | Per-day changelog |
|---|---|---|
| 2026-05-19 | **Partials + preambles** — vault-visible shared rules; `{{include: name}}` directive; preambles folder auto-attaches to every request; per-template overrides via `cft` fence; one-line fix that resurrected 11 hidden settings sections (`activeDocument.createEl` → `containerEl.createEl`) | [`2026-05-19_01.md`](../2026-05-19_01.md) |
| 2026-05-19 | **Gemini provider lands** — Ask Gemini modal, settings section, `groundingMetadata` parsing, per-segment citations via `groundingSupports[]`, Markdown substitute for the searchEntryPoint HTML chip, response shape dissected with curl | [`2026-05-19_02.md`](../2026-05-19_02.md) |
| 2026-05-20 | **Obsidian-shipping pass** — CORS-blocked `fetch``requestUrl` + canonical/og:url + `<title>` parsing; `?key=``X-goog-api-key` header; default model → `gemini-flash-latest`; Gemini modal CSS; `safeCreateFile` race fix; `ERR_NETWORK_CHANGED` UX; system-prompt textareas promoted; `addClass` token bug fixed at two more call sites | [`2026-05-20_01.md`](../2026-05-20_01.md) |
## What's next
- **Hook Gemini into directory templates.** A template declaring `provider: gemini` in its `cft` fence should route through the Gemini service the same way existing templates route through Perplexity. The per-segment grounding shape is actually better-suited to citation-spec output than Perplexity's bare `search_results[]`.
- **Extract the editorial-stance partial.** The anti-incumbent stance is still duplicated across `concept-profile.md` and `vocabulary-profile.md`; extracting it into `partials/editorial-stance-anti-incumbent.md` is the obvious next move now that the partials machinery exists.
- **Real page titles for non-Gemini providers too.** The `requestUrl` + canonical-URL + `<title>` extraction we built for Gemini citation resolution generalizes — Perplexity citation post-processing currently keeps whatever title the source delivered, which is sometimes a domain. Same parse pipeline could enrich those too.
- **Diff-aware regeneration for directory templates.** Files with `cf_last_run` stamped could be skipped on subsequent runs by default, with an explicit `--force` mode for refreshes. Right now every folder-batch run regenerates every file.
- **Per-domain canonical-URL parse heuristics.** Some sites (Substack, Medium, news aggregators) put the canonical URL behind a paywall redirect or in a non-standard meta tag. A small per-host hint table could improve resolution accuracy on the long tail of grounding sources.
## References
- **Release on GitHub:** https://github.com/lossless-group/perplexed-plugin/releases/tag/0.2.0
- **Install from Obsidian:** Settings → Community Plugins → Browse → "Perplexed"
- **Prior release:** [`0.1.1`](./0.1.1.md) (`0.1.2` was a workflow/attestation pass — no user-facing changes)
- **Gemini provider changelog:** [`changelog/2026-05-19_02.md`](../2026-05-19_02.md)
- **Partials + preambles changelog:** [`changelog/2026-05-19_01.md`](../2026-05-19_01.md)
- **Obsidian-shipping pass changelog:** [`changelog/2026-05-20_01.md`](../2026-05-20_01.md)
- **Wide-modal doctrine:** `context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md`
- **Upstream providers:** [Perplexity](https://www.perplexity.ai/), [Anthropic Claude](https://www.anthropic.com/), [Google Gemini](https://ai.google.dev/), [Perplexica / Vane](https://github.com/ItzCrazyKns/Vane), [LM Studio](https://lmstudio.ai/)
- **Sibling Lossless plugins:** [`image-gin`](https://github.com/lossless-group/image-gin), [`cite-wide`](https://github.com/lossless-group/cite-wide)

251
main.ts
View file

@ -6,6 +6,7 @@ import { PerplexityService } from './src/services/perplexityService';
import { PerplexicaService } from './src/services/perplexicaService';
import { LMStudioService } from './src/services/lmStudioService';
import { ClaudeService } from './src/services/claudeService';
import { GeminiService } from './src/services/geminiService';
import { PromptsService } from './src/services/promptsService';
// Import modals
@ -13,6 +14,7 @@ import { PerplexityModal } from './src/modals/PerplexityModal';
import { PerplexicaModal } from './src/modals/PerplexicaModal';
import { LMStudioModal } from './src/modals/LMStudioModal';
import { ClaudeModal } from './src/modals/ClaudeModal';
import { GeminiModal } from './src/modals/GeminiModal';
import { URLUpdateModal } from './src/modals/URLUpdateModal';
import { ArticleGeneratorModal } from './src/modals/ArticleGeneratorModal';
import { TextEnhancementModal } from './src/modals/TextEnhancementModal';
@ -48,6 +50,11 @@ interface PerplexedPluginSettings {
lmStudioRequestTemplate: string;
anthropicApiKey: string;
claudeDefaultModel: string;
geminiApiKey: string;
geminiDefaultModel: string;
geminiEnableGrounding: boolean;
geminiIncludeSearchSuggestions: boolean;
geminiResolveCitationUrls: boolean;
defaultModel: string;
defaultOptimizationMode: string;
defaultFocusMode: string;
@ -133,6 +140,11 @@ const DEFAULT_SETTINGS: PerplexedPluginSettings = {
perplexityApiKey: '',
anthropicApiKey: '',
claudeDefaultModel: 'claude-opus-4-7',
geminiApiKey: '',
geminiDefaultModel: 'gemini-flash-latest',
geminiEnableGrounding: true,
geminiIncludeSearchSuggestions: true,
geminiResolveCitationUrls: true,
perplexityRequestTemplate: `{
"model": "llama-3.1-sonar-small-128k-online",
"messages": [
@ -333,6 +345,7 @@ export default class PerplexedPlugin extends Plugin {
private perplexicaService!: PerplexicaService | null;
private lmStudioService!: LMStudioService | null;
private claudeService!: ClaudeService | null;
private geminiService!: GeminiService | null;
private promptsService!: PromptsService | null;
async onload(): Promise<void> {
@ -427,12 +440,26 @@ export default class PerplexedPlugin extends Plugin {
new Notice('Failed to initialize claudeservice');
this.claudeService = null;
}
try {
this.geminiService = new GeminiService({
geminiApiKey: this.settings.geminiApiKey,
promptsService: this.promptsService,
headerPosition: this.settings.headerPosition,
});
console.debug('Perplexed Plugin: GeminiService initialized successfully');
} catch (error) {
console.error('Perplexed Plugin: Failed to initialize GeminiService:', error);
new Notice('Failed to initialize geminiservice');
this.geminiService = null;
}
} else {
// If promptsService failed, set all other services to null
this.perplexityService = null;
this.perplexicaService = null;
this.lmStudioService = null;
this.claudeService = null;
this.geminiService = null;
console.debug('Perplexed Plugin: Skipping service initialization due to PromptsService failure');
}
@ -473,6 +500,13 @@ export default class PerplexedPlugin extends Plugin {
console.error('Perplexed Plugin: Failed to register Claude commands:', error);
}
try {
this.registerGeminiCommands();
console.debug('Perplexed Plugin: Gemini commands registered successfully');
} catch (error) {
console.error('Perplexed Plugin: Failed to register Gemini commands:', error);
}
try {
this.registerArticleGeneratorCommands();
console.debug('Perplexed Plugin: Article generator commands registered successfully');
@ -789,6 +823,49 @@ export default class PerplexedPlugin extends Plugin {
}
}
private registerGeminiCommands(): void {
this.addCommand({
id: 'ask-gemini',
name: 'Ask Gemini',
editorCallback: (editor: Editor) => {
if (!this.geminiService) {
new Notice('Gemini service not initialized. Set GEMINI_API_KEY in .env or settings, then reinitialize services.');
return;
}
if (!this.promptsService) {
new Notice('Prompts service not initialized.');
return;
}
new GeminiModal(
this.app,
editor,
this.geminiService,
this.promptsService,
{
defaultModel: this.settings.geminiDefaultModel,
enableGrounding: this.settings.geminiEnableGrounding,
includeSearchSuggestions: this.settings.geminiIncludeSearchSuggestions,
resolveCitationUrls: this.settings.geminiResolveCitationUrls,
}
).open();
},
});
this.addCommand({
id: 'gemini-service-status',
name: 'Check Gemini service status',
callback: () => {
if (this.geminiService && this.settings.geminiApiKey) {
new Notice('Gemini service is initialized and an API key is configured.');
} else if (this.geminiService) {
new Notice('Gemini service is initialized but no API key is set.');
} else {
new Notice('Gemini service is not initialized.');
}
},
});
}
private registerClaudeCommands(): void {
this.addCommand({
id: 'ask-claude',
@ -1094,12 +1171,25 @@ export default class PerplexedPlugin extends Plugin {
console.error('Perplexed Plugin: Failed to reinitialize ClaudeService:', error);
this.claudeService = null;
}
try {
this.geminiService = new GeminiService({
geminiApiKey: this.settings.geminiApiKey,
promptsService: this.promptsService,
headerPosition: this.settings.headerPosition,
});
console.debug('Perplexed Plugin: GeminiService reinitialized successfully');
} catch (error) {
console.error('Perplexed Plugin: Failed to reinitialize GeminiService:', error);
this.geminiService = null;
}
} else {
// If promptsService failed, set all other services to null
this.perplexityService = null;
this.perplexicaService = null;
this.lmStudioService = null;
this.claudeService = null;
this.geminiService = null;
console.debug('Perplexed Plugin: Skipping service reinitialization due to PromptsService failure');
}
@ -1397,6 +1487,68 @@ class PerplexedSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
// Gemini (Google) Section
new Setting(containerEl).setName("Gemini (Google)").setHeading();
containerEl.createEl('p', {
text: 'Configure Gemini API access. The Google_search tool emits per-segment grounding supports that map text spans to source urls — the per-claim attribution Claude\'s dynamic-filter pass loses.',
cls: 'setting-item-description'
});
new Setting(containerEl)
.setName('Gemini API key')
.setDesc('Your Google AI studio API key. Generate one in Google AI studio.')
.addText(text => text
.setPlaceholder('Aiza...')
.setValue(this.plugin.settings.geminiApiKey)
.onChange(async (value) => {
this.plugin.settings.geminiApiKey = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default Gemini model')
.setDesc('Default model used by the ask Gemini command. Recommended: Gemini flash (latest) — free-tier friendly.')
.addDropdown(dropdown => dropdown
.addOption('gemini-flash-latest', 'Gemini flash (latest) — recommended')
.addOption('gemini-pro-latest', 'Gemini pro (latest)')
.addOption('gemini-2.5-pro', 'Gemini 2.5 pro (pinned)')
.addOption('gemini-2.5-flash', 'Gemini 2.5 flash (pinned)')
.setValue(this.plugin.settings.geminiDefaultModel)
.onChange(async (value) => {
this.plugin.settings.geminiDefaultModel = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable Google search grounding by default')
.setDesc('Sends the Google_search tool with every request. Disable to get ungrounded model knowledge only.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.geminiEnableGrounding)
.onChange(async (value) => {
this.plugin.settings.geminiEnableGrounding = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include Google searches list in notes')
.setDesc('Appends a Markdown "Google searches" section listing the queries Gemini ran, each linked to Google search. Markdown-native substitute for the Google grounding chip (which is inline-styled HTML that Obsidian can\'t render cleanly).')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.geminiIncludeSearchSuggestions)
.onChange(async (value) => {
this.plugin.settings.geminiIncludeSearchSuggestions = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Resolve citation urls (durable, slower)')
.setDesc('Google\'s grounding redirect urls expire ~30 days after the response. With this on, the plugin resolves each redirect to the real source URL before writing the citations footer. Costs one HTTP request per cited source (parallelized, 3s timeout each).')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.geminiResolveCitationUrls)
.onChange(async (value) => {
this.plugin.settings.geminiResolveCitationUrls = value;
await this.plugin.saveSettings();
}));
// Perplexica / Vane Section
new Setting(containerEl).setName("Perplexica / vane (self-hosted)").setHeading();
containerEl.createEl('p', {
@ -1544,54 +1696,57 @@ class PerplexedSettingTab extends PluginSettingTab {
// System Prompts
new Setting(containerEl).setName("System prompts").setHeading();
new Setting(containerEl)
.setName('Perplexity system prompt')
.setDesc('System prompt used for perplexity AI requests')
.addTextArea(text => text
.setPlaceholder('Enter system prompt for perplexity...')
.setValue(this.plugin.settings.prompts.perplexitySystemPrompt)
.onChange(async (value: string) => {
this.plugin.settings.prompts.perplexitySystemPrompt = value;
const promptsService = this.plugin.getPromptsService();
if (promptsService) {
promptsService.updateSettings(this.plugin.settings.prompts);
}
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Perplexica / vane system prompt')
.setDesc('System prompt used for perplexica / vane requests')
.addTextArea(text => text
.setPlaceholder('Enter system prompt for perplexica / vane...')
.setValue(this.plugin.settings.prompts.perplexicaSystemPrompt)
.onChange(async (value: string) => {
this.plugin.settings.prompts.perplexicaSystemPrompt = value;
const promptsService = this.plugin.getPromptsService();
if (promptsService) {
promptsService.updateSettings(this.plugin.settings.prompts);
}
await this.plugin.saveSettings();
})
);
// Helper: render a system prompt as a Setting (name+desc only) followed
// by a sibling full-width textarea. Beats Setting.addTextArea — which
// crams a multi-line input into a ~200px right-edge slot — for any
// input where the user actually has to read what they wrote.
const addPromptRow = (
name: string,
desc: string,
placeholder: string,
getter: () => string,
setter: (v: string) => void,
): void => {
new Setting(containerEl).setName(name).setDesc(desc);
const ta = containerEl.createEl('textarea');
ta.addClass('perplexed-prose-textarea');
ta.placeholder = placeholder;
ta.value = getter();
ta.rows = 3;
ta.addEventListener('input', () => void (async () => {
setter(ta.value);
const promptsService = this.plugin.getPromptsService();
if (promptsService) {
promptsService.updateSettings(this.plugin.settings.prompts);
}
await this.plugin.saveSettings();
})());
};
new Setting(containerEl)
.setName('Lm studio default system prompt')
.setDesc('Default system prompt used for lm studio requests')
.addTextArea(text => text
.setPlaceholder('Enter default system prompt for lm studio...')
.setValue(this.plugin.settings.prompts.lmStudioDefaultSystemPrompt)
.onChange(async (value: string) => {
this.plugin.settings.prompts.lmStudioDefaultSystemPrompt = value;
const promptsService = this.plugin.getPromptsService();
if (promptsService) {
promptsService.updateSettings(this.plugin.settings.prompts);
}
await this.plugin.saveSettings();
})
);
addPromptRow(
'Perplexity system prompt',
'System prompt used for perplexity AI requests',
'Enter system prompt for perplexity...',
() => this.plugin.settings.prompts.perplexitySystemPrompt,
(v) => { this.plugin.settings.prompts.perplexitySystemPrompt = v; },
);
addPromptRow(
'Perplexica / vane system prompt',
'System prompt used for perplexica / vane requests',
'Enter system prompt for perplexica / vane...',
() => this.plugin.settings.prompts.perplexicaSystemPrompt,
(v) => { this.plugin.settings.prompts.perplexicaSystemPrompt = v; },
);
addPromptRow(
'Lm studio default system prompt',
'Default system prompt used for lm studio requests',
'Enter default system prompt for lm studio...',
() => this.plugin.settings.prompts.lmStudioDefaultSystemPrompt,
(v) => { this.plugin.settings.prompts.lmStudioDefaultSystemPrompt = v; },
);
// Placeholder Text
new Setting(containerEl).setName("Placeholder text").setHeading();
@ -1683,7 +1838,7 @@ class PerplexedSettingTab extends PluginSettingTab {
const articleTemplateTextArea = containerEl.createEl('textarea');
articleTemplateTextArea.rows = 15;
articleTemplateTextArea.cols = 50;
articleTemplateTextArea.addClass('perplexed-json-textarea is-tall');
articleTemplateTextArea.addClasses(['perplexed-json-textarea', 'is-tall']);
articleTemplateTextArea.placeholder = 'Enter article generator template...';
articleTemplateTextArea.value = this.plugin.settings.prompts.articleGeneratorTemplate;
@ -1703,7 +1858,7 @@ class PerplexedSettingTab extends PluginSettingTab {
const deepResearchTemplateTextArea = containerEl.createEl('textarea');
deepResearchTemplateTextArea.rows = 20;
deepResearchTemplateTextArea.cols = 50;
deepResearchTemplateTextArea.addClass('perplexed-json-textarea is-tall');
deepResearchTemplateTextArea.addClasses(['perplexed-json-textarea', 'is-tall']);
deepResearchTemplateTextArea.placeholder = 'Enter deep research article generator template...';
deepResearchTemplateTextArea.value = this.plugin.settings.prompts.deepResearchArticleTemplate;

View file

@ -1,9 +1,9 @@
{
"id": "perplexed",
"name": "Perplexed",
"version": "0.1.2",
"version": "0.2.0",
"minAppVersion": "1.8.10",
"description": "Generate source-cited research content from Perplexity, Anthropic Claude, Perplexica, or local LM Studio — directly into your notes.",
"description": "Generate source-cited research content from Perplexity, Anthropic Claude, Google Gemini, Perplexica, or local LM Studio — directly into your notes.",
"author": "The Lossless Group",
"authorUrl": "https://lossless.group",
"fundingUrl": "https://buymeacoffee.com/losslessgroup",

View file

@ -1,7 +1,7 @@
{
"name": "perplexed",
"version": "0.1.2",
"description": "Generate source-cited research content from Perplexity, Anthropic Claude, Perplexica, or local LM Studio — directly into your notes.",
"version": "0.2.0",
"description": "Generate source-cited research content from Perplexity, Anthropic Claude, Google Gemini, Perplexica, or local LM Studio — directly into your notes.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

170
src/modals/GeminiModal.ts Normal file
View file

@ -0,0 +1,170 @@
import { Modal, Notice, Setting } from 'obsidian';
import type { Editor, App } from 'obsidian';
import type { GeminiService, GeminiOptions } from '../services/geminiService';
import type { PromptsService } from '../services/promptsService';
const GEMINI_MODELS: Array<{ value: string; label: string; tagline: string }> = [
{ value: 'gemini-flash-latest', label: 'Gemini Flash (latest)', tagline: 'Always-current flash alias — free-tier friendly' },
{ value: 'gemini-pro-latest', label: 'Gemini Pro (latest)', tagline: 'Always-current pro alias — deepest reasoning' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', tagline: 'Pinned 2.5 Pro — deepest reasoning + grounding' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', tagline: 'Pinned 2.5 Flash — faster, lower cost' },
];
const DEFAULT_MODEL = 'gemini-flash-latest';
export interface GeminiModalDefaults {
defaultModel?: string;
enableGrounding?: boolean;
includeSearchSuggestions?: boolean;
resolveCitationUrls?: boolean;
}
export class GeminiModal extends Modal {
private editor: Editor;
private geminiService: GeminiService;
private query = '';
private model: string = DEFAULT_MODEL;
private grounding = true;
private suggestions = true;
private resolveUrls = true;
private stream = true;
constructor(
app: App,
editor: Editor,
geminiService: GeminiService,
// Accepted for caller-symmetry with PerplexityModal; the Gemini flow
// doesn't pull from PromptsService — prompt content lives in the
// user's question.
_promptsService: PromptsService,
defaults?: GeminiModalDefaults
) {
super(app);
this.editor = editor;
this.geminiService = geminiService;
if (defaults?.defaultModel) this.model = defaults.defaultModel;
if (defaults?.enableGrounding !== undefined) this.grounding = defaults.enableGrounding;
if (defaults?.includeSearchSuggestions !== undefined) this.suggestions = defaults.includeSearchSuggestions;
if (defaults?.resolveCitationUrls !== undefined) this.resolveUrls = defaults.resolveCitationUrls;
}
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass('gemini-modal');
contentEl.empty();
const header = contentEl.createDiv({ cls: 'gemini-modal__header' });
header.createEl('h2', { text: 'Ask Gemini', cls: 'gemini-modal__title' });
header.createEl('p', {
cls: 'gemini-modal__subtitle',
text: 'Google search grounding with per-segment citations. Streams into the active note at the cursor.',
});
const querySection = contentEl.createDiv({ cls: 'gemini-modal__section' });
querySection.createEl('label', {
text: 'Question',
cls: 'gemini-modal__label',
attr: { for: 'gemini-modal-query' },
});
const queryTextarea = querySection.createEl('textarea', {
cls: 'gemini-modal__textarea',
attr: {
id: 'gemini-modal-query',
rows: '6',
placeholder: 'What would you like to research? Multi-line OK.',
},
});
queryTextarea.value = this.query;
queryTextarea.addEventListener('input', () => { this.query = queryTextarea.value; });
const optionsSection = contentEl.createDiv({ cls: 'gemini-modal__section' });
optionsSection.createEl('h3', { text: 'Model', cls: 'gemini-modal__section-title' });
new Setting(optionsSection)
.setName('Model')
.setDesc(this.modelTagline(this.model))
.addDropdown(dd => {
GEMINI_MODELS.forEach(({ value, label }) => { dd.addOption(value, label); });
dd.setValue(this.model);
dd.onChange((value) => {
this.model = value;
const descEl = optionsSection.querySelector(
'.setting-item:nth-of-type(1) .setting-item-description'
);
if (descEl) descEl.textContent = this.modelTagline(value);
});
});
const togglesSection = contentEl.createDiv({ cls: 'gemini-modal__section' });
togglesSection.createEl('h3', { text: 'Behavior', cls: 'gemini-modal__section-title' });
new Setting(togglesSection)
.setName('Enable Google search grounding')
.setDesc('Server-side Google_search tool. Emits per-segment grounding supports that survive into citations.')
.addToggle(t => t.setValue(this.grounding).onChange(v => { this.grounding = v; }));
new Setting(togglesSection)
.setName('Append Google searches list')
.setDesc('Markdown bullet list of the queries Gemini ran, each linked to Google search.')
.addToggle(t => t.setValue(this.suggestions).onChange(v => { this.suggestions = v; }));
new Setting(togglesSection)
.setName('Resolve citation urls')
.setDesc('Resolve Google grounding redirects to durable source urls. Off = fast but citations expire in ~30 days.')
.addToggle(t => t.setValue(this.resolveUrls).onChange(v => { this.resolveUrls = v; }));
new Setting(togglesSection)
.setName('Stream response')
.setDesc('Recommended for long answers — avoids HTTP timeouts and writes incrementally to the note.')
.addToggle(t => t.setValue(this.stream).onChange(v => { this.stream = v; }));
const footer = contentEl.createDiv({ cls: 'gemini-modal__footer' });
const cancelBtn = footer.createEl('button', { text: 'Cancel', cls: 'gemini-modal__button' });
cancelBtn.addEventListener('click', () => this.close());
const askBtn = footer.createEl('button', { text: 'Ask Gemini', cls: 'gemini-modal__button mod-cta' });
askBtn.addEventListener('click', () => void this.onSubmit());
queryTextarea.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void this.onSubmit();
}
});
activeWindow.setTimeout(() => queryTextarea.focus(), 50);
}
private modelTagline(value: string): string {
const found = GEMINI_MODELS.find(m => m.value === value);
return found ? found.tagline : '';
}
private async onSubmit(): Promise<void> {
const trimmed = this.query.trim();
if (!trimmed) {
new Notice('Please enter a question for Gemini.');
return;
}
const options: GeminiOptions = {
enableGrounding: this.grounding,
includeSearchSuggestions: this.suggestions,
resolveCitationUrls: this.resolveUrls,
};
this.close();
await this.geminiService.queryGemini(
trimmed,
this.model,
this.stream,
this.editor,
options
);
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,521 @@
import { Notice, request } from 'obsidian';
import type { Editor } from 'obsidian';
import type { PromptsService } from './promptsService';
export interface GeminiOptions {
enableGrounding?: boolean;
maxTokens?: number;
temperature?: number;
systemInstruction?: string;
includeSearchSuggestions?: boolean;
/**
* Resolve vertexaisearch.cloud.google.com grounding redirects to the real
* source URL before writing citations. The redirect URLs expire ~30 days
* after the response, so without this the Citations footer rots on a clock.
* Costs one HEAD-like fetch per unique source (parallelized, 3s timeout each).
*/
resolveCitationUrls?: boolean;
}
export interface GeminiSettings {
geminiApiKey: string;
promptsService?: PromptsService | null;
headerPosition?: 'top' | 'bottom';
}
interface GroundingChunk {
web?: { uri: string; title?: string };
}
interface GroundingSupport {
segment?: { startIndex?: number; endIndex?: number; text?: string };
groundingChunkIndices?: number[];
confidenceScores?: number[];
}
interface GroundingMetadata {
webSearchQueries?: string[];
groundingChunks?: GroundingChunk[];
groundingSupports?: GroundingSupport[];
searchEntryPoint?: { renderedContent?: string };
}
interface GeminiCandidate {
content?: { parts?: Array<{ text?: string }>; role?: string };
groundingMetadata?: GroundingMetadata;
finishReason?: string;
}
interface GeminiResponseChunk {
candidates?: GeminiCandidate[];
promptFeedback?: { blockReason?: string };
}
interface GeminiCitation {
url: string;
title: string;
citedText: string;
}
const DEFAULT_MAX_TOKENS = 32000;
const API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
export class GeminiService {
private settings: GeminiSettings;
constructor(settings: GeminiSettings) {
this.settings = settings;
}
public updateApiKey(apiKey: string): void {
this.settings.geminiApiKey = apiKey;
}
public async queryGemini(
query: string,
model: string,
stream: boolean,
editor: Editor,
options?: GeminiOptions
): Promise<void> {
if (!this.settings.geminiApiKey) {
new Notice('Gemini API key not configured. Set GEMINI_API_KEY in .env or in plugin settings.');
return;
}
const timestamp = new Date().toISOString();
const cursor = editor.getCursor();
const processedQuery = query.split('\n').map(line => `> ${line}`).join('\n');
const headerText = `\n\n***\n> [!info] **Gemini Query** (${timestamp})\n> **Question:**\n${processedQuery}\n> \n> **Model:** ${model}\n> \n>`;
let responseCursor: { line: number; ch: number };
if (this.settings.headerPosition === 'bottom') {
responseCursor = { ...cursor };
} else {
editor.replaceRange(headerText, cursor, cursor);
const headerLines = headerText.split('\n');
const lastLine = headerLines[headerLines.length - 1] ?? '';
responseCursor = {
line: cursor.line + headerLines.length - 1,
ch: lastLine.length,
};
}
const body: Record<string, unknown> = {
contents: [{ role: 'user', parts: [{ text: query }] }],
generationConfig: {
maxOutputTokens: options?.maxTokens ?? DEFAULT_MAX_TOKENS,
temperature: options?.temperature ?? 0.7,
},
};
if (options?.systemInstruction) {
body.systemInstruction = { parts: [{ text: options.systemInstruction }] };
}
if (options?.enableGrounding !== false) {
// The google_search tool is the per-segment-grounding successor to
// google_search_retrieval. Gemini 2.x emits groundingSupports[] that
// map text spans → groundingChunks[] indices — the per-claim attribution
// that Claude's web_search_20260209 dynamic-filter pass loses.
body.tools = [{ google_search: {} }];
}
try {
if (stream) {
await this.handleStreamingResponse(model, body, editor, responseCursor, headerText, options);
} else {
await this.handleNonStreamingResponse(model, body, editor, responseCursor, headerText, options);
}
} catch (error) {
this.handleError(error, editor);
}
}
private async handleStreamingResponse(
model: string,
body: Record<string, unknown>,
editor: Editor,
responseCursor: { line: number; ch: number },
headerText: string,
options?: GeminiOptions
): Promise<void> {
const url = `${API_BASE}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
// activeWindow.fetch because requestUrl buffers SSE bodies.
const response = await activeWindow.fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'X-goog-api-key': this.settings.geminiApiKey,
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Gemini HTTP ${response.status}: ${errText}`);
}
if (!response.body) throw new Error('Gemini: no response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const currentPos = { ...responseCursor };
let finalGrounding: GroundingMetadata | null = null;
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data) continue;
let parsed: GeminiResponseChunk;
try {
parsed = JSON.parse(data) as GeminiResponseChunk;
} catch {
continue;
}
const cand = parsed.candidates?.[0];
if (!cand) continue;
const deltaText = (cand.content?.parts ?? [])
.map(p => p.text ?? '')
.join('');
if (deltaText) {
fullText += deltaText;
editor.replaceRange(deltaText, currentPos);
const contentLines = deltaText.split('\n');
if (contentLines.length === 1) {
currentPos.ch += deltaText.length;
} else {
currentPos.line += contentLines.length - 1;
currentPos.ch = contentLines[contentLines.length - 1]?.length ?? 0;
}
editor.scrollIntoView({ from: currentPos, to: currentPos }, true);
}
if (cand.groundingMetadata) {
finalGrounding = this.mergeGrounding(finalGrounding, cand.groundingMetadata);
}
}
}
await this.afterResponse(editor, headerText, finalGrounding, fullText, options);
}
private async handleNonStreamingResponse(
model: string,
body: Record<string, unknown>,
editor: Editor,
responseCursor: { line: number; ch: number },
headerText: string,
options?: GeminiOptions
): Promise<void> {
const url = `${API_BASE}/models/${encodeURIComponent(model)}:generateContent`;
const raw = await request({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-goog-api-key': this.settings.geminiApiKey,
},
body: JSON.stringify(body),
});
const parsed = JSON.parse(raw) as GeminiResponseChunk;
const cand = parsed.candidates?.[0];
const text = (cand?.content?.parts ?? []).map(p => p.text ?? '').join('');
if (text) editor.replaceRange(text, responseCursor);
await this.afterResponse(editor, headerText, cand?.groundingMetadata ?? null, text, options);
}
private mergeGrounding(prev: GroundingMetadata | null, next: GroundingMetadata): GroundingMetadata {
if (!prev) return next;
const merged: GroundingMetadata = {};
const queries = [...new Set([...(prev.webSearchQueries ?? []), ...(next.webSearchQueries ?? [])])];
if (queries.length > 0) merged.webSearchQueries = queries;
const chunks = next.groundingChunks ?? prev.groundingChunks;
if (chunks) merged.groundingChunks = chunks;
const supports = next.groundingSupports ?? prev.groundingSupports;
if (supports) merged.groundingSupports = supports;
const entry = next.searchEntryPoint ?? prev.searchEntryPoint;
if (entry) merged.searchEntryPoint = entry;
return merged;
}
private async afterResponse(
editor: Editor,
headerText: string,
grounding: GroundingMetadata | null,
_fullText: string,
options?: GeminiOptions
): Promise<void> {
if (grounding) {
const queries = grounding.webSearchQueries ?? [];
// Surface the queries Google ran. This is also the markdown-native
// satisfaction of Google's grounding-attribution requirement: the
// chip Google ships in searchEntryPoint.renderedContent is 5KB of
// inline-styled HTML that Obsidian's markdown renderer can't display
// cleanly (the <style> tag is stripped, leaving orphan <div> chips).
// A bullet list of queries — each linked to google.com/search?q=…
// — surfaces the same suggested searches in a form Obsidian renders
// and the user can actually click.
if (queries.length > 0 && options?.includeSearchSuggestions !== false) {
this.appendSearchSuggestions(editor, queries);
}
const citations = this.extractCitations(grounding);
if (citations.length > 0) {
const resolved = options?.resolveCitationUrls === false
? citations
: await this.resolveCitationUrls(citations);
this.addCitations(editor, resolved);
}
}
if (this.settings.headerPosition === 'bottom' && headerText) {
const endOfDoc = editor.lastLine();
const endPos = { line: endOfDoc, ch: editor.getLine(endOfDoc).length };
editor.replaceRange('\n\n' + headerText, endPos);
}
const endOfDoc = editor.lastLine();
const endPos = { line: endOfDoc, ch: editor.getLine(endOfDoc).length };
editor.replaceRange('\n\n***\n', endPos);
}
/**
* Walk groundingMetadata and produce Lossless-spec citations.
*
* Gemini's google_search tool emits groundingChunks[] (URL+title per page)
* AND groundingSupports[] (text span which chunks). Where Claude's
* web_search_20260209 loses per-claim attachment in the dynamic-filter
* sandbox round-trip, Gemini's groundingSupports[] survives intact
* so cited_text comes from the segment.text and we keep per-segment
* attribution.
*
* Note: support.segment.startIndex/endIndex are UTF-8 BYTE offsets into
* the response text, not character offsets fine for ASCII, but anyone
* wiring inline [N] markers later by slicing the response text will need
* a Buffer-style indexer, not a JS string indexer.
*
* Note: chunk.web.title is the source DOMAIN (e.g. "nobelprize.org"), not
* the page title. chunk.web.uri is a vertexaisearch.cloud.google.com
* grounding redirect that expires ~30 days after the response
* resolveCitationUrls() resolves to the durable URL before writing.
*/
private extractCitations(g: GroundingMetadata): GeminiCitation[] {
const chunks = g.groundingChunks ?? [];
const byUrl = new Map<string, GeminiCitation>();
// Pass 1: raw chunks as URL + title only (fallback when no support refs)
for (const chunk of chunks) {
const web = chunk.web;
if (!web?.uri) continue;
if (byUrl.has(web.uri)) continue;
byUrl.set(web.uri, { url: web.uri, title: web.title || 'Source', citedText: '' });
}
// Pass 2: enrich with per-segment cited_text from groundingSupports
const supports = g.groundingSupports ?? [];
for (const support of supports) {
const segmentText = support.segment?.text ?? '';
if (!segmentText) continue;
for (const idx of support.groundingChunkIndices ?? []) {
const chunk = chunks[idx];
const uri = chunk?.web?.uri;
if (!uri) continue;
const existing = byUrl.get(uri);
if (existing) {
// First per-segment quote wins (closest to source of truth).
if (!existing.citedText) existing.citedText = segmentText;
} else {
byUrl.set(uri, {
url: uri,
title: chunk.web?.title || 'Source',
citedText: segmentText,
});
}
}
}
const out = Array.from(byUrl.values());
console.debug(
`[GeminiService] extractCitations — chunks: ${chunks.length}; ` +
`supports: ${supports.length}; extracted: ${out.length}`
);
return out;
}
/**
* Emit a Citations section per the Lossless Citation Spec Gemini extension.
* Same shape as ClaudeService.addCitations so cite-wide hex-substitution
* passes are provider-agnostic.
*/
private addCitations(editor: Editor, citations: GeminiCitation[]): void {
if (citations.length === 0) return;
const content = editor.getValue();
const existingMatch = content.match(
/### Citations\n\n([\s\S]*?)(?=\n\n\*\*\*|\n\n### |\n\n## |\n\n# |$)/
);
let existing = '';
let n = 1;
if (existingMatch && existingMatch[1]) {
existing = existingMatch[1];
const numbered = existing.match(/\[(\d+)\]:/g);
if (numbered && numbered.length > 0) {
const max = Math.max(...numbered.map(s => {
const m = s.match(/\d+/);
return m ? parseInt(m[0]) : 0;
}));
n = max + 1;
}
}
let added = '';
citations.forEach((c, i) => {
const collapsed = c.citedText.replace(/\s*\n\s*/g, ' ').trim();
const suffix = collapsed ? ` > ${collapsed}` : '';
const safeTitle = c.title.replace(/\]/g, '\\]');
added += `[${n + i}]: [${safeTitle}](${c.url}).${suffix}\n\n`;
});
if (existingMatch) {
const updated = existing + added;
const startIdx = content.indexOf('### Citations');
const endIdx = startIdx + '### Citations\n\n'.length + existing.length;
const before = content.substring(0, startIdx + '### Citations\n\n'.length);
const after = content.substring(endIdx);
editor.setValue(before + updated + after);
} else {
const text = '\n\n### Citations\n\n' + added;
const endOfDoc = editor.lastLine();
const endPos = { line: endOfDoc, ch: editor.getLine(endOfDoc).length };
editor.replaceRange(text, endPos);
}
}
/**
* Markdown-native rendering of the Google Search queries Gemini ran. Doubles
* as the markdown-friendly substitute for searchEntryPoint.renderedContent
* each query is linked to google.com/search?q= so the user can re-run the
* search, which is the spirit of Google's grounding-attribution requirement.
*/
private appendSearchSuggestions(editor: Editor, queries: string[]): void {
const lines = queries.map(q => {
const safe = q.replace(/`/g, '\\`');
const href = `https://www.google.com/search?q=${encodeURIComponent(q)}`;
return `- [\`${safe}\`](${href})`;
}).join('\n');
const block = `\n\n### Google Searches\n\n${lines}\n`;
const endOfDoc = editor.lastLine();
const endPos = { line: endOfDoc, ch: editor.getLine(endOfDoc).length };
editor.replaceRange(block, endPos);
}
/**
* Resolve vertexaisearch.cloud.google.com grounding redirects to the real
* source URL + a real page title.
*
* Why not fetch(): browser-context fetch from app://obsidian.md to
* vertexaisearch.cloud.google.com is blocked by CORS (no
* Access-Control-Allow-Origin), and even when `redirect: 'manual'` would
* theoretically expose the Location header, cross-origin opaqueredirect
* responses hide it. Obsidian's `requestUrl` runs Node-side, ignores CORS,
* follows redirects, and returns the final HTML so we both bypass the
* block AND get a page we can scrape for og:url + <title>.
*
* Parallel with 5s timeout per request. On any failure (timeout, network,
* parse error) we keep the original redirect URL so the citation is at
* least navigable today even if it rots in 30 days.
*/
private async resolveCitationUrls(citations: GeminiCitation[]): Promise<GeminiCitation[]> {
const REDIRECT_HOST = 'vertexaisearch.cloud.google.com';
const TIMEOUT_MS = 5000;
const fetchWithTimeout = async (url: string): Promise<string | null> => {
try {
const result = await Promise.race([
request({ url, method: 'GET' }),
new Promise<never>((_, reject) =>
activeWindow.setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)
),
]);
return result;
} catch {
return null;
}
};
const extractCanonicalUrl = (html: string): string | null => {
// <link rel="canonical" href="..."> — most authoritative
const linkMatch = html.match(/<link[^>]+rel\s*=\s*["']canonical["'][^>]+href\s*=\s*["']([^"']+)["']/i)
?? html.match(/<link[^>]+href\s*=\s*["']([^"']+)["'][^>]+rel\s*=\s*["']canonical["']/i);
if (linkMatch && linkMatch[1]) return linkMatch[1];
// <meta property="og:url" content="..."> — second best
const ogMatch = html.match(/<meta[^>]+property\s*=\s*["']og:url["'][^>]+content\s*=\s*["']([^"']+)["']/i)
?? html.match(/<meta[^>]+content\s*=\s*["']([^"']+)["'][^>]+property\s*=\s*["']og:url["']/i);
if (ogMatch && ogMatch[1]) return ogMatch[1];
return null;
};
const extractTitle = (html: string): string | null => {
// Prefer og:title, fall back to <title>
const ogMatch = html.match(/<meta[^>]+property\s*=\s*["']og:title["'][^>]+content\s*=\s*["']([^"']+)["']/i)
?? html.match(/<meta[^>]+content\s*=\s*["']([^"']+)["'][^>]+property\s*=\s*["']og:title["']/i);
if (ogMatch && ogMatch[1]) return this.decodeHtmlEntities(ogMatch[1]).trim();
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch && titleMatch[1]) return this.decodeHtmlEntities(titleMatch[1]).trim();
return null;
};
const resolveOne = async (c: GeminiCitation): Promise<GeminiCitation> => {
if (!c.url.includes(REDIRECT_HOST)) return c;
const html = await fetchWithTimeout(c.url);
if (!html) {
console.debug('[GeminiService] resolveCitationUrls — fetch failed; keeping redirect', c.url);
return c;
}
const canonical = extractCanonicalUrl(html);
const pageTitle = extractTitle(html);
return {
...c,
url: canonical ?? c.url,
title: pageTitle ?? c.title,
};
};
return Promise.all(citations.map(resolveOne));
}
private decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (_: string, n: string) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_: string, n: string) => String.fromCharCode(parseInt(n, 16)));
}
private handleError(error: unknown, editor: Editor): void {
const msg = error instanceof Error ? error.message : String(error);
console.error('Gemini error:', error);
new Notice(`Gemini error: ${msg}`);
editor.replaceRange(`\n**Error:** ${msg}\n\n***\n`, editor.getCursor());
}
}

View file

@ -743,13 +743,40 @@ export class PerplexityService {
} catch (error) {
console.error('Error in streaming response:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error during streaming';
new Notice(`Streaming error: ${errorMsg}`);
// Clear loading text and animation before showing error
const rawMsg = error instanceof Error ? error.message : 'Unknown error during streaming';
// Connectivity classes we can recognize and translate. Mid-stream
// resume isn't possible — Perplexity has no resume token — so the
// best UX is to tell the user exactly what happened and that
// they should re-run the query.
//
// - ERR_NETWORK_CHANGED : WiFi flip, VPN reconnect, sleep/wake
// - failed to fetch / TypeError: socket closed before headers
// - stream went idle for Ns : our own idle-timeout in readWithIdleTimeout
// - AbortError : caller aborted
const isNetworkChange = /NETWORK_CHANGED|network changed/i.test(rawMsg);
const isSocketDrop = /failed to fetch|load failed|socket|ECONNRESET/i.test(rawMsg);
const isIdleTimeout = /went idle/i.test(rawMsg);
const isAbort = /AbortError|aborted/i.test(rawMsg);
let userMsg: string;
if (isNetworkChange) {
userMsg = 'Network changed mid-stream (WiFi flip, VPN reconnect, or sleep/wake). Re-run the query — no partial response was saved.';
} else if (isIdleTimeout) {
userMsg = 'Perplexity stream stalled (likely API back-pressure or rate limit). Re-run the query; if it stalls again, try a smaller prompt or a non-research model.';
} else if (isAbort) {
userMsg = 'Request aborted.';
} else if (isSocketDrop) {
userMsg = `Connection dropped before Perplexity finished. Re-run the query. (raw: ${rawMsg})`;
} else {
userMsg = `Streaming error: ${rawMsg}`;
}
new Notice(userMsg);
this.clearLoadingText(editor);
this.clearLoadingAnimation();
editor.replaceRange(`\n**Streaming Error:** ${errorMsg}\n\n`, currentPos);
editor.replaceRange(`\n**Streaming Error:** ${userMsg}\n\n`, currentPos);
}
}

View file

@ -52,6 +52,22 @@ export const BUNDLED_PREAMBLES: Record<string, string> = {
'research-framing': researchFramingPreamble,
};
/**
* vault.create wrapped to tolerate the same race window ensureFolder handles:
* getAbstractFileByPath returns null but a concurrent or just-finished write
* has already produced the file (Obsidian's index lags behind the adapter
* write). Treat "File already exists" as success the caller's intent
* (file exists at path with content) is satisfied either way.
*/
async function safeCreateFile(app: App, path: string, content: string): Promise<void> {
try {
await app.vault.create(path, content);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!/already exists/i.test(msg)) throw err;
}
}
async function ensureFolder(app: App, folderPath: string): Promise<void> {
const normalized = normalizePath(folderPath);
const existing = app.vault.getAbstractFileByPath(normalized);
@ -63,7 +79,15 @@ async function ensureFolder(app: App, folderPath: string): Promise<void> {
for (const seg of segments) {
cursor = cursor ? `${cursor}/${seg}` : seg;
if (!app.vault.getAbstractFileByPath(cursor)) {
await app.vault.createFolder(cursor);
try {
await app.vault.createFolder(cursor);
} catch (err) {
// Race / stale cache: getAbstractFileByPath can return null for
// a folder Obsidian has already begun creating. Treat the
// "already exists" path as success; rethrow anything else.
const msg = err instanceof Error ? err.message : String(err);
if (!/already exists/i.test(msg)) throw err;
}
}
}
}
@ -97,7 +121,7 @@ async function seedFolder(
let seeded = 0;
const readmePath = `${normalized}/${readme.name}`;
if (!app.vault.getAbstractFileByPath(readmePath)) {
await app.vault.create(readmePath, readme.content);
await safeCreateFile(app, readmePath, readme.content);
seeded++;
}
@ -105,7 +129,7 @@ async function seedFolder(
for (const file of contentFiles) {
const path = `${normalized}/${file.name}`;
if (app.vault.getAbstractFileByPath(path)) continue;
await app.vault.create(path, file.content);
await safeCreateFile(app, path, file.content);
seeded++;
}
}
@ -187,7 +211,7 @@ export async function reSeedMissingFiles(
skipped++;
continue;
}
await app.vault.create(path, file.content);
await safeCreateFile(app, path, file.content);
seeded++;
}
}

230
src/styles/gemini-modal.css Normal file
View file

@ -0,0 +1,230 @@
/* ============================================================
Gemini Modal research query composer with Google grounding
Same skeleton as claude-modal (proven wide-modal pattern from
context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md), with
Gemini-flavored accents: subtle multi-stop gradient on the
title and a gradient hairline under the header echoing
Google's brand identity without screaming about it.
============================================================ */
.gemini-modal {
/* Modal container — wider and breathier than default */
width: 90vw;
max-width: 680px;
}
.gemini-modal .modal-content {
padding: 0;
}
/* ----- Header ----- */
.gemini-modal__header {
position: relative;
padding: 24px 28px 18px;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Gradient hairline under the header Google's four-color identity
distilled into a subtle 1px accent. Stays under the divider so it
reads as a brand cue, not a banner. */
.gemini-modal__header::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: linear-gradient(
90deg,
#4285f4 0%,
#4285f4 25%,
#ea4335 25%,
#ea4335 50%,
#fbbc04 50%,
#fbbc04 75%,
#34a853 75%,
#34a853 100%
);
opacity: 0.55;
pointer-events: none;
}
.gemini-modal__title {
margin: 0 0 6px;
font-size: 1.55em;
font-weight: 650;
letter-spacing: -0.015em;
/* Gradient title text Google blue red yellow green
transitions tuned to feel premium, not garish. Falls back
to plain text-normal in browsers that don't support
background-clip: text. */
background: linear-gradient(
92deg,
#4285f4 0%,
#9b72cb 38%,
#ea4335 64%,
#fbbc04 88%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--text-normal); /* fallback */
}
.gemini-modal__subtitle {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
/* ----- Sections ----- */
.gemini-modal__section {
padding: 18px 28px 4px;
}
.gemini-modal__section + .gemini-modal__section {
border-top: 1px solid var(--background-modifier-border-hover);
margin-top: 4px;
}
.gemini-modal__section-title {
margin: 0 0 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
}
/* Tighten the native Setting spacing inside our sections */
.gemini-modal__section .setting-item {
padding: 10px 0;
border-top: none;
}
.gemini-modal__section .setting-item + .setting-item {
border-top: 1px solid var(--background-modifier-border-hover);
}
/* ----- Question label + textarea ----- */
.gemini-modal__label {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
}
.gemini-modal__textarea {
width: 100%;
min-height: 130px;
padding: 13px 15px;
font-family: var(--font-text);
font-size: 14px;
line-height: 1.55;
color: var(--text-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 10px;
resize: vertical;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.gemini-modal__textarea:focus {
outline: none;
/* Gemini-blue focus ring instead of the theme accent so the brand
cue stays consistent inside the modal regardless of user theme. */
border-color: #4285f4;
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.22);
}
.gemini-modal__textarea::placeholder {
color: var(--text-faint);
}
/* ----- Footer / actions ----- */
.gemini-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 28px 24px;
margin-top: 12px;
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
border-bottom-left-radius: var(--modal-radius, 8px);
border-bottom-right-radius: var(--modal-radius, 8px);
}
.gemini-modal__button {
padding: 8px 20px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background-color: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease,
box-shadow 0.15s ease, transform 0.05s ease;
}
.gemini-modal__button:hover {
background-color: var(--background-modifier-hover);
}
.gemini-modal__button:active {
transform: translateY(1px);
}
/* Primary CTA Gemini-blue, gradient on hover. Stays readable in
both light and dark Obsidian themes because the gradient is on
its own background, not relying on theme tokens. */
.gemini-modal__button.mod-cta {
background: #4285f4;
color: #ffffff;
border-color: #4285f4;
box-shadow: 0 1px 2px rgba(66, 133, 244, 0.25);
}
.gemini-modal__button.mod-cta:hover {
background: linear-gradient(135deg, #4285f4 0%, #9b72cb 100%);
border-color: #4285f4;
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.35);
}
.gemini-modal__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ----- Responsive ----- */
@media (max-width: 600px) {
.gemini-modal {
width: 95vw;
max-width: none;
}
.gemini-modal__header,
.gemini-modal__section,
.gemini-modal__footer {
padding-left: 18px;
padding-right: 18px;
}
.gemini-modal__footer {
flex-direction: column-reverse;
}
.gemini-modal__button {
width: 100%;
}
}

View file

@ -9,6 +9,7 @@
@import './text-enhancement-modal.css';
@import './text-enhancement-with-images-modal.css';
@import './claude-modal.css';
@import './gemini-modal.css';
@import './settings-tab.css';
/* Custom callout styling for AI reasoning process */

View file

@ -20,3 +20,33 @@
.perplexed-json-textarea.is-extra-tall {
min-height: 600px;
}
/* Prose textarea for system prompts and other natural-language inputs.
* Distinct from json-textarea: body font (not monospace), short default
* height (~3 lines), user can resize vertically if they paste a longer prompt.
* Pair with full-width sibling-row pattern, not Setting.addTextArea. */
.perplexed-prose-textarea {
width: 100%;
min-height: 80px;
padding: 10px 12px;
font-family: var(--font-text);
font-size: 14px;
line-height: 1.5;
color: var(--text-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.perplexed-prose-textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.perplexed-prose-textarea::placeholder {
color: var(--text-faint);
}

View file

@ -1,5 +1,6 @@
{
"0.1.0": "0.15.0",
"0.1.1": "1.8.10",
"0.1.2": "1.8.10"
}
"0.1.2": "1.8.10",
"0.2.0": "1.8.10"
}