mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
Mobile textfield troubleshooting and remove Web Speech
Web Speech had all kinds of bugs, not worth troubleshooting.
This commit is contained in:
parent
c207d5444d
commit
dddfb0cf12
24 changed files with 686 additions and 356 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
CLAUDE.md
16
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.<ext>` 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 `![[<path>]]\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 `![[<path>]]\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 `![[<path>]]\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 `<h2>`** 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 `<details class="rewrite-profile-collapsed">` 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 `<details>`), 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 `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 `<details class="rewrite-profile-collapsed">` 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.
|
||||
|
|
|
|||
244
docs/MOBILE_KEYBOARD_BUG.md
Normal file
244
docs/MOBILE_KEYBOARD_BUG.md
Normal file
|
|
@ -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: <computed> 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 `<div>`) 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").
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.` });
|
||||
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
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<string> {
|
|||
}
|
||||
|
||||
async function cleanupTranscript(params: PipelineParams, transcript: string): Promise<string> {
|
||||
// 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) {
|
||||
|
|
|
|||
149
src/platform.ts
149
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MistralModelsResponse>(
|
||||
'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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
throw new Error(
|
||||
'webspeech: transcribe() should not be called; the pipeline must use the live SpeechRecognition transcript',
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.recorder = new Recorder();
|
||||
await this.recorder.start(this.plugin.settings.recordingFormat);
|
||||
}
|
||||
|
||||
private async endCapture(isWebSpeech: boolean): Promise<PipelineSource> {
|
||||
if (isWebSpeech) {
|
||||
const transcript = (await this.webSpeech?.stop()) ?? '';
|
||||
this.webSpeech = null;
|
||||
return { kind: 'webspeech', transcript };
|
||||
}
|
||||
private async endCapture(): Promise<PipelineSource> {
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<PipelineSource> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
141
src/webspeech.ts
141
src/webspeech.ts
|
|
@ -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<string>;
|
||||
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<string> {
|
||||
return new Promise<string>((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
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
30
styles.css
30
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue