perplexed

From buildable to actually-works-in-vault — the Gemini provider's first day in Obsidian

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.

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.mdvertexaisearch.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 responseWhat we used to doWhat we do now
chunk.web.uri (redirect URL)Write to citation as-isFollow 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 textParse <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 classOld behaviorNew behavior
NETWORK_CHANGED30-line stack trace inline in the noteNetwork changed mid-stream (WiFi flip, VPN reconnect, or sleep/wake). Re-run the query — no partial response was saved.
Idle timeoutBare stream went idle for 90sPerplexity 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 dropfailed to fetchConnection dropped before Perplexity finished. Re-run the query.
User abortAbortErrorRequest 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.