diff --git a/.gitignore b/.gitignore index 338dd38..808fb6b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ main.js # obsidian data.json +# Secrets: API keys live in secrets.json.nosync (any encryption mode). +# The .nosync suffix is for iCloud; git needs its own ignore rule. +secrets.json* +*.nosync + # Exclude macOS Finder (System Explorer) View States .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 9ee2bee..4f7a77d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,11 +45,10 @@ src/ ├── main.ts # Lifecycle, commands, ribbon, settings tab registration ├── types.ts # Shared interfaces (provider IDs, configs, templates, settings) ├── http.ts # requestUrl wrappers: jsonPost/jsonGet/multipartPost + ProviderError -├── platform.ts # Active-profile resolver + MediaRecorder/Web Speech availability probes +├── platform.ts # Active-profile resolver + MediaRecorder availability probe ├── secrets.ts # safeStorage (desktop) + plaintext fallback (mobile) for API keys ├── recorder.ts # MediaRecorder state machine + getBestMimeType (no size cap; per-provider limits live in transcription/limits.ts) ├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral) -├── webspeech.ts # SpeechRecognition wrapper ├── pipeline.ts # transcribe → cleanup → insert orchestrator ├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion ├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only) @@ -77,7 +76,6 @@ src/ │ ├── deepgram.ts # single POST │ ├── revai.ts # submit → poll → fetch text │ ├── mistral-voxtral.ts # Mistral Voxtral STT (JSON response; always transcodes to WAV) -│ ├── webspeech.ts # adapter; throws "should not be called" (see Gotchas) │ └── whisper-local.ts # Thin shim that POSTs to the WhisperHost-managed local server └── llm/ ├── index.ts # LLMProvider interface + createLLMProvider() @@ -91,21 +89,21 @@ src/ [src/pipeline.ts](src/pipeline.ts) runs four stages with `onStage` callbacks for UI: 1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[]]\n\n` before insertion. -2. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault. +2. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault. 3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, and a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below). On error, the (possibly stripped) transcript is copied to the clipboard before re-throwing, so the user keeps their words. 4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`. The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer). -The `PipelineSource` union has four variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `webspeech` (live-captured transcript), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[]]\n\n` prepend. +The `PipelineSource` union has three variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[]]\n\n` prepend. ## Provider system -[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` — see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts) — because Voxtral's response is JSON-only (no `response_format=text`), it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)), and `/v1/models` doesn't surface audio-model IDs the same way it surfaces chat models. +[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` (see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts)) because Voxtral's response is JSON-only (no `response_format=text`) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)). API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile:desktop:transcription`, `profile:desktop:llm`, `profile:mobile:transcription`, `profile:mobile:llm`. -Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai, Web Speech. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The text field next to the dropdown is always the canonical source of `profile.config.model` so users can type any string the dropdown doesn't expose. +Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The text field next to the dropdown is always the canonical source of `profile.config.model` so users can type any string the dropdown doesn't expose. ## Local whisper.cpp host (desktop) @@ -217,12 +215,13 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react. - **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text. - **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other. -- **Web Speech adapter throws "should not be called".** The pipeline short-circuits when `source.kind === 'webspeech'` and uses the transcript captured live by `src/webspeech.ts` during recording. The factory still has the case to keep the switch exhaustive. On mobile WKWebView (iOS) `SpeechRecognition` is undefined, so [src/platform.ts](src/platform.ts) exports `isWebSpeechAvailable()`; the modal/setup-card and Quick Record both check it before starting a Web Speech session. - **`setHeading()` instead of manual `

`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. - **`window.confirm` is banned** by ESLint's `no-alert`. If a future phase needs an in-vault confirmation prompt, add a small `Modal` subclass rather than reaching for `window.confirm`. - **Sentence-case lint covers brand and acronym lists** in [eslint.config.mts](eslint.config.mts). Adding a new provider, model family, or product name means adding it to `REWRITE_BRANDS` (or `REWRITE_ACRONYMS` for things like `LLM`). Dropdown option labels also pass through the rule; in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts), labels are iterated via `opt.label` (member access) to dodge the literal-string check. - **Provider option arrays appear in both setup-card.ts and tab.ts.** Intentionally duplicated rather than extracted, per the "don't refactor beyond what the task requires" rule. If the lists drift, fix the user-visible inconsistency, not the duplication. - **Settings tab re-renders the entire container on dropdown changes** that toggle conditional fields (provider, insertMode, activeProfileOverride). Text fields call `saveSettings()` on change but do not redraw, so focus is preserved while typing. Preserve this pattern when adding new conditional fields. +- **Profile sections wrap their settings in `.rewrite-profile-section`.** [src/settings/tab.ts](src/settings/tab.ts) `renderProfile()` creates a wrapper div per profile rather than rendering settings as direct children of `containerEl`. The active-on-this-device profile (per `detectActiveProfileKind`) gets `is-active-profile` (accent left border) and a `.rewrite-profile-active-badge` span inside the heading's `nameEl`. The inactive profile's body is wrapped in a `
` whose expand state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by dropdowns. New per-profile settings must take `body` as their parent (the wrapper or the `
`), not the original `parent` arg, or they will render outside the section's visual frame. +- **Both provider unions include `'none'`.** [src/types.ts](src/types.ts) `TranscriptionProviderID` and `LLMProviderID` carry a `'none'` member for users who only want one half of the pipeline. The factories in [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) return sentinel providers (transcription throws on `transcribe()`; LLM `complete()` returns the user message unchanged), but the pipeline never actually calls these because: (a) `collectTranscript` throws a friendlier error when `transcriptionProvider === 'none'` and `source.kind === 'audio'`; (b) `cleanupTranscript` short-circuits and returns the raw transcript when `llmProvider === 'none'` (this also skips wake-name extraction and known-nouns injection, since both only matter when an LLM consumes the system prompt). The settings tab + setup card hide model/baseUrl/apiKey fields for the `'none'` side; `isProfileConfigured` / `isProfileConfiguredForText` treat `'none'` as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate on `transcriptionProvider === 'none'` with a "use Paste instead" hint. - **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits. - **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`. - **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`. @@ -242,6 +241,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `
` whose `` reads `"Destination: Default ()"` (no override) or `"Destination: Custom ()"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`). - **Quick Record floater holds its own popover.** The floater grew a third button between the timer and Stop that opens a popover-style template list ([src/ui/quick-record.ts](src/ui/quick-record.ts)). The popover is a child of the floater div, listens for outside-click via a capture-phase `document` listener and Escape via `document keydown`, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and when `setBusy` runs (the pipeline is in flight). Selecting a template updates `controller.template` but does NOT update `lastUsedTemplateId`; that only happens after a successful completion, matching pre-popover behavior. - **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system. +- **Mobile keyboard scroll uses `visualViewport`, not `scrollIntoView` alone.** Android WebViews don't resize the layout viewport when the soft keyboard opens, so a bare `scrollIntoView({ block: 'center' })` centers the input on the full screen, which is then covered by the keyboard. `installMobileKeyboardScrollFix` in [src/platform.ts](src/platform.ts) waits for `window.visualViewport.resize` (keyboard finished opening), then runs a two-step recovery: first scroll the nearest scrollable ancestor by the delta needed to lift the input (this is what works on the settings tab); if the input is still below the visible region, shrink the enclosing `.modal-container` to the visible region by setting inline `top` and `height`. Obsidian's flex centering inside `.modal-container` then re-positions the `.modal` popup above the keyboard. Scrolling alone cannot move a `position: fixed` popup. The container shrink restores on `blur` and re-fits on subsequent `visualViewport.resize` events (keyboard height changes, emoji panel, etc.); it fully restores when `vv.height` returns to near `window.innerHeight`. Falls back to the legacy `scrollIntoView` path when `visualViewport` is unavailable. Attach it on every container that hosts user-facing text inputs ([src/ui/modal.ts](src/ui/modal.ts), [src/settings/tab.ts](src/settings/tab.ts), [src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts), `RenamePromptModal` in [src/insert.ts](src/insert.ts)); `focusin` bubbles, so a single listener on the container covers descendants and the inline setup card. ## Local install for testing diff --git a/README.md b/README.md index e6a56e2..d997693 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ You bring your own provider keys. Nothing is sent to a ReWrite server; the plugi ## Features - Record audio directly in Obsidian, or paste a pre-existing transcript. -- 7 transcription providers: OpenAI Whisper, OpenAI-compatible (whisper.cpp, faster-whisper-server, etc.), Groq, AssemblyAI, Deepgram, Rev.ai, the browser-native Web Speech API, and a plugin-managed local whisper.cpp server (desktop). +- 7 transcription providers: OpenAI Whisper, OpenAI-compatible (whisper.cpp, faster-whisper-server, etc.), Groq, AssemblyAI, Deepgram, Rev.ai, Mistral Voxtral, and a plugin-managed local whisper.cpp server (desktop). - 5 LLM providers for cleanup: Anthropic Claude, OpenAI GPT, OpenAI-compatible (Ollama, LM Studio), Google Gemini, Mistral. - Desktop and Mobile profiles, auto-selected by environment with a manual override. - 5 starter templates (General cleanup, Todo list, Daily note, Meeting notes, Idea capture); fully editable and reorderable. @@ -223,7 +223,6 @@ secrets.json.nosync Obsidian on iOS and Android runs in a constrained WebView. A few things behave differently from desktop: -- **Web Speech (default mobile transcription provider)** is unavailable on iOS Obsidian (WKWebView does not implement `SpeechRecognition`). On Android, support is patchy. If Web Speech is unavailable, the modal surfaces a notice and you can switch to the Paste tab or pick a different transcription provider on your Mobile profile. - **iOS screen-off**: `MediaRecorder` silently stops capturing audio when the screen turns off on iOS. The plugin cannot prevent this; keep the screen on while recording, or use the Paste tab with an OS-level dictation keyboard. - **API keys are stored in plaintext on mobile** because Electron's `safeStorage` is not available. The `secrets.json.nosync` file still uses the `.nosync` filename so iCloud Drive will skip it, but for other sync tools you must apply the exclusion rules above. - **Recording size limit**: clips over 25 MB are rejected. This is a transcription-API limit, not an Obsidian one, and is most likely to bite on long mobile recordings. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b779791..8c7134c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -12,19 +12,14 @@ Phase A shipped (see Done). Remaining work: - "Stop when idle for N minutes" toggle (default off; useful for the large-v3 user who doesn't want 1.5 GB resident all day). - Process supervision hardening based on real-world Phase A usage (zombies, orphans, signal handling differences across platforms). -### 2. Mobile API management UX +### 2. Mobile API management UX — remaining sub-issues -Editing keys, base URLs, and model strings on mobile is rough. Two concrete issues plus one visual ask: +The visual differentiator (border + badge + collapsed inactive profile) shipped in Done. Two adjacent items still open: -- The two profile sections in [src/settings/tab.ts](../src/settings/tab.ts) render identically, so it's easy to edit the desktop profile while sitting on the phone. Add a visual differentiator (color accent, badge, or border on the active-on-this-device profile) so the mobile vs desktop section is unmistakable. - The settings tab re-renders the entire container on dropdown changes (provider, insertMode, activeProfileOverride per [CLAUDE.md](../CLAUDE.md)'s Gotchas), which on mobile causes a visible scroll jump after committing a text field that sits next to a dropdown. Text fields already skip the re-render; audit whether any newer fields slipped into the redraw path and consider scrolling the focused row back into view after redraw. -- On mobile the on-screen keyboard frequently covers the input that just received focus. Likely needs a `scrollIntoView({ block: 'center' })` on the active control's focus/blur events, or a `keyboardWillShow`-equivalent hook through Obsidian's API. +- On mobile the on-screen keyboard frequently covers the input that just received focus. Likely needs a `scrollIntoView({ block: 'center' })` on the active control's focus/blur events, or a `keyboardWillShow`-equivalent hook through Obsidian's API. Needs device testing to characterize which fields, when on focus vs blur vs commit. -### 3. (Bug) Voxtral transcription model dropdown is empty - -[src/transcription/mistral-voxtral.ts](../src/transcription/mistral-voxtral.ts) does not implement `listModels`, so the Refresh button on the model field is a no-op for the Voxtral transcription provider. Mistral's `/v1/models` returns the full catalog including chat models; filter to audio/Voxtral capability (probably `id.includes('voxtral')` for a v1) when surfacing transcription model IDs. Cross-check: the LLM-side Mistral provider already uses the shared OpenAI adapter's listing, so this gap is transcription-only. - -### 4. Real-time transcription mode (live STT, no cleanup) +### 3. Real-time transcription mode (live STT, no cleanup) Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, and a few other providers have equivalents (Deepgram streaming, AssemblyAI realtime). Investigate adding an opt-in "Real-time" mode: @@ -33,11 +28,7 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an - Provider-side gating: only enable for transcription providers that expose a realtime endpoint; document which. - Honest assessment: this is lower-value than the rest of the plugin (transcript with no cleanup is what Obsidian's existing voice plugins already do). Ship only if users ask. -### 5. (Bug) Web Speech repeats short phrases indefinitely - -The Web Speech adapter in [src/webspeech.ts](../src/webspeech.ts) currently loops short utterances: the same phrase is repeated over and over instead of producing one transcript line. Likely cause is the interim-result accumulator concatenating without deduping against final results, or the restart-on-end logic re-emitting buffered interim text. Needs investigation. Until fixed, document Web Speech as a "do not use" option in the README (or remove it from the provider dropdown). - -### 6. Daily-note template: how far can the prompt drive a structured fill? +### 4. Daily-note template: how far can the prompt drive a structured fill? The current daily-note default template is a freeform cleanup. Investigate whether the system prompt can reliably lay the transcript into a real structured daily-note template with named sections (Mood / Highlights / Tasks / Tomorrow / etc.) rather than emitting prose. References: @@ -50,6 +41,18 @@ Open questions: do we ship this as an updated default template (prompt-only), or ## Done +### Visual differentiation between desktop and mobile profile sections + +[src/settings/tab.ts](../src/settings/tab.ts) `renderProfile()` now wraps each profile's settings in a `.rewrite-profile-section` div. The active-on-this-device profile (resolved via `detectActiveProfileKind` from [src/platform.ts](../src/platform.ts)) gets an `is-active-profile` modifier that adds an accent-colored left border, plus a `.rewrite-profile-active-badge` span inserted into the heading's `nameEl` reading "Active on this device". The inactive profile's body (everything below the heading) is rendered inside a `
` collapsed by default, with a "Show settings" summary. Expanded state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by provider/insertMode/activeProfileOverride dropdowns. New CSS classes in [styles.css](../styles.css): `.rewrite-profile-section`, `.rewrite-profile-section.is-active-profile`, `.rewrite-profile-active-badge`, `.rewrite-profile-collapsed`. The mobile keyboard-cover and scroll-jump sub-items from the original issue are deferred (see Open) pending device testing. + +### Voxtral transcription model dropdown populates + +[src/transcription/mistral-voxtral.ts](../src/transcription/mistral-voxtral.ts) gained a `listModels` method that fetches `https://api.mistral.ai/v1/models` and filters to IDs containing `voxtral` (case-insensitive), sorted. The shape mirrors the listModels in [src/transcription/openai.ts](../src/transcription/openai.ts), but stays a local filter rather than extending `filterAudioTranscriptionModels` (which is hardcoded to whisper/transcribe and owned by the OpenAI/Groq path). The settings tab's existing model-field dropdown + Refresh button pick up the new method automatically through the `typeof provider.listModels === 'function'` check. + +### Removed the Web Speech transcription provider + +The browser-native Web Speech API option (`'webspeech'`) is gone. An earlier idempotency fix to `src/webspeech.ts` stopped the "short phrase repeats indefinitely" symptom, but field testing showed the underlying provider still produced garbled output and froze after a few seconds, with no path to a reliable experience. With six remote providers plus local whisper.cpp covering both platforms, Web Speech wasn't worth keeping as a viable option. Removed: `src/webspeech.ts`, `src/transcription/webspeech.ts`, the `'webspeech'` variant of `TranscriptionProviderID`, the `'webspeech'` variant of `PipelineSource`, `isWebSpeechAvailable()` in [src/platform.ts](../src/platform.ts), the dropdown entry in [src/settings/tab.ts](../src/settings/tab.ts) and [src/ui/setup-card.ts](../src/ui/setup-card.ts), all `transcriptionProvider !== 'webspeech'` conditionals in those files, the Web Speech branches in [src/ui/modal.ts](../src/ui/modal.ts) and [src/ui/quick-record.ts](../src/ui/quick-record.ts), the `isWebSpeech` constructor param on `QuickRecordController`, the `webspeech` case in [src/transcription/limits.ts](../src/transcription/limits.ts), and all CLAUDE.md / README / eslint-config references. The mobile default `transcriptionProvider` changed from `'webspeech'` to `'openai'`. Pre-release rule: no migration code; dev installs with old data should delete `data.json`. + ### Mode selector on the Quick Record floater The Quick Record floating mini-UI ([src/ui/quick-record.ts](../src/ui/quick-record.ts)) gained a third button between the timer and Stop that opens a popover-style list of `plugin.templates`. Selecting an entry updates `QuickRecordController.template` in place and the button label so the user can see which template will run. Switching does NOT update `lastUsedTemplateId`; that still only happens after a successful completion. The popover is a child of the floater div, dismisses on selection / Escape / outside-click (capture-phase listeners on `document`, registered and torn down per open), and is force-closed when `setBusy` runs so the user can't switch templates after Stop is pressed. CSS additions in [styles.css](../styles.css): `.rewrite-quick-template` (the button), `.rewrite-quick-popover` (the floating list), `.rewrite-quick-popover-item` (rows, with an `is-active` modifier for the current selection). No destination-override UI on the floater per the v1 scope decision. diff --git a/docs/MOBILE_KEYBOARD_BUG.md b/docs/MOBILE_KEYBOARD_BUG.md new file mode 100644 index 0000000..4e1b304 --- /dev/null +++ b/docs/MOBILE_KEYBOARD_BUG.md @@ -0,0 +1,244 @@ +# Mobile keyboard covers plugin text boxes (Android) — investigation log + +Status: **UNRESOLVED.** Three substantively different code approaches have produced no +observable change on the user's Android device. This document records everything tried so +the problem can be tackled methodically (instrument first, then fix) rather than by more +blind guessing. + +## Problem statement + +On Android, when a text field managed by the plugin receives focus, the on-screen software +keyboard slides up and covers the text field. The field does not move out of the way, so the +user cannot see what they are typing. The expected behavior (seen in Obsidian core UIs and in +other plugins) is that the focused field scrolls/repositions above the keyboard. + +Reporter platform: Android, Obsidian mobile. Desktop and iOS are not affected (the helper +early-returns on `!Platform.isMobile`, and iOS WebKit resizes the layout viewport when the +keyboard opens, which makes naive `scrollIntoView` work there). + +## Test matrix + +The reporter exercises these flows, positioning each field low enough that the keyboard would +overlap it: + +| # | Flow | Surface | Container | Last reported result | +|---|------|---------|-----------|----------------------| +| 1 | Quick controls (bottom-right hamburger) → ReWrite → tap **Unlock** | Passphrase unlock prompt | `PassphraseModal` (popup) | FAIL | +| 2 | Quick controls → ReWrite → **Paste** → tap textarea | Paste tab textarea | `ReWriteModal` (popup) | FAIL | +| 3 | Sidebar → Settings gear → ReWrite (Voice Notes) → scroll to **Change passphrase** → tap both fields | Change-passphrase prompt | `PassphraseModal` (popup, `requireConfirm`) | FAIL | +| 4 | Sidebar → Settings gear → ReWrite (Voice Notes) → tap a field (transcription API key, assistant name, attachments folder, etc.) | Settings tab field | `ReWriteSettingTab` (settings panel) | PASS (confirmed still passing) | + +**Disambiguation (confirmed by reporter):** test 4 still succeeds; tests 1-3 still fail. So the +split is stable: scrollable settings surface works, fixed-position popups do not. + +**Critical caveat — this does NOT prove our code runs.** Test 4 passing is most likely *native +browser / Obsidian-core* behavior, not our helper. Chrome on Android natively scrolls a focused +input into view within its nearest scrollable ancestor, and is keyboard-aware when doing so. The +settings panel is exactly that: a tall scrollable surface. A fixed-position centered popup is not, +so native focus-scroll cannot move it and the field stays covered. In other words, the test 4 / +tests 1-3 split is fully explained by *the browser*, with our helper contributing nothing in any of +the four cases. **Hypothesis A (our code is a silent no-op because `visualViewport` never changes) +remains fully alive.** We still have zero positive evidence that `installMobileKeyboardScrollFix` +mutates anything on the device. + +The reporter confirmed the build was deployed cleanly: they deleted the plugin's `main.js`, +`manifest.json`, and `styles.css` and recreated them (keeping `data.json` and +`secrets.json.nosync`), so stale-artifact caching is ruled out. + +## The code under test + +All attempts live in one helper: `installMobileKeyboardScrollFix(root)` in +[src/platform.ts](../src/platform.ts). It is attached (via `focusin`, which bubbles) to: + +- [src/ui/modal.ts](../src/ui/modal.ts) line 31 — main modal `contentEl` +- [src/settings/tab.ts](../src/settings/tab.ts) line 69 — settings `containerEl` +- [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts) line 29 — passphrase modal `contentEl` (added this session) +- [src/insert.ts](../src/insert.ts) line 121 — `RenamePromptModal` `contentEl` (added this session) + +The helper early-returns unless `Platform.isMobile`. + +## Chronology of attempts + +### Attempt 0 — pre-existing baseline (before this investigation) + +- **Mechanism:** `focusin` → `setTimeout(300ms)` → `target.scrollIntoView({ block: 'center', behavior: 'smooth' })`. +- **Wired into:** main modal + settings tab only. +- **Theory:** scroll the focused input to the center of its scroll container after the keyboard + animation starts. +- **Why it can't work on Android:** `scrollIntoView({ block: 'center' })` centers the element in + the *layout* viewport (the full screen). Android (unlike iOS) does not shrink the layout + viewport when the keyboard opens, so "center of the full screen" is still behind the keyboard's + top edge for low fields. + +### Attempt 1 — visualViewport + scroll nearest scrollable ancestor + +- **Change:** Rewrote the helper to read `window.visualViewport`. On `focusin`: if the visual + viewport is already shrunk act immediately, else wait for a one-shot `visualViewport` `resize` + (600 ms safety timeout). Then compute `visibleBottom = vv.offsetTop + vv.height`, and if the + input's `rect.bottom` is below `visibleBottom - 16`, scroll the nearest scrollable ancestor by + the delta (fallback `window.scrollBy`). Kept a `scrollIntoView` fallback for when + `visualViewport` is unavailable. +- **Also:** wired the helper into `PassphraseModal` and `RenamePromptModal`. +- **Theory:** `visualViewport` reflects the real post-keyboard visible region on Android even + though the layout viewport does not, so we can scroll precisely. +- **Result:** Reporter: test 4 (settings) works; tests 1-3 (popups) fail. + +### Attempt 2 — translate the `.modal` box via CSS transform + +- **Change:** Added a branch: when no scrollable ancestor is found, walk up to the `.modal` + element and apply a composed `transform: translateY(-delta)` to lift the modal box, + restoring on `blur`. Composition read the current computed transform so as not to wipe + Obsidian's centering transform. +- **Theory:** short popups have no scroll room, so the scroll path did nothing; lift the whole + fixed-position modal instead. +- **Result:** Reporter: still broken for tests 1-3. + +### Attempt 3 — shrink `.modal-container` so flex re-centers the popup + +- **Change (current code):** Two-step in `liftAboveKeyboard`: (1) scroll the nearest scrollable + ancestor and re-measure; (2) if the input is still below the visible region, walk up to + `.modal-container` and set inline `top = vv.offsetTop` and `height = vv.height`, shrinking the + fixed-position container to the visible region so Obsidian's flex centering re-positions + `.modal` above the keyboard. Restore on `blur`; re-fit on subsequent `visualViewport` `resize`; + fully restore when `vv.height` returns to near `window.innerHeight`. +- **Theory:** `.modal-content` actually has `overflow: auto`, so Attempt 2's scroll path was being + taken and the transform branch never ran. Scrolling inside `.modal-content` shifts the input + within the modal but cannot move the fixed, centered modal itself; shrinking the container makes + the existing flex centering do the work. +- **Result:** Reporter: "No change for any of the tests." + +## Current code (verbatim summary) + +`liftAboveKeyboard(target, vv)`: +1. `visibleBottom = vv.offsetTop + vv.height`. If `rect.bottom <= visibleBottom - 16`, **return + (do nothing)** — we believe the input is already visible. +2. Scroll nearest scrollable ancestor by delta; re-measure; return if now visible. +3. Else shrink `.modal-container` (`top`/`height`), restore on blur. +4. Else `window.scrollBy`. + +The entry path waits for `visualViewport.resize` (or acts immediately if `vv.height < +window.innerHeight - 100`), with a 600 ms fallback timer. + +## Unverified assumptions (the heart of the problem) + +We have been writing code against a mental model that has **never been confirmed on the actual +device**. Every one of these is a guess: + +1. **That the `focusin` listener fires at all.** Never confirmed. If `Platform.isMobile` is + somehow false, or the listener is attached to the wrong element, nothing runs. +2. **That `window.visualViewport` shrinks when the Android keyboard opens.** This is the load-bearing + assumption for all three attempts, and it is the most suspect (see hypothesis A). +3. **That `window.innerHeight` does / doesn't change** when the keyboard opens (drives the + "already up" check and the restore condition). +4. **The DOM structure and class names** of Obsidian mobile modals (`.modal-container` > `.modal` > + `.modal-content`), and that `.modal-content` has `overflow: auto`, and that `.modal-container` + centers via flex. Never inspected on device. +5. **That step 1's early-return condition is ever false on the device.** If `visualViewport` does + not shrink, `rect.bottom <= visibleBottom - 16` is always true and the function is a guaranteed + no-op — which would perfectly explain "no change for any approach." +6. **Whether test 4 "working" is our code or Obsidian's own behavior.** If Obsidian core scrolls + its own settings panel on focus, test 4 would "pass" regardless of our helper, and we have zero + evidence our code does anything anywhere. + +## Leading hypotheses (ranked) + +### A. `visualViewport` does not reflect the keyboard in Obsidian's WebView (most likely) + +Obsidian mobile is a Capacitor app. The Capacitor **Keyboard** plugin has a `resize` mode +(`native` / `body` / `ionic` / `none`). If Obsidian uses `resize: 'none'` (keyboard overlays +content), then **neither `window.innerHeight` nor `window.visualViewport.height` changes** when the +keyboard opens — the keyboard simply floats on top. In that case: + +- The entry "already up" check (`vv.height < innerHeight - 100`) is never true. +- The `visualViewport` `resize` event may never fire, so we fall to the 600 ms timer. +- When `liftAboveKeyboard` finally runs, `visibleBottom` equals the full-screen bottom, so + `rect.bottom <= visibleBottom - 16` is true and **we return immediately, doing nothing.** + +This single hypothesis explains why three completely different mutation strategies all produced +no visible change: **none of them ever executed their mutation.** This is the first thing to +verify. + +If true, the correct signal is the Capacitor keyboard event, not `visualViewport`. Capacitor +dispatches DOM events on `window`: `keyboardWillShow` / `keyboardDidShow` (with +`event.keyboardHeight`) and `keyboardWillHide` / `keyboardDidHide`. Obsidian is known to surface +these. The fix would key off `keyboardHeight` instead of `visualViewport`. + +### B. The listener never fires / wrong element + +Less likely given the helper is attached at four call sites, but unproven. Would also explain +"no change anywhere." + +### C. `visualViewport` works, but our DOM model is wrong + +If `visualViewport` does shrink (so step 1 does not early-return) but `.modal-container` is not the +real class, or it does not center via flex, or `top/height` are overridden by `!important` rules, +then step 3 silently fails. This is plausible only if hypothesis A is false. + +## Recommended next step: instrument before fixing + +Stop guessing. Add a temporary on-screen diagnostic (a `Notice`, or a fixed debug `
`) that +fires on `focusin` and again ~400 ms later, dumping the real numbers. Because remote DevTools on +Android Obsidian is fiddly, an on-screen readout is the lowest-friction way to get ground truth. + +Capture, at focus time and after a delay: + +- `Platform.isMobile` +- `window.innerHeight` before vs. after keyboard +- `window.visualViewport?.height`, `.offsetTop`, `.pageTop` before vs. after +- whether a `visualViewport` `resize` event fired (counter) +- whether any `keyboardDidShow` window event fired, and its `keyboardHeight` if present +- the focused element's `getBoundingClientRect().bottom` +- the chain of ancestor class names from the input up to `body` (to confirm `.modal-container` / + `.modal` / `.modal-content` and which one, if any, is scrollable) + +The single most important data point: **does `visualViewport.height` (or `innerHeight`) actually +decrease when the keyboard appears?** If no, hypothesis A is confirmed and we pivot to Capacitor +keyboard events. If yes, hypothesis A is dead and we debug the DOM model (hypothesis C). + +If feasible, also enable remote debugging: connect the Android device over USB, open +`chrome://inspect` in desktop Chrome, inspect the Obsidian WebView, and watch the DOM/visualViewport +live while focusing a field. This is the gold-standard confirmation. + +## Alternative fix approaches not yet tried + +Pursue these only after instrumentation tells us which signal is real. + +1. **Capacitor keyboard events (most promising if hypothesis A holds).** Listen on `window` for + `keyboardWillShow` / `keyboardDidShow` and read `event.keyboardHeight`. Apply a bottom inset + (e.g. `padding-bottom` on the scrollable region, or shrink `.modal-container` by + `keyboardHeight`) and reverse it on `keyboardWillHide` / `keyboardDidHide`. This does not depend + on `visualViewport` updating. +2. **CSS environment inset.** Newer WebViews expose `env(keyboard-inset-height)` / + `env(keyboard-inset-bottom)` when the page opts in via the `interactive-widget` viewport meta — + but a plugin cannot set the viewport meta, so this likely requires Obsidian-level support. + Worth testing whether the env vars are non-zero on the device anyway. +3. **Pure CSS positioning of popups.** Anchor `.rewrite-modal` / `.rewrite-passphrase-modal` to the + top of the screen on mobile (e.g. `.is-mobile .modal { top: 8px; transform: none; }`) so even a + non-moving modal sits above where the keyboard appears. Crude, but independent of any JS signal, + and a useful diagnostic: if a top-anchored modal is still covered, the keyboard is taller than + assumed or our CSS is being overridden. +3b. **Make popups behave like the settings surface (highest-leverage, given the confirmed split).** + The one thing that demonstrably works is a tall scrollable container (settings tab) where native + keyboard-aware focus-scroll does the job. Mimic that for popups: on mobile, top-anchor the popup + and give its content a scrollable region that can extend below the fold + (`.is-mobile .rewrite-modal { top: 0; transform: none; max-height: 100%; }` plus an + `overflow-y: auto` content area with enough height that the focused field can scroll up). If the + browser then scrolls popup fields into view the same way it does settings fields, the bug is + fixed without relying on `visualViewport` or Capacitor events at all. This is CSS-first and + signal-independent, and it directly transplants the working case onto the failing cases — try it + before the JS-signal approaches. +4. **Ask whether Obsidian already exposes a hook.** Check Obsidian's mobile behavior and any + documented API for keyboard insets before reinventing it; mirror whatever the core settings + panel does (since test 4 historically worked, the core mechanism is worth copying directly). +5. **Confirm the `webspeech` removal / unrelated churn isn't interfering.** Unlikely, but the + working tree has broad uncommitted changes; verify the helper is actually the code shipping in + `main.js` (search the built bundle for `visualViewport` / `modal-container`). + +## Key references + +- Helper: [src/platform.ts](../src/platform.ts) `installMobileKeyboardScrollFix` / `liftAboveKeyboard` +- Call sites: [src/ui/modal.ts](../src/ui/modal.ts):31, [src/settings/tab.ts](../src/settings/tab.ts):69, [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts):29, [src/insert.ts](../src/insert.ts):121 +- Digital Garden plugin (referenced by the reporter as a working example): its Appearance modal is a + bare `new Modal(app)` with **no** keyboard-handling code — its good behavior comes from Obsidian + core, not plugin code. So "copy Digital Garden" reduces to "copy Obsidian core's modal behavior." +- Gotcha entry summarizing the helper: [CLAUDE.md](../CLAUDE.md) (search "Mobile keyboard scroll"). diff --git a/eslint.config.mts b/eslint.config.mts index 008c445..b9beffb 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -5,7 +5,7 @@ import { globalIgnores } from "eslint/config"; // Mirror the plugin's DEFAULT_BRANDS / DEFAULT_ACRONYMS (the rule replaces the // defaults when options are passed, so we have to provide the full lists) plus -// the extras this project needs: provider names, "Web Speech", "ReWrite", "LLM". +// the extras this project needs: provider names, "ReWrite", "LLM". const DEFAULT_BRANDS = [ "iOS", "iPadOS", "macOS", "Windows", "Android", "Linux", "Obsidian", "Obsidian Sync", "Obsidian Publish", @@ -33,7 +33,6 @@ const DEFAULT_ACRONYMS = [ const REWRITE_BRANDS = [ "ReWrite", - "Web Speech", "OpenAI", "Whisper", "Anthropic", "Claude", "Google Gemini", "Gemini", diff --git a/src/insert.ts b/src/insert.ts index 21e46be..05a81e6 100644 --- a/src/insert.ts +++ b/src/insert.ts @@ -1,4 +1,5 @@ import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian'; +import { installMobileKeyboardScrollFix } from './platform'; import { NewFileCollisionMode, NoteTemplate } from './types'; export type InsertStage = 'cursor' | 'newFile' | 'append'; @@ -117,6 +118,7 @@ class RenamePromptModal extends Modal { onOpen(): void { const { contentEl } = this; this.modalEl.addClass('rewrite-rename-modal'); + installMobileKeyboardScrollFix(contentEl); contentEl.createEl('h2', { text: 'File already exists' }); contentEl.createEl('p', { text: `A file already exists at ${this.conflictPath}. Choose a new path.` }); diff --git a/src/llm/index.ts b/src/llm/index.ts index 19d8587..bbe7bcf 100644 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -16,6 +16,11 @@ export interface LLMProvider { export function createLLMProvider(id: LLMProviderID): LLMProvider { switch (id) { + case 'none': + return { + id: 'none', + complete: async (_systemPrompt, userMessage) => userMessage, + }; case 'anthropic': return createAnthropicLLM(); case 'openai': diff --git a/src/pipeline.ts b/src/pipeline.ts index 207a3c8..aeac1de 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -14,7 +14,6 @@ export type PipelineStage = 'persist-audio' | 'transcribe' | 'cleanup' | 'insert export type PipelineSource = | { kind: 'audio'; audio: Blob; sourcePath?: string; durationMs?: number } | { kind: 'paste'; text: string } - | { kind: 'webspeech'; transcript: string } | { kind: 'text'; text: string }; export interface PipelineParams { @@ -87,9 +86,10 @@ async function collectTranscript(params: PipelineParams): Promise { case 'paste': case 'text': return source.text; - case 'webspeech': - return source.transcript; case 'audio': { + if (params.profile.transcriptionProvider === 'none') { + throw new Error('Transcription is disabled (provider set to None). Use the Paste or From note tab instead.'); + } validateRecording(source.audio.size, source.durationMs, params.profile.transcriptionProvider); params.onStage?.('transcribe'); const provider = createTranscriptionProvider(params.profile.transcriptionProvider); @@ -99,6 +99,12 @@ async function collectTranscript(params: PipelineParams): Promise { } async function cleanupTranscript(params: PipelineParams, transcript: string): Promise { + // LLM=none: insert the transcript as-is. Skips wake-name extraction and + // known-nouns injection too, because both only matter when an LLM consumes + // the system prompt. + if (params.profile.llmProvider === 'none') { + return transcript; + } let systemPrompt = params.template.prompt; let workingTranscript = transcript; if (params.settings.adHocInstructionsEnabled && params.settings.assistantName.trim().length > 0) { diff --git a/src/platform.ts b/src/platform.ts index 9aef207..b9563d5 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -26,10 +26,147 @@ export function isMediaRecorderAvailable(): boolean { return typeof MediaRecorder !== 'undefined' && typeof navigator !== 'undefined' && !!navigator.mediaDevices; } -export function isWebSpeechAvailable(): boolean { - const w = window as unknown as { - SpeechRecognition?: unknown; - webkitSpeechRecognition?: unknown; - }; - return Boolean(w.SpeechRecognition || w.webkitSpeechRecognition); +// Mobile soft-keyboards routinely cover whichever input just received focus. +// This helper attaches a `focusin` listener to `root` and, when on mobile, +// brings the focused input/textarea into the visible region above the +// keyboard. Android WebViews do NOT resize the layout viewport when the +// keyboard opens (iOS does), so a bare `scrollIntoView({ block: 'center' })` +// centers the input on the full screen, which is then covered by the +// keyboard. We use `window.visualViewport` to read the actual post-keyboard +// visible region, then: +// 1. Scroll the nearest scrollable ancestor by the delta needed to lift +// the input. On the settings tab this fully resolves the case. +// 2. If the input is still below the visible region (typical for short +// Obsidian popups whose `.modal-content` has no scroll room), shrink +// the enclosing `.modal-container` to the visible region via inline +// `top`/`height`. Obsidian centers `.modal` inside `.modal-container` +// via flex, so the popup re-positions above the keyboard. Restore on +// blur. Scrolling cannot move a `position: fixed` modal up by itself. +// Falls back to `scrollIntoView` when `visualViewport` is unavailable. Safe +// to call on desktop; it returns immediately. The caller's element is +// expected to outlive the listener (Obsidian re-empties containers across +// renders rather than detaching them), so no explicit teardown is provided. +export function installMobileKeyboardScrollFix(root: HTMLElement): void { + if (!Platform.isMobile) return; + root.addEventListener('focusin', (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return; + const vv = window.visualViewport; + if (!vv) { + window.setTimeout(() => { + try { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } catch { + target.scrollIntoView(); + } + }, 300); + return; + } + + const act = () => liftAboveKeyboard(target, vv); + // If the keyboard is already up (visual viewport already shrunk), act now. + if (vv.height < window.innerHeight - 100) { + window.setTimeout(act, 50); + return; + } + // Otherwise wait for the visual viewport to shrink (keyboard opens), + // with a safety timeout so we still scroll if no resize fires. + let done = false; + const onResize = () => { + if (done) return; + done = true; + vv.removeEventListener('resize', onResize); + window.clearTimeout(timer); + act(); + }; + const timer = window.setTimeout(() => { + if (done) return; + done = true; + vv.removeEventListener('resize', onResize); + act(); + }, 600); + vv.addEventListener('resize', onResize); + }); +} + +function liftAboveKeyboard(target: HTMLElement, vv: VisualViewport): void { + const margin = 16; + const visibleBottom = vv.offsetTop + vv.height; + let rect = target.getBoundingClientRect(); + if (rect.bottom <= visibleBottom - margin) return; + let delta = rect.bottom - (visibleBottom - margin); + + // First try scrolling the nearest scrollable ancestor. This is what works + // on the settings page (the inner tab-content scrolls and the input rises + // into the visible region). + const scrollable = findScrollableAncestor(target); + if (scrollable) { + scrollable.scrollTop += delta; + rect = target.getBoundingClientRect(); + if (rect.bottom <= visibleBottom - margin) return; + delta = rect.bottom - (visibleBottom - margin); + } + + // Still hidden. The input lives inside a `position: fixed` popup whose + // `.modal-content` doesn't have enough scroll room (passphrase modal, + // rename prompt, the Paste tab modal, etc.). Shrink the enclosing + // `.modal-container` to the visible region; Obsidian centers `.modal` + // inside it via flex, so the popup re-positions above the keyboard. + const container = findModalContainer(target); + if (container) { + applyModalContainerLift(container, target, vv); + return; + } + window.scrollBy(0, delta); +} + +function findScrollableAncestor(el: HTMLElement): HTMLElement | null { + let node: HTMLElement | null = el.parentElement; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + const overflowY = style.overflowY; + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return null; +} + +function findModalContainer(el: HTMLElement): HTMLElement | null { + let node: HTMLElement | null = el.parentElement; + while (node && node !== document.body) { + if (node.classList.contains('modal-container')) return node; + node = node.parentElement; + } + return null; +} + +function applyModalContainerLift(container: HTMLElement, target: HTMLElement, vv: VisualViewport): void { + const prevTop = container.style.top; + const prevHeight = container.style.height; + container.style.top = `${vv.offsetTop}px`; + container.style.height = `${vv.height}px`; + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + target.removeEventListener('blur', onBlur); + vv.removeEventListener('resize', onResize); + container.style.top = prevTop; + container.style.height = prevHeight; + }; + const onBlur = () => restore(); + const onResize = () => { + // Keyboard closed: visual viewport returns to roughly full window height. + if (vv.height >= window.innerHeight - 50) restore(); + else { + // Keyboard height may have changed (e.g., emoji panel toggled); + // re-fit the container. + container.style.top = `${vv.offsetTop}px`; + container.style.height = `${vv.height}px`; + } + }; + target.addEventListener('blur', onBlur); + vv.addEventListener('resize', onResize); } diff --git a/src/settings/index.ts b/src/settings/index.ts index 2c1f95d..d40d188 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -33,7 +33,7 @@ const DESKTOP_DEFAULT_PROFILE: EnvironmentProfile = { const MOBILE_DEFAULT_PROFILE: EnvironmentProfile = { name: 'Mobile', - transcriptionProvider: 'webspeech', + transcriptionProvider: 'openai', transcriptionConfig: { ...EMPTY_TRANSCRIPTION_CONFIG }, llmProvider: 'anthropic', llmConfig: { ...EMPTY_LLM_CONFIG }, diff --git a/src/settings/tab.ts b/src/settings/tab.ts index f14570c..290ee2a 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -11,7 +11,7 @@ import { TranscriptionConfig, TranscriptionProviderID, } from '../types'; -import { detectActiveProfileKind } from '../platform'; +import { detectActiveProfileKind, installMobileKeyboardScrollFix } from '../platform'; import { createTranscriptionProvider } from '../transcription'; import { createLLMProvider } from '../llm'; import { formatWhisperStatus } from '../whisper-host'; @@ -30,8 +30,8 @@ const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string; { id: 'deepgram', label: 'Deepgram' }, { id: 'revai', label: 'Rev.ai' }, { id: 'mistral-voxtral', label: 'Mistral Voxtral' }, - { id: 'webspeech', label: 'Web Speech (browser)' }, { id: 'whisper-local', label: 'Local whisper.cpp (desktop only)', desktopOnly: true }, + { id: 'none', label: 'None (text-only; recording disabled)' }, ]; const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ @@ -40,6 +40,7 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ { id: 'openai-compatible', label: 'OpenAI-compatible (local server)' }, { id: 'gemini', label: 'Google Gemini' }, { id: 'mistral', label: 'Mistral' }, + { id: 'none', label: 'None (skip cleanup; insert raw text)' }, ]; const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: string }> = [ @@ -48,6 +49,12 @@ const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: st ]; export class ReWriteSettingTab extends PluginSettingTab { + // Tracks whether the inactive-on-this-device profile is expanded. Survives + // the full-container redraws that fire when dropdowns toggle conditional + // fields (provider, insertMode, activeProfileOverride). + private inactiveProfileExpanded = false; + private keyboardScrollFixInstalled = false; + constructor(app: App, private readonly plugin: ReWritePlugin) { super(app, plugin); } @@ -57,6 +64,12 @@ export class ReWriteSettingTab extends PluginSettingTab { containerEl.empty(); containerEl.addClass('rewrite-settings'); + // containerEl is reused across display() calls, so install once. + if (!this.keyboardScrollFixInstalled) { + installMobileKeyboardScrollFix(containerEl); + this.keyboardScrollFixInstalled = true; + } + this.renderEncryption(containerEl); this.renderActiveProfile(containerEl); this.renderProfile(containerEl, 'desktop'); @@ -245,9 +258,33 @@ export class ReWriteSettingTab extends PluginSettingTab { ? this.plugin.settings.desktopProfile : this.plugin.settings.mobileProfile; const title = kind === 'desktop' ? 'Desktop profile' : 'Mobile profile'; - new Setting(parent).setName(title).setHeading(); + const isActive = detectActiveProfileKind(this.plugin.settings) === kind; - new Setting(parent) + const section = parent.createDiv({ cls: 'rewrite-profile-section' }); + if (isActive) section.addClass('is-active-profile'); + + const heading = new Setting(section).setName(title).setHeading(); + if (isActive) { + heading.nameEl.createSpan({ + cls: 'rewrite-profile-active-badge', + text: 'Active on this device', + }); + } + + let body: HTMLElement; + if (isActive) { + body = section; + } else { + const details = section.createEl('details', { cls: 'rewrite-profile-collapsed' }); + details.open = this.inactiveProfileExpanded; + details.addEventListener('toggle', () => { + this.inactiveProfileExpanded = details.open; + }); + details.createEl('summary', { text: 'Show settings' }); + body = details; + } + + new Setting(body) .setName('Profile label') .setDesc('Display name for this profile.') .addText((t) => { @@ -258,7 +295,7 @@ export class ReWriteSettingTab extends PluginSettingTab { }); }); - new Setting(parent) + new Setting(body) .setName('Transcription provider') .addDropdown((dd) => { for (const opt of TRANSCRIPTION_OPTIONS) { @@ -273,11 +310,11 @@ export class ReWriteSettingTab extends PluginSettingTab { }); }); - if (profile.transcriptionProvider !== 'webspeech') { - this.renderTranscriptionModelField(parent, profile); + if (profile.transcriptionProvider !== 'none') { + this.renderTranscriptionModelField(body, profile); if (profile.transcriptionProvider === 'openai-compatible') { - new Setting(parent) + new Setting(body) .setName('Transcription base URL') .setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)') .addText((t) => { @@ -290,7 +327,7 @@ export class ReWriteSettingTab extends PluginSettingTab { } if (profile.transcriptionProvider !== 'whisper-local') { - new Setting(parent) + new Setting(body) .setName('Transcription API key') .addText((t) => { t.inputEl.type = 'password'; @@ -306,7 +343,7 @@ export class ReWriteSettingTab extends PluginSettingTab { } } - new Setting(parent) + new Setting(body) .setName('LLM provider') .addDropdown((dd) => { for (const opt of LLM_OPTIONS) dd.addOption(opt.id, opt.label); @@ -318,43 +355,47 @@ export class ReWriteSettingTab extends PluginSettingTab { }); }); - this.renderLLMModelField(parent, profile); + if (profile.llmProvider !== 'none') { + this.renderLLMModelField(body, profile); - if (profile.llmProvider === 'openai-compatible') { - new Setting(parent) - .setName('LLM base URL') - .setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)') + if (profile.llmProvider === 'openai-compatible') { + new Setting(body) + .setName('LLM base URL') + .setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)') + .addText((t) => { + t.setValue(profile.llmConfig.baseUrl); + t.onChange(async (v) => { + profile.llmConfig.baseUrl = v; + await this.commit(); + }); + }); + } + + new Setting(body) + .setName('LLM API key') .addText((t) => { - t.setValue(profile.llmConfig.baseUrl); + t.inputEl.type = 'password'; + this.applyApiKeyFieldState(t.inputEl); + t.setPlaceholder(this.apiKeyPlaceholder()); + t.setValue(profile.llmConfig.apiKey); t.onChange(async (v) => { - profile.llmConfig.baseUrl = v; + if (this.plugin.encryptionStatus.locked) return; + profile.llmConfig.apiKey = v; await this.commit(); }); }); } - new Setting(parent) - .setName('LLM API key') - .addText((t) => { - t.inputEl.type = 'password'; - this.applyApiKeyFieldState(t.inputEl); - t.setPlaceholder(this.apiKeyPlaceholder()); - t.setValue(profile.llmConfig.apiKey); - t.onChange(async (v) => { - if (this.plugin.encryptionStatus.locked) return; - profile.llmConfig.apiKey = v; - await this.commit(); - }); - }); - - this.renderProfileAdvanced(parent, profile); + this.renderProfileAdvanced(body, profile); } private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void { + if (profile.transcriptionProvider === 'none' && profile.llmProvider === 'none') return; + const details = parent.createEl('details', { cls: 'rewrite-advanced' }); details.createEl('summary', { text: 'Advanced' }); - if (profile.transcriptionProvider !== 'webspeech') { + if (profile.transcriptionProvider !== 'none') { new Setting(details) .setName('Transcription language') .setDesc('Optional language hint. Leave blank to auto-detect.') @@ -367,18 +408,20 @@ export class ReWriteSettingTab extends PluginSettingTab { }); } - new Setting(details) - .setName('LLM max tokens') - .setDesc('Maximum tokens for the cleanup response. Default 2048.') - .addText((t) => { - t.inputEl.type = 'number'; - t.setValue(String(profile.llmConfig.maxTokens)); - t.onChange(async (v) => { - const n = Number.parseInt(v, 10); - profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048; - await this.commit(); + if (profile.llmProvider !== 'none') { + new Setting(details) + .setName('LLM max tokens') + .setDesc('Maximum tokens for the cleanup response. Default 2048.') + .addText((t) => { + t.inputEl.type = 'number'; + t.setValue(String(profile.llmConfig.maxTokens)); + t.onChange(async (v) => { + const n = Number.parseInt(v, 10); + profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048; + await this.commit(); + }); }); - }); + } } private renderTranscriptionModelField(parent: HTMLElement, profile: EnvironmentProfile): void { @@ -690,7 +733,7 @@ export class ReWriteSettingTab extends PluginSettingTab { new Setting(parent).setName('Recording').setHeading(); new Setting(parent) .setName('Audio format preference') - .setDesc('Web Speech ignores this. Use mp4 on iOS, otherwise webm.') + .setDesc('Use mp4 on iOS, otherwise webm.') .addDropdown((dd) => { for (const opt of RECORDING_FORMAT_OPTIONS) dd.addOption(opt.id, opt.label); dd.setValue(this.plugin.settings.recordingFormat); @@ -862,6 +905,8 @@ function modelFieldDesc(hint: string, supportsList: boolean, cachedCount: number function transcriptionModelHint(id: TranscriptionProviderID): string { switch (id) { + case 'none': + return ''; case 'openai': return 'e.g. whisper-1'; case 'groq': @@ -878,13 +923,13 @@ function transcriptionModelHint(id: TranscriptionProviderID): string { return 'Whichever model your local server exposes'; case 'whisper-local': return 'Any value works; the loaded model is set at server start.'; - case 'webspeech': - return ''; } } function llmModelHint(id: LLMProviderID): string { switch (id) { + case 'none': + return ''; case 'anthropic': return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001'; case 'openai': diff --git a/src/transcription/index.ts b/src/transcription/index.ts index 897573e..54b19f3 100644 --- a/src/transcription/index.ts +++ b/src/transcription/index.ts @@ -4,7 +4,6 @@ import { createAssemblyAITranscription } from './assemblyai'; import { createDeepgramTranscription } from './deepgram'; import { createRevAITranscription } from './revai'; import { createMistralVoxtralTranscription } from './mistral-voxtral'; -import { createWebSpeechTranscription } from './webspeech'; import { createWhisperLocalTranscription } from './whisper-local'; export interface TranscriptionProvider { @@ -22,6 +21,14 @@ export function createTranscriptionProvider( id: TranscriptionProviderID, ): TranscriptionProvider { switch (id) { + case 'none': + return { + id: 'none', + requiresAudio: false, + transcribe: async () => { + throw new Error('Transcription is disabled (provider set to None). Use the Paste or From note tab instead.'); + }, + }; case 'openai': case 'openai-compatible': case 'groq': @@ -34,8 +41,6 @@ export function createTranscriptionProvider( return createRevAITranscription(); case 'mistral-voxtral': return createMistralVoxtralTranscription(); - case 'webspeech': - return createWebSpeechTranscription(); case 'whisper-local': return createWhisperLocalTranscription(); } diff --git a/src/transcription/limits.ts b/src/transcription/limits.ts index 46db2bb..48f31bc 100644 --- a/src/transcription/limits.ts +++ b/src/transcription/limits.ts @@ -7,7 +7,7 @@ import { TranscriptionProviderID } from '../types'; // - Deepgram: 2 GB sync (developers.deepgram.com) // - Rev.ai: 2 GB multipart / 17 h (docs.rev.ai/api/asynchronous) // - Mistral Voxtral: 1 GB / 30 min (docs.mistral.ai/api/endpoint/audio/transcriptions) -// - openai-compatible / whisper-local / webspeech: no client-side cap +// - openai-compatible / whisper-local: no client-side cap export interface TranscriptionLimits { readonly maxBytes?: number; readonly maxDurationMs?: number; @@ -20,6 +20,8 @@ const HOUR = 60 * MIN; export function getTranscriptionLimits(id: TranscriptionProviderID): TranscriptionLimits { switch (id) { + case 'none': + return {}; case 'openai': return { maxBytes: 25 * MB }; case 'groq': @@ -34,13 +36,13 @@ export function getTranscriptionLimits(id: TranscriptionProviderID): Transcripti return { maxBytes: 1 * GB, maxDurationMs: 30 * MIN }; case 'openai-compatible': case 'whisper-local': - case 'webspeech': return {}; } } export function transcriptionProviderLabel(id: TranscriptionProviderID): string { switch (id) { + case 'none': return 'None'; case 'openai': return 'OpenAI Whisper'; case 'groq': return 'Groq'; case 'assemblyai': return 'AssemblyAI'; @@ -49,7 +51,6 @@ export function transcriptionProviderLabel(id: TranscriptionProviderID): string case 'mistral-voxtral': return 'Mistral Voxtral'; case 'openai-compatible': return 'OpenAI-compatible'; case 'whisper-local': return 'Local whisper.cpp'; - case 'webspeech': return 'Web Speech'; } } diff --git a/src/transcription/mistral-voxtral.ts b/src/transcription/mistral-voxtral.ts index 72c05cd..a37ffa5 100644 --- a/src/transcription/mistral-voxtral.ts +++ b/src/transcription/mistral-voxtral.ts @@ -1,23 +1,28 @@ import { TranscriptionConfig } from '../types'; -import { MultipartPart, multipartPost, ProviderError } from '../http'; +import { jsonGet, MultipartPart, multipartPost, ProviderError } from '../http'; import { transcodeToWavPcm } from '../audio-transcode'; import { TranscriptionProvider } from './index'; -// Mistral Voxtral diverges from the OpenAI Whisper shape on three points, so it +// Mistral Voxtral diverges from the OpenAI Whisper shape on two points, so it // gets its own adapter rather than dispatching through openai.ts: // 1. Response is JSON only ({ text, segments, ... }); no response_format=text. // 2. WebM/Opus is not an accepted input format, so the recorded blob is always // transcoded to 16 kHz mono WAV before upload (same path as whisper-local). // 30 min of 16 kHz mono 16-bit PCM is ~57 MB, well under the 1 GB cap. -// 3. /v1/models does not document audio-model surfacing, so listModels is omitted. +// listModels fetches the Mistral catalog and filters by ID substring `voxtral`. const VOXTRAL_ENDPOINT = 'https://api.mistral.ai/v1/audio/transcriptions'; +const MISTRAL_MODELS_ENDPOINT = 'https://api.mistral.ai/v1/models'; interface VoxtralResponse { text?: unknown; } +interface MistralModelsResponse { + data?: Array<{ id?: unknown }>; +} + export function createMistralVoxtralTranscription(): TranscriptionProvider { - return { + const provider: TranscriptionProvider = { id: 'mistral-voxtral', requiresAudio: true, async transcribe( @@ -62,4 +67,38 @@ export function createMistralVoxtralTranscription(): TranscriptionProvider { return text.trim(); }, }; + + provider.listModels = async (config, signal) => { + if (!config.apiKey) throw new Error('mistral-voxtral: API key is not configured'); + const response = await jsonGet( + 'mistral-voxtral', + MISTRAL_MODELS_ENDPOINT, + { Authorization: `Bearer ${config.apiKey}` }, + signal, + ); + return filterVoxtralModels(response.data ?? []); + }; + + return provider; +} + +function filterVoxtralModels(rows: Array<{ id?: unknown }>): string[] { + // Match Voxtral by name first, but also fall back to capability hints so we + // pick up audio-capable models if Mistral ever renames the family or adds + // new variants. Exclude obvious non-audio (chat/embed/moderation/etc). + const out: string[] = []; + for (const row of rows) { + const id = typeof row.id === 'string' ? row.id : ''; + if (!id) continue; + const lower = id.toLowerCase(); + if ( + lower.includes('voxtral') + || lower.includes('audio') + || lower.includes('transcribe') + ) { + out.push(id); + } + } + out.sort(); + return out; } diff --git a/src/transcription/webspeech.ts b/src/transcription/webspeech.ts deleted file mode 100644 index ee11bdd..0000000 --- a/src/transcription/webspeech.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TranscriptionConfig } from '../types'; -import { TranscriptionProvider } from './index'; - -// Web Speech does not produce an audio blob. The recorder (src/webspeech.ts) drives a -// SpeechRecognition session and emits a finalized transcript directly. The pipeline must -// short-circuit when source === 'webspeech' so this transcribe() is never called. -// It exists only so the factory has a complete switch case. -export function createWebSpeechTranscription(): TranscriptionProvider { - return { - id: 'webspeech', - requiresAudio: false, - async transcribe(_audio: Blob, _config: TranscriptionConfig): Promise { - throw new Error( - 'webspeech: transcribe() should not be called; the pipeline must use the live SpeechRecognition transcript', - ); - }, - }; -} diff --git a/src/types.ts b/src/types.ts index 2ed41dc..bb97e08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ export type TranscriptionProviderID = + | 'none' | 'openai' | 'openai-compatible' | 'groq' @@ -6,10 +7,10 @@ export type TranscriptionProviderID = | 'deepgram' | 'revai' | 'mistral-voxtral' - | 'webspeech' | 'whisper-local'; export type LLMProviderID = + | 'none' | 'anthropic' | 'openai' | 'openai-compatible' diff --git a/src/ui/audio-source.ts b/src/ui/audio-source.ts index 297f5ed..62be0fe 100644 --- a/src/ui/audio-source.ts +++ b/src/ui/audio-source.ts @@ -35,6 +35,10 @@ export async function runAudioFilePipeline( plugin.promptUnlock(); return; } + if (profile.transcriptionProvider === 'none') { + new Notice('ReWrite: transcription is disabled for this profile. Pick a transcription provider in settings to reprocess audio.'); + return; + } if (!isProfileConfigured(profile)) { new Notice('ReWrite: configure a transcription and LLM provider before reprocessing audio.'); new ReWriteModal(plugin.app, plugin).open(); diff --git a/src/ui/modal.ts b/src/ui/modal.ts index 0518d8e..0c4df43 100644 --- a/src/ui/modal.ts +++ b/src/ui/modal.ts @@ -1,9 +1,8 @@ import { App, Modal, Notice } from 'obsidian'; import type ReWritePlugin from '../main'; import { runPipeline, PipelineSource, PipelineStage } from '../pipeline'; -import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform'; +import { installMobileKeyboardScrollFix, isMediaRecorderAvailable, resolveActiveProfile } from '../platform'; import { Recorder } from '../recorder'; -import { startWebSpeech, WebSpeechSession } from '../webspeech'; import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types'; import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card'; import { resolveActiveTextSource } from './text-source'; @@ -12,7 +11,6 @@ export class ReWriteModal extends Modal { private templateId: string; private activeTab: 'record' | 'paste' | 'fromNote' = 'record'; private recorder: Recorder | null = null; - private webSpeech: WebSpeechSession | null = null; private timerHandle: number | null = null; private running = false; private currentSource: PipelineSource | null = null; @@ -30,6 +28,7 @@ export class ReWriteModal extends Modal { onOpen(): void { this.modalEl.addClass('rewrite-modal'); + installMobileKeyboardScrollFix(this.contentEl); this.render(); } @@ -95,7 +94,13 @@ export class ReWriteModal extends Modal { } if (this.activeTab === 'record') { - this.renderRecordTab(tabBody, profile.transcriptionProvider === 'webspeech', profile.transcriptionConfig.language); + if (profile.transcriptionProvider === 'none') { + tabBody.createEl('p', { + text: 'Recording is disabled because this profile has no transcription provider configured. Switch to one of the other tabs above, or pick a transcription provider in settings.', + }); + return; + } + this.renderRecordTab(tabBody); } else { this.renderPasteTab(tabBody); } @@ -271,14 +276,8 @@ export class ReWriteModal extends Modal { }); } - private renderRecordTab(parent: HTMLElement, isWebSpeech: boolean, language: string): void { - if (isWebSpeech && !isWebSpeechAvailable()) { - parent.createEl('p', { - text: 'Web Speech is not available here. Use the paste tab or pick a different transcription provider in settings.', - }); - return; - } - if (!isWebSpeech && !isMediaRecorderAvailable()) { + private renderRecordTab(parent: HTMLElement): void { + if (!isMediaRecorderAvailable()) { parent.createEl('p', { text: 'Audio recording is not supported in this environment. Use the paste tab instead.', }); @@ -293,16 +292,13 @@ export class ReWriteModal extends Modal { const dot = indicator.createSpan({ cls: 'rewrite-pulse-dot' }); dot.hide(); const timer = indicator.createSpan({ cls: 'rewrite-timer', text: '0:00' }); - const liveTranscript = parent.createDiv({ cls: 'rewrite-live-transcript' }); let isRecording = false; const handleClick = async (): Promise => { if (this.running) return; if (!isRecording) { try { - await this.beginCapture(isWebSpeech, language, (text) => { - liveTranscript.setText(text); - }); + await this.beginCapture(); } catch (e) { new Notice(e instanceof Error ? e.message : String(e)); return; @@ -310,11 +306,11 @@ export class ReWriteModal extends Modal { isRecording = true; button.setText('Stop'); dot.show(); - this.startTimerLoop(timer, isWebSpeech); + this.startTimerLoop(timer); } else { button.disabled = true; try { - const source = await this.endCapture(isWebSpeech); + const source = await this.endCapture(); isRecording = false; button.setText('Record'); dot.hide(); @@ -379,38 +375,21 @@ export class ReWriteModal extends Modal { }); } - private async beginCapture( - isWebSpeech: boolean, - language: string, - onLiveText: (text: string) => void, - ): Promise { - if (isWebSpeech) { - this.webSpeech = startWebSpeech({ - language: language || undefined, - onUpdate: onLiveText, - }); - } else { - this.recorder = new Recorder(); - await this.recorder.start(this.plugin.settings.recordingFormat); - } + private async beginCapture(): Promise { + this.recorder = new Recorder(); + await this.recorder.start(this.plugin.settings.recordingFormat); } - private async endCapture(isWebSpeech: boolean): Promise { - if (isWebSpeech) { - const transcript = (await this.webSpeech?.stop()) ?? ''; - this.webSpeech = null; - return { kind: 'webspeech', transcript }; - } + private async endCapture(): Promise { if (!this.recorder) throw new Error('No active recording.'); const result = await this.recorder.stop(); this.recorder = null; return { kind: 'audio', audio: result.blob, durationMs: result.durationMs }; } - private startTimerLoop(timerEl: HTMLElement, isWebSpeech: boolean): void { - const startedAt = Date.now(); + private startTimerLoop(timerEl: HTMLElement): void { this.timerHandle = window.setInterval(() => { - const ms = isWebSpeech ? Date.now() - startedAt : this.recorder?.getElapsedMs() ?? 0; + const ms = this.recorder?.getElapsedMs() ?? 0; timerEl.setText(formatDuration(ms)); }, 250); } @@ -426,8 +405,6 @@ export class ReWriteModal extends Modal { this.stopTimerLoop(); this.recorder?.cancel(); this.recorder = null; - this.webSpeech?.cancel(); - this.webSpeech = null; } private async execute(source: PipelineSource): Promise { diff --git a/src/ui/passphrase-modal.ts b/src/ui/passphrase-modal.ts index 14557a6..7839baa 100644 --- a/src/ui/passphrase-modal.ts +++ b/src/ui/passphrase-modal.ts @@ -1,4 +1,5 @@ import { App, Modal, Notice, Setting } from 'obsidian'; +import { installMobileKeyboardScrollFix } from '../platform'; export interface PassphrasePromptParams { app: App; @@ -25,6 +26,7 @@ export class PassphraseModal extends Modal { this.modalEl.addClass('rewrite-modal'); this.modalEl.addClass('rewrite-passphrase-modal'); const { contentEl } = this; + installMobileKeyboardScrollFix(contentEl); contentEl.createEl('h2', { text: this.params.title }); if (this.params.description) { diff --git a/src/ui/quick-record.ts b/src/ui/quick-record.ts index bb99150..36c2da4 100644 --- a/src/ui/quick-record.ts +++ b/src/ui/quick-record.ts @@ -1,42 +1,30 @@ import { Notice } from 'obsidian'; import type ReWritePlugin from '../main'; import { PipelineSource, PipelineStage, runPipeline } from '../pipeline'; -import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform'; +import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform'; import { Recorder } from '../recorder'; -import { startWebSpeech, WebSpeechSession } from '../webspeech'; import { NoteTemplate } from '../types'; import { isProfileConfigured } from './setup-card'; import { ReWriteModal } from './modal'; export class QuickRecordController { private recorder: Recorder | null = null; - private webSpeech: WebSpeechSession | null = null; private timerHandle: number | null = null; private floater: QuickRecordFloater | null = null; private settled = false; - private startedAt = 0; private template: NoteTemplate; constructor( private readonly plugin: ReWritePlugin, template: NoteTemplate, - private readonly isWebSpeech: boolean, private readonly onDispose: () => void, ) { this.template = template; } async begin(): Promise { - const { profile } = resolveActiveProfile(this.plugin.settings); - if (this.isWebSpeech) { - this.webSpeech = startWebSpeech({ - language: profile.transcriptionConfig.language || undefined, - }); - } else { - this.recorder = new Recorder(); - await this.recorder.start(this.plugin.settings.recordingFormat); - } - this.startedAt = Date.now(); + this.recorder = new Recorder(); + await this.recorder.start(this.plugin.settings.recordingFormat); this.floater = new QuickRecordFloater({ onStop: () => { void this.finish(); @@ -51,9 +39,7 @@ export class QuickRecordController { initialTemplateName: this.template.name, }); this.timerHandle = window.setInterval(() => { - const ms = this.isWebSpeech - ? Date.now() - this.startedAt - : this.recorder?.getElapsedMs() ?? 0; + const ms = this.recorder?.getElapsedMs() ?? 0; this.floater?.setTime(formatDuration(ms)); }, 250); } @@ -64,8 +50,6 @@ export class QuickRecordController { this.stopTimer(); this.recorder?.cancel(); this.recorder = null; - this.webSpeech?.cancel(); - this.webSpeech = null; this.floater?.dispose(); this.floater = null; this.onDispose(); @@ -105,11 +89,6 @@ export class QuickRecordController { } private async endCapture(): Promise { - if (this.isWebSpeech) { - const transcript = (await this.webSpeech?.stop()) ?? ''; - this.webSpeech = null; - return { kind: 'webspeech', transcript }; - } if (!this.recorder) throw new Error('No active recording.'); const result = await this.recorder.stop(); this.recorder = null; @@ -137,6 +116,12 @@ export async function startQuickRecord( return null; } + if (profile.transcriptionProvider === 'none') { + new Notice('ReWrite: transcription is disabled for this profile. Opening the modal so you can paste text instead.'); + new ReWriteModal(plugin.app, plugin).open(); + return null; + } + if (!isProfileConfigured(profile)) { new Notice('ReWrite: profile is not configured. Finish setup to use quick record.'); new ReWriteModal(plugin.app, plugin).open(); @@ -150,19 +135,13 @@ export async function startQuickRecord( return null; } - const isWebSpeech = profile.transcriptionProvider === 'webspeech'; - if (isWebSpeech && !isWebSpeechAvailable()) { - new Notice('Web Speech is not available here. Opening the modal instead.'); - new ReWriteModal(plugin.app, plugin).open(); - return null; - } - if (!isWebSpeech && !isMediaRecorderAvailable()) { + if (!isMediaRecorderAvailable()) { new Notice('Audio recording is not supported in this environment. Opening the modal instead.'); new ReWriteModal(plugin.app, plugin).open(); return null; } - const controller = new QuickRecordController(plugin, template, isWebSpeech, onDispose); + const controller = new QuickRecordController(plugin, template, onDispose); try { await controller.begin(); } catch (e) { diff --git a/src/ui/setup-card.ts b/src/ui/setup-card.ts index 46240a5..d6e9428 100644 --- a/src/ui/setup-card.ts +++ b/src/ui/setup-card.ts @@ -9,8 +9,8 @@ const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string; { id: 'deepgram', label: 'Deepgram' }, { id: 'revai', label: 'Rev.ai' }, { id: 'mistral-voxtral', label: 'Mistral Voxtral' }, - { id: 'webspeech', label: 'Web Speech (browser)' }, { id: 'whisper-local', label: 'Local whisper.cpp (desktop only)', desktopOnly: true }, + { id: 'none', label: 'None (text-only; recording disabled)' }, ]; const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ @@ -19,11 +19,12 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ { id: 'openai-compatible', label: 'OpenAI-compatible (local server)' }, { id: 'gemini', label: 'Google Gemini' }, { id: 'mistral', label: 'Mistral' }, + { id: 'none', label: 'None (skip cleanup; insert raw text)' }, ]; export function isProfileConfigured(profile: EnvironmentProfile): boolean { const tx = profile.transcriptionProvider; - if (tx !== 'webspeech') { + if (tx !== 'none') { if (!profile.transcriptionConfig.model) return false; if (tx !== 'whisper-local' && !profile.transcriptionConfig.apiKey) return false; if (tx === 'openai-compatible' && !profile.transcriptionConfig.baseUrl.trim()) return false; @@ -32,6 +33,7 @@ export function isProfileConfigured(profile: EnvironmentProfile): boolean { } export function isProfileConfiguredForText(profile: EnvironmentProfile): boolean { + if (profile.llmProvider === 'none') return true; if (!profile.llmConfig.model) return false; if (!profile.llmConfig.apiKey) return false; if (profile.llmProvider === 'openai-compatible' && !profile.llmConfig.baseUrl.trim()) return false; @@ -75,7 +77,7 @@ export function renderSetupCard(params: SetupCardParams): void { }); }); - if (profile.transcriptionProvider !== 'webspeech') { + if (profile.transcriptionProvider !== 'none') { new Setting(card) .setName('Transcription model') .setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider)) @@ -122,34 +124,36 @@ export function renderSetupCard(params: SetupCardParams): void { }); }); - new Setting(card) - .setName('LLM model') - .setDesc(modelPlaceholderForLLM(profile.llmProvider)) - .addText((t) => { - t.setValue(profile.llmConfig.model); - t.onChange((v) => { - profile.llmConfig.model = v; - }); - }); - - if (profile.llmProvider === 'openai-compatible') { + if (profile.llmProvider !== 'none') { new Setting(card) - .setName('LLM base URL') - .setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)') + .setName('LLM model') + .setDesc(modelPlaceholderForLLM(profile.llmProvider)) .addText((t) => { - t.setValue(profile.llmConfig.baseUrl); + t.setValue(profile.llmConfig.model); t.onChange((v) => { - profile.llmConfig.baseUrl = v; + profile.llmConfig.model = v; }); }); - } - renderApiKeyField(card, { - label: 'LLM API key', - placeholder: 'Saved securely on this device', - getValue: () => profile.llmConfig.apiKey, - setValue: (v) => { profile.llmConfig.apiKey = v; }, - }); + if (profile.llmProvider === 'openai-compatible') { + new Setting(card) + .setName('LLM base URL') + .setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)') + .addText((t) => { + t.setValue(profile.llmConfig.baseUrl); + t.onChange((v) => { + profile.llmConfig.baseUrl = v; + }); + }); + } + + renderApiKeyField(card, { + label: 'LLM API key', + placeholder: 'Saved securely on this device', + getValue: () => profile.llmConfig.apiKey, + setValue: (v) => { profile.llmConfig.apiKey = v; }, + }); + } const actions = card.createDiv({ cls: 'rewrite-setup-actions' }); const saveBtn = actions.createEl('button', { text: 'Save and continue', cls: 'mod-cta' }); @@ -180,6 +184,8 @@ function renderApiKeyField(container: HTMLElement, field: KeyField): void { function modelPlaceholderForTranscription(id: TranscriptionProviderID): string { switch (id) { + case 'none': + return ''; case 'openai': return 'e.g. whisper-1'; case 'groq': @@ -196,13 +202,13 @@ function modelPlaceholderForTranscription(id: TranscriptionProviderID): string { return 'Whichever model your local server exposes'; case 'whisper-local': return 'Any value works; the loaded model is set at server start'; - case 'webspeech': - return ''; } } function modelPlaceholderForLLM(id: LLMProviderID): string { switch (id) { + case 'none': + return ''; case 'anthropic': return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001'; case 'openai': diff --git a/src/webspeech.ts b/src/webspeech.ts deleted file mode 100644 index 57c53f3..0000000 --- a/src/webspeech.ts +++ /dev/null @@ -1,141 +0,0 @@ -interface SpeechRecognitionAlternativeLike { - transcript: string; -} - -interface SpeechRecognitionResultLike { - 0: SpeechRecognitionAlternativeLike; - length: number; - isFinal: boolean; -} - -interface SpeechRecognitionResultListLike { - length: number; - [index: number]: SpeechRecognitionResultLike; -} - -interface SpeechRecognitionEventLike { - results: SpeechRecognitionResultListLike; - resultIndex: number; -} - -interface SpeechRecognitionErrorEventLike { - error?: string; -} - -interface SpeechRecognitionLike { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - abort(): void; - addEventListener(type: 'result', listener: (e: SpeechRecognitionEventLike) => void): void; - addEventListener(type: 'error', listener: (e: SpeechRecognitionErrorEventLike) => void): void; - addEventListener(type: 'end', listener: () => void): void; -} - -type SpeechRecognitionCtor = new () => SpeechRecognitionLike; - -function getConstructor(): SpeechRecognitionCtor | null { - const w = window as unknown as { - SpeechRecognition?: SpeechRecognitionCtor; - webkitSpeechRecognition?: SpeechRecognitionCtor; - }; - return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; -} - -export interface WebSpeechSession { - stop(): Promise; - cancel(): void; -} - -export interface StartWebSpeechOptions { - language?: string; - onUpdate?: (combinedTranscript: string) => void; -} - -export function startWebSpeech(options: StartWebSpeechOptions = {}): WebSpeechSession { - const Ctor = getConstructor(); - if (!Ctor) { - throw new Error('Web Speech API is not available in this environment'); - } - const recognition = new Ctor(); - recognition.continuous = true; - recognition.interimResults = true; - if (options.language) recognition.lang = options.language; - - let finalTranscript = ''; - let interimTranscript = ''; - let resolveStop: ((text: string) => void) | null = null; - let rejectStop: ((err: Error) => void) | null = null; - let terminated = false; - - recognition.addEventListener('result', (event: SpeechRecognitionEventLike) => { - let interim = ''; - for (let i = event.resultIndex; i < event.results.length; i++) { - const result = event.results[i]; - if (!result || result.length === 0) continue; - const transcript = result[0].transcript; - if (result.isFinal) { - finalTranscript += transcript; - } else { - interim += transcript; - } - } - interimTranscript = interim; - options.onUpdate?.((finalTranscript + interim).trim()); - }); - - recognition.addEventListener('error', (event: SpeechRecognitionErrorEventLike) => { - if (terminated) return; - terminated = true; - const err = new Error(`Web Speech error: ${event.error ?? 'unknown'}`); - if (rejectStop) { - rejectStop(err); - rejectStop = null; - resolveStop = null; - } - }); - - recognition.addEventListener('end', () => { - const text = (finalTranscript + interimTranscript).trim(); - if (resolveStop) { - resolveStop(text); - resolveStop = null; - rejectStop = null; - } - }); - - recognition.start(); - - return { - stop(): Promise { - return new Promise((resolve, reject) => { - if (terminated) { - resolve((finalTranscript + interimTranscript).trim()); - return; - } - resolveStop = resolve; - rejectStop = reject; - try { - recognition.stop(); - } catch { - terminated = true; - resolveStop = null; - rejectStop = null; - resolve((finalTranscript + interimTranscript).trim()); - } - }); - }, - cancel(): void { - terminated = true; - resolveStop = null; - rejectStop = null; - try { - recognition.abort(); - } catch { - // ignore - } - }, - }; -} diff --git a/styles.css b/styles.css index d71eaf3..1e24612 100644 --- a/styles.css +++ b/styles.css @@ -145,6 +145,36 @@ margin-bottom: 8px; } +.rewrite-settings .rewrite-profile-section { + margin-bottom: 16px; +} + +.rewrite-settings .rewrite-profile-section.is-active-profile { + border-left: 3px solid var(--interactive-accent); + padding-left: 12px; + margin-left: -15px; +} + +.rewrite-settings .rewrite-profile-active-badge { + display: inline-block; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-radius: 4px; + font-size: var(--font-ui-smaller); + padding: 1px 6px; + margin-left: 8px; + vertical-align: middle; + font-weight: normal; +} + +.rewrite-settings .rewrite-profile-collapsed > summary { + cursor: pointer; + user-select: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + padding: 4px 0; +} + /* Quick record floating mini-UI */ .rewrite-quick-floater {