From 8fe298e5a1c013a9b8ea6a63edc7b25228522ae0 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Sun, 24 May 2026 21:26:38 -0700 Subject: [PATCH] f3 --- CLAUDE.md | 10 ++-- docs/FEATURES.md | 124 +++++++++++++++++++++++++++++++++++++++++ src/pipeline.ts | 13 +---- src/settings/index.ts | 81 --------------------------- src/settings/tab.ts | 101 ++++++++++++--------------------- src/types.ts | 11 ---- src/ui/modal.ts | 3 +- src/ui/quick-record.ts | 2 +- src/ui/setup-card.ts | 53 +++--------------- 9 files changed, 178 insertions(+), 220 deletions(-) create mode 100644 docs/FEATURES.md diff --git a/CLAUDE.md b/CLAUDE.md index ad73b6d..562a534 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ The v1 implementation is feature-complete against [obsidian-voice-notes-spec.md] When extending the plugin, follow the file layout the spec prescribes: provider adapters under `src/transcription/` and `src/llm/`, factories in each `index.ts`, no provider-specific logic leaking outside its own file. +**Pre-release status: no migrations, no backcompat shims.** There are no real users yet. When changing settings shape, `data.json` keys, `secrets.json.nosync` schema, template structure, or any other persisted format: change it cleanly. Do not write migration code, do not add compatibility read paths, do not preserve deprecated fields "just in case." Existing dev installs can be reset by deleting `data.json` / `secrets.json.nosync`. Drop this rule the moment v1.0.0 ships to the community plugin directory. + ## Documentation maintenance Update CLAUDE.md with every behavioral change. When modifying code that this document describes (pipeline stages, command IDs, settings keys, gotchas, conventions), update CLAUDE.md in the same change. If a behavioral change has no existing section, add one or drop a note under "Gotchas". Treat the doc update as part of the task, not a follow-up. @@ -50,8 +52,8 @@ src/ ├── pipeline.ts # transcribe → cleanup → insert orchestrator ├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion ├── settings/ -│ ├── index.ts # DEFAULT_SETTINGS, load/save, secret hydration, family resolvers -│ ├── tab.ts # PluginSettingTab with all 6 sections +│ ├── index.ts # DEFAULT_SETTINGS, load/save, per-profile secret hydration +│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates, recording │ └── default-templates.ts # The 5 seeded templates (General cleanup, Todo list, etc.) ├── ui/ │ ├── modal.ts # Main modal: template select + Record/Paste tabs + setup-card injection @@ -85,7 +87,7 @@ The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed b [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: 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). -API keys live in [src/secrets.ts](src/secrets.ts), keyed by *provider family* (`openai`, `anthropic`, `groq`, etc.) so that one OpenAI key serves both Whisper and GPT. Per-profile overrides exist on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`; the `openai-compatible` family has *no* global slot because its base URL is profile-specific. Keys are looked up via `resolveTranscriptionApiKey` / `resolveLLMApiKey` in [src/settings/index.ts](src/settings/index.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`. ## Settings @@ -94,7 +96,7 @@ API keys live in [src/secrets.ts](src/secrets.ts), keyed by *provider family* (` 1. `plugin.loadData()` returns `Partial | null`. 2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values. 3. If `merged.templates.length === 0`, [src/settings/default-templates.ts](src/settings/default-templates.ts) seeds the 5 starter templates and sets `defaultTemplateId` if unset. This handles first launch *and* migrations from any pre-Phase-11 install with an empty templates array. -4. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into the in-memory `apiKeys` map + profile slots. +4. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into each profile's `transcriptionConfig.apiKey` / `llmConfig.apiKey`. Saving flow strips secrets out of `data.json` and writes them to `secrets.json.nosync` instead (see [src/secrets.ts](src/secrets.ts)). Never persist API keys to `data.json`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..412a9a7 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,124 @@ +# ReWrite — Features To Add + +Forward-looking backlog. Not committed to a release. Add items as they come up; move to a phase plan when picking one up. + +## Open + +### 1. LLM model field as a dropdown of available models + +**Current state:** [src/settings/tab.ts:197-206](../src/settings/tab.ts#L197-L206) renders "LLM model" as a free-text input with a hint string from `llmModelHint()`. Same shape for "Transcription model" at [src/settings/tab.ts:136-145](../src/settings/tab.ts#L136-L145). + +**Desired:** dropdown populated with models the configured provider actually exposes, instead of asking the user to type a model ID correctly. + +**Open design questions (decide when picking this up):** + +- **Source of the list.** Two options: + 1. *Live fetch* per provider. OpenAI has `GET /v1/models`, Anthropic has `GET /v1/models` (recent), Gemini has `GET /v1beta/models`, Mistral has `GET /v1/models`, Groq mirrors OpenAI, `openai-compatible` mirrors OpenAI. Pros: always current, surfaces user's actual entitlements. Cons: requires the API key to already be set before model selection, adds a network call to the settings tab, needs caching + a refresh button, needs graceful degradation when offline. + 2. *Static curated list* baked into each adapter, refreshed when we ship updates. Pros: works without a key, instant. Cons: stale list problem (this plugin's whole reason for existing is that model names move fast). +- **Recommended:** live fetch with on-disk cache (in `data.json`, per provider family, with timestamp), a "Refresh models" button next to the dropdown, and a "Custom..." escape hatch that falls back to free-text. Cache TTL ~7 days. If the fetch fails (no key, offline, 401), show the cached list if any, otherwise fall back to free-text with the current hint. +- **Same treatment for transcription model?** Yes, for the providers that expose it (OpenAI Whisper has limited models; AssemblyAI has tiers; Deepgram has many; Groq mirrors OpenAI; `openai-compatible` and `revai` should stay free-text since the namespace is user-controlled). +- **Per-profile vs. global cache?** Global per provider family — the model list doesn't depend on which profile you're configuring. + +**Touch points:** [src/settings/tab.ts](../src/settings/tab.ts) (UI), per-provider adapter files under [src/llm/](../src/llm/) and [src/transcription/](../src/transcription/) (add `listModels(config)`), [src/types.ts](../src/types.ts) (cache shape on `GlobalSettings`). + +--- + +### 2. Plugin-managed local whisper.cpp server (desktop) + +**Goal:** let a desktop user run fully on-device transcription with whisper.cpp without ever opening a terminal during normal use. Plugin spawns the server, plugin tears it down, pipeline talks to it via the existing `openai-compatible` adapter under the hood. + +**Scope: desktop only.** Mobile profile is unaffected and continues to use remote or `openai-compatible` providers. Guard everything with `Platform.isDesktop` and lazy-require Node modules inside the guard, the same pattern [src/secrets.ts](../src/secrets.ts) uses for `safeStorage`. + +**Obsidian policy:** clean. Confirmed against [Obsidian's Developer policies](https://docs.obsidian.md/Developer+policies) and [Plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) on 2026-05-24: no explicit prohibition on child processes or launching user-supplied binaries. Standard transparency rules apply (disclose in README, no obfuscation). + +**Binary + model location:** user-supplied absolute paths in settings, not a fixed location under `/.obsidian/plugins/rewrite-plugin/`. Two reasons: (a) people often keep binaries in `~/bin/` or a tools dir and would rather not duplicate, (b) large model files (150 MB-1.5 GB) inside `.obsidian/` would inflate vault sync (Obsidian Sync, Syncthing, iCloud, etc.). Plugin only reads paths, never copies or downloads. + +**Staged delivery:** + +#### Phase A (the "C" option) — on-demand, button-driven + +User experience: + +- New settings section "Local whisper.cpp server (desktop)". Fields: + - Binary path (absolute, file picker + text input) + - Model path (absolute, file picker + text input) + - Port (default 8080, integer) + - Extra args (optional, advanced) +- Start / Stop buttons in settings. Status indicator (stopped / starting / running / crashed). +- When running, transcription provider auto-resolves base URL to `http://127.0.0.1:` if the user selects a new "Local whisper.cpp" transcription provider, *or* the user can still point `openai-compatible` at it manually (no special-casing in pipeline). + +Build: + +- New module `src/whisper-host.ts`: `start()`, `stop()`, `status()`, `onLog(cb)`. Uses lazy-required `child_process.spawn`, `net` (port-in-use probe), `fs` (existence checks). +- Health check after spawn: poll `GET /` or `GET /v1/models` on the port for up to ~5s before declaring ready. +- Orphan handling on startup: if a previous Obsidian crash left a process bound to the configured port, surface that in status rather than silently failing or killing an unrelated process. +- Cross-platform: handle `.exe` suffix on Windows, `chmod +x` is the user's problem (document it). +- Capture stdout/stderr, ring-buffer to ~1 MB, show in a "View log" disclosure inside settings. +- Stop on plugin unload regardless of how the server was started. + +#### Phase B — auto-start lifecycle + +Once Phase A is solid: + +- Add "Start automatically when Obsidian opens" toggle (default off). +- Add "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). +- README updates: setup walkthrough with binary + model download links, troubleshooting (port in use, antivirus blocking, model load failure). + +**Touch points:** [src/main.ts](../src/main.ts) (lifecycle hook for stop-on-unload only), new `src/whisper-host.ts`, [src/settings/tab.ts](../src/settings/tab.ts) (new section), [src/types.ts](../src/types.ts) (settings shape), [src/platform.ts](../src/platform.ts) (capability probe for `Platform.isDesktop` + `FileSystemAdapter`), [README.md](../README.md) (new section + disclosure of process-spawn behavior). + +**Risks / things to watch:** + +- Process supervision has long-tail bugs (zombies, orphans, signal handling differences across platforms). Phase A's button-driven model is forgiving; Phase B's auto-lifecycle is where these bite. +- Antivirus may quarantine `whisper-server.exe` on Windows on first run. Document it; don't try to work around it. +- The plugin must never spawn anything the user didn't explicitly configure. No discovery, no PATH lookup, no "auto-download." Path is supplied → spawn that exact file. + +--- + +### 3. Act on existing text in a note + +**Goal:** apply a ReWrite template to text that's already in a markdown note, no recording involved. Use case: you used a separate STT app (or just typed) to get a wall of text into a note, now you want ReWrite to turn it into a daily note, a todo list, a summary, etc. + +This is the same pipeline minus the transcribe stage. [src/pipeline.ts](../src/pipeline.ts) already short-circuits transcription for `source.kind === 'paste'` and `source.kind === 'webspeech'`; a new `source.kind === 'text'` slots in identically. + +**Entry points (three, mirroring how voice has three):** + +1. **Command palette: `rewrite-plugin:process-text` ("Process text with template").** Picks template, then runs on the editor's current selection if there is one, otherwise the whole note body. Bails with a Notice if no markdown editor is active. +2. **Editor context menu item: "ReWrite with template..."** Registered via `this.registerEvent(this.app.workspace.on('editor-menu', ...))`. Opens a template quick-picker (a small modal listing templates by name). Operates on selection if present, otherwise whole note. +3. **New tab in the existing modal: "From note".** Joins the existing "Record" and "Paste" tabs in [src/ui/modal.ts](../src/ui/modal.ts). Shows a preview of what will be processed ("Selection: 247 chars" or "Whole note: 1,832 chars"), template dropdown, Run button. Reachable via the ribbon icon and the existing `open-modal` command, so the feature is discoverable without anyone reading docs. + +**Source resolution:** + +- If an editor selection is non-empty → use selection. +- Else if the active leaf is a markdown view → use full note body. +- Else → Notice "Open a markdown note or select text to use this command." Bail. + +**Output / insert behavior:** + +- Honors the template's `insertMode` (cursor / newFile / append), same as voice. If the template says "create new file in Daily/", that's where the cleaned output goes regardless of where the source text came from. Consistent mental model: templates own *where output lives*. +- The source text is **not modified by default**. Output goes per template; user manually deletes the source if they want. Reason: silently mutating the source on every invocation is destructive and surprising. +- One exception worth considering: a per-invocation "Replace source after processing" checkbox in the modal/quick-picker (defaults off). Not a template setting because it's about the source, not the output. **Open question:** ship without this in v1 of the feature, add only if users actually ask. The default-off "Keep both" behavior is safer. + +**Pipeline change:** + +- New source variant in [src/pipeline.ts](../src/pipeline.ts): `{ kind: 'text', text: string }`. Skips transcribe stage exactly like `paste` does. Cleanup + insert run unchanged. +- The LLM-error clipboard fallback (currently copies the raw transcript when cleanup fails) still applies: copies the source text so the user doesn't lose context if the LLM call dies. +- `AbortSignal` plumbed through as today. + +**Setup-card / config gating:** + +- The "From note" tab and the new commands only need the LLM provider configured, not transcription. Setup card logic in [src/ui/setup-card.ts](../src/ui/setup-card.ts) currently blocks on *both* being configured. Either: split the gating per entry point (cleaner) or: only block on LLM for text-source entries (simpler). Pick when implementing. + +**Touch points:** [src/pipeline.ts](../src/pipeline.ts) (add `text` source variant), [src/main.ts](../src/main.ts) (register new command + editor-menu event), [src/ui/modal.ts](../src/ui/modal.ts) (add "From note" tab), new file `src/ui/template-picker.ts` for the quick-picker modal used by the command + context menu, [src/ui/setup-card.ts](../src/ui/setup-card.ts) (relax gating for text-source flow), [src/types.ts](../src/types.ts) (extend source union). + +**Out of scope (for this feature):** + +- Per-template "this template only operates on text" / "this template only operates on voice" gating. All templates work with both source types in v1; if a user creates a template whose prompt only makes sense for raw transcripts, that's on them. +- Multi-select / multi-note batch processing. One source at a time. + +--- + +## Done + +### Collapse API key settings: per-profile only, hide rarely-changed fields under "Advanced" + +Removed the "Global API keys" section and the by-family `apiKeys` map. Each profile now owns its own transcription and LLM keys directly on `transcriptionConfig.apiKey` / `llmConfig.apiKey`. The per-profile fields are no longer framed as "overrides"; they're the only slot. "Transcription language" and "LLM max tokens" moved into a per-profile `
` "Advanced" disclosure. The resolver functions (`resolveTranscriptionApiKey`, `resolveLLMApiKey`) and family helpers (`transcriptionProviderFamily`, `llmProviderFamily`) are gone, along with the `ProviderFamily` type. `secrets.json.nosync` now only contains `profile:{kind}:{side}` IDs. diff --git a/src/pipeline.ts b/src/pipeline.ts index 4405cd3..821e983 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,6 +1,5 @@ import { App } from 'obsidian'; import { EnvironmentProfile, GlobalSettings, NoteTemplate } from './types'; -import { resolveLLMApiKey, resolveTranscriptionApiKey } from './settings'; import { createTranscriptionProvider } from './transcription'; import { createLLMProvider } from './llm'; import { insertOutput, InsertResult } from './insert'; @@ -57,23 +56,15 @@ async function collectTranscript(params: PipelineParams): Promise { case 'audio': { params.onStage?.('transcribe'); const provider = createTranscriptionProvider(params.profile.transcriptionProvider); - const config = { - ...params.profile.transcriptionConfig, - apiKey: resolveTranscriptionApiKey(params.settings, params.profile), - }; - return provider.transcribe(source.audio, config, params.signal); + return provider.transcribe(source.audio, params.profile.transcriptionConfig, params.signal); } } } async function cleanupTranscript(params: PipelineParams, transcript: string): Promise { const llm = createLLMProvider(params.profile.llmProvider); - const config = { - ...params.profile.llmConfig, - apiKey: resolveLLMApiKey(params.settings, params.profile), - }; try { - return await llm.complete(params.template.prompt, transcript, config, params.signal); + return await llm.complete(params.template.prompt, transcript, params.profile.llmConfig, params.signal); } catch (e) { const original = e instanceof Error ? e : new Error(String(e)); try { diff --git a/src/settings/index.ts b/src/settings/index.ts index 48d5e1b..688b72a 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -4,10 +4,7 @@ import { EnvironmentProfile, GlobalSettings, LLMConfig, - LLMProviderID, - ProviderFamily, TranscriptionConfig, - TranscriptionProviderID, } from '../types'; import { loadAllKeys, saveManyKeys } from '../secrets'; import { freshDefaultTemplates } from './default-templates'; @@ -43,7 +40,6 @@ const MOBILE_DEFAULT_PROFILE: EnvironmentProfile = { }; export const DEFAULT_SETTINGS: GlobalSettings = { - apiKeys: {}, activeProfileOverride: 'auto', desktopProfile: DESKTOP_DEFAULT_PROFILE, mobileProfile: MOBILE_DEFAULT_PROFILE, @@ -53,23 +49,8 @@ export const DEFAULT_SETTINGS: GlobalSettings = { templates: [], }; -const PROVIDER_FAMILIES: ProviderFamily[] = [ - 'openai', - 'anthropic', - 'groq', - 'assemblyai', - 'deepgram', - 'revai', - 'gemini', - 'mistral', -]; - const PROFILE_KINDS: ActiveProfileKind[] = ['desktop', 'mobile']; -function globalKeyId(family: ProviderFamily): string { - return `global:${family}`; -} - function profileTranscriptionKeyId(kind: ActiveProfileKind): string { return `profile:${kind}:transcription`; } @@ -99,10 +80,6 @@ export async function saveSettings(plugin: Plugin, settings: GlobalSettings): Pr async function hydrateSecrets(plugin: Plugin, settings: GlobalSettings): Promise { const all = await loadAllKeys(plugin); - for (const family of PROVIDER_FAMILIES) { - const value = all[globalKeyId(family)]; - if (value) settings.apiKeys[family] = value; - } for (const kind of PROFILE_KINDS) { const profile = profileFor(settings, kind); const trKey = all[profileTranscriptionKeyId(kind)]; @@ -114,9 +91,6 @@ async function hydrateSecrets(plugin: Plugin, settings: GlobalSettings): Promise async function persistSecrets(plugin: Plugin, settings: GlobalSettings): Promise { const updates: Record = {}; - for (const family of PROVIDER_FAMILIES) { - updates[globalKeyId(family)] = settings.apiKeys[family] ?? ''; - } for (const kind of PROFILE_KINDS) { const profile = profileFor(settings, kind); updates[profileTranscriptionKeyId(kind)] = profile.transcriptionConfig.apiKey; @@ -128,7 +102,6 @@ async function persistSecrets(plugin: Plugin, settings: GlobalSettings): Promise function stripSecrets(settings: GlobalSettings): GlobalSettings { return { ...settings, - apiKeys: {}, desktopProfile: stripProfileKeys(settings.desktopProfile), mobileProfile: stripProfileKeys(settings.mobileProfile), }; @@ -153,7 +126,6 @@ function mergeSettings( return { ...base, ...partial, - apiKeys: { ...base.apiKeys, ...(partial.apiKeys ?? {}) }, desktopProfile: mergeProfile(base.desktopProfile, partial.desktopProfile), mobileProfile: mergeProfile(base.mobileProfile, partial.mobileProfile), templates: partial.templates ?? base.templates, @@ -178,56 +150,3 @@ function mergeProfile( }, }; } - -export function transcriptionProviderFamily(id: TranscriptionProviderID): ProviderFamily | null { - switch (id) { - case 'openai': - return 'openai'; - case 'groq': - return 'groq'; - case 'assemblyai': - return 'assemblyai'; - case 'deepgram': - return 'deepgram'; - case 'revai': - return 'revai'; - case 'openai-compatible': - case 'webspeech': - return null; - } -} - -export function llmProviderFamily(id: LLMProviderID): ProviderFamily | null { - switch (id) { - case 'anthropic': - return 'anthropic'; - case 'openai': - return 'openai'; - case 'gemini': - return 'gemini'; - case 'mistral': - return 'mistral'; - case 'openai-compatible': - return null; - } -} - -export function resolveTranscriptionApiKey( - settings: GlobalSettings, - profile: EnvironmentProfile, -): string { - if (profile.transcriptionConfig.apiKey) return profile.transcriptionConfig.apiKey; - const family = transcriptionProviderFamily(profile.transcriptionProvider); - if (!family) return ''; - return settings.apiKeys[family] ?? ''; -} - -export function resolveLLMApiKey( - settings: GlobalSettings, - profile: EnvironmentProfile, -): string { - if (profile.llmConfig.apiKey) return profile.llmConfig.apiKey; - const family = llmProviderFamily(profile.llmProvider); - if (!family) return ''; - return settings.apiKeys[family] ?? ''; -} diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 2b45681..7816379 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -3,10 +3,10 @@ import type ReWritePlugin from '../main'; import { ActiveProfileKind, ActiveProfileOverride, + EnvironmentProfile, InsertMode, LLMProviderID, NoteTemplate, - ProviderFamily, RecordingFormatPreference, TranscriptionProviderID, } from '../types'; @@ -30,17 +30,6 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ { id: 'mistral', label: 'Mistral' }, ]; -const PROVIDER_FAMILY_OPTIONS: Array<{ id: ProviderFamily; label: string }> = [ - { id: 'openai', label: 'OpenAI' }, - { id: 'anthropic', label: 'Anthropic' }, - { id: 'groq', label: 'Groq' }, - { id: 'assemblyai', label: 'AssemblyAI' }, - { id: 'deepgram', label: 'Deepgram' }, - { id: 'revai', label: 'Rev.ai' }, - { id: 'gemini', label: 'Google Gemini' }, - { id: 'mistral', label: 'Mistral' }, -]; - const INSERT_MODE_OPTIONS: Array<{ id: InsertMode; label: string }> = [ { id: 'cursor', label: 'Insert at cursor' }, { id: 'newFile', label: 'Create new file' }, @@ -68,7 +57,6 @@ export class ReWriteSettingTab extends PluginSettingTab { this.renderActiveProfile(containerEl); this.renderProfile(containerEl, 'desktop'); this.renderProfile(containerEl, 'mobile'); - this.renderGlobalApiKeys(containerEl); this.renderTemplates(containerEl); this.renderRecording(containerEl); } @@ -158,22 +146,10 @@ export class ReWriteSettingTab extends PluginSettingTab { } new Setting(parent) - .setName('Transcription language') - .setDesc('Optional language hint. Leave blank to auto-detect.') - .addText((t) => { - t.setValue(profile.transcriptionConfig.language); - t.onChange(async (v) => { - profile.transcriptionConfig.language = v; - await this.commit(); - }); - }); - - new Setting(parent) - .setName('Transcription API key override') - .setDesc('Optional. Leave blank to use the global key for this provider.') + .setName('Transcription API key') .addText((t) => { t.inputEl.type = 'password'; - t.setPlaceholder('Per-profile override'); + t.setPlaceholder('Saved securely on this device'); t.setValue(profile.transcriptionConfig.apiKey); t.onChange(async (v) => { profile.transcriptionConfig.apiKey = v; @@ -219,6 +195,38 @@ export class ReWriteSettingTab extends PluginSettingTab { } new Setting(parent) + .setName('LLM API key') + .addText((t) => { + t.inputEl.type = 'password'; + t.setPlaceholder('Saved securely on this device'); + t.setValue(profile.llmConfig.apiKey); + t.onChange(async (v) => { + profile.llmConfig.apiKey = v; + await this.commit(); + }); + }); + + this.renderProfileAdvanced(parent, profile); + } + + private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void { + const details = parent.createEl('details', { cls: 'rewrite-advanced' }); + details.createEl('summary', { text: 'Advanced' }); + + if (profile.transcriptionProvider !== 'webspeech') { + new Setting(details) + .setName('Transcription language') + .setDesc('Optional language hint. Leave blank to auto-detect.') + .addText((t) => { + t.setValue(profile.transcriptionConfig.language); + t.onChange(async (v) => { + profile.transcriptionConfig.language = v; + await this.commit(); + }); + }); + } + + new Setting(details) .setName('LLM max tokens') .setDesc('Maximum tokens for the cleanup response. Default 2048.') .addText((t) => { @@ -230,45 +238,6 @@ export class ReWriteSettingTab extends PluginSettingTab { await this.commit(); }); }); - - new Setting(parent) - .setName('LLM API key override') - .setDesc('Optional. Leave blank to use the global key for this provider.') - .addText((t) => { - t.inputEl.type = 'password'; - t.setPlaceholder('Per-profile override'); - t.setValue(profile.llmConfig.apiKey); - t.onChange(async (v) => { - profile.llmConfig.apiKey = v; - await this.commit(); - }); - }); - } - - private renderGlobalApiKeys(parent: HTMLElement): void { - new Setting(parent).setName('Global API keys').setHeading(); - parent.createEl('p', { - text: 'Shared across profiles unless overridden above. Stored securely on this device.', - cls: 'rewrite-section-desc', - }); - - for (const { id, label } of PROVIDER_FAMILY_OPTIONS) { - new Setting(parent) - .setName(label) - .addText((t) => { - t.inputEl.type = 'password'; - t.setPlaceholder('Not set'); - t.setValue(this.plugin.settings.apiKeys[id] ?? ''); - t.onChange(async (v) => { - if (v) { - this.plugin.settings.apiKeys[id] = v; - } else { - delete this.plugin.settings.apiKeys[id]; - } - await this.commit(); - }); - }); - } } private renderTemplates(parent: HTMLElement): void { diff --git a/src/types.ts b/src/types.ts index 6e7e933..2303160 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,16 +14,6 @@ export type LLMProviderID = | 'gemini' | 'mistral'; -export type ProviderFamily = - | 'openai' - | 'anthropic' - | 'groq' - | 'assemblyai' - | 'deepgram' - | 'revai' - | 'gemini' - | 'mistral'; - export interface TranscriptionConfig { apiKey: string; baseUrl: string; @@ -62,7 +52,6 @@ export type ActiveProfileKind = 'desktop' | 'mobile'; export type RecordingFormatPreference = 'webm' | 'mp4'; export interface GlobalSettings { - apiKeys: Partial>; activeProfileOverride: ActiveProfileOverride; desktopProfile: EnvironmentProfile; mobileProfile: EnvironmentProfile; diff --git a/src/ui/modal.ts b/src/ui/modal.ts index 7ab7f9c..a4cbf7e 100644 --- a/src/ui/modal.ts +++ b/src/ui/modal.ts @@ -51,10 +51,9 @@ export class ReWriteModal extends Modal { contentEl.createEl('h2', { text: 'ReWrite' }); const { kind, profile } = resolveActiveProfile(this.plugin.settings); - if (!isProfileConfigured(profile, this.plugin.settings)) { + if (!isProfileConfigured(profile)) { renderSetupCard({ container: contentEl, - settings: this.plugin.settings, profile, profileLabel: kind === 'desktop' ? 'Desktop' : 'Mobile', onSaved: async () => { diff --git a/src/ui/quick-record.ts b/src/ui/quick-record.ts index bbf2ca6..98bea14 100644 --- a/src/ui/quick-record.ts +++ b/src/ui/quick-record.ts @@ -120,7 +120,7 @@ export async function startQuickRecord( const settings = plugin.settings; const { profile } = resolveActiveProfile(settings); - if (!isProfileConfigured(profile, settings)) { + if (!isProfileConfigured(profile)) { new Notice('ReWrite: profile is not configured. Finish setup to use quick record.'); new ReWriteModal(plugin.app, plugin).open(); return null; diff --git a/src/ui/setup-card.ts b/src/ui/setup-card.ts index ca8d5c0..ea17284 100644 --- a/src/ui/setup-card.ts +++ b/src/ui/setup-card.ts @@ -1,6 +1,5 @@ import { Setting } from 'obsidian'; -import { EnvironmentProfile, GlobalSettings, LLMProviderID, TranscriptionProviderID } from '../types'; -import { llmProviderFamily, resolveLLMApiKey, resolveTranscriptionApiKey, transcriptionProviderFamily } from '../settings'; +import { EnvironmentProfile, LLMProviderID, TranscriptionProviderID } from '../types'; const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string }> = [ { id: 'openai', label: 'OpenAI Whisper' }, @@ -20,22 +19,21 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [ { id: 'mistral', label: 'Mistral' }, ]; -export function isProfileConfigured(profile: EnvironmentProfile, settings: GlobalSettings): boolean { +export function isProfileConfigured(profile: EnvironmentProfile): boolean { const tx = profile.transcriptionProvider; if (tx !== 'webspeech') { if (!profile.transcriptionConfig.model) return false; - if (!resolveTranscriptionApiKey(settings, profile)) return false; + if (!profile.transcriptionConfig.apiKey) return false; if (tx === 'openai-compatible' && !profile.transcriptionConfig.baseUrl.trim()) return false; } if (!profile.llmConfig.model) return false; - if (!resolveLLMApiKey(settings, profile)) return false; + if (!profile.llmConfig.apiKey) return false; if (profile.llmProvider === 'openai-compatible' && !profile.llmConfig.baseUrl.trim()) return false; return true; } export interface SetupCardParams { container: HTMLElement; - settings: GlobalSettings; profile: EnvironmentProfile; profileLabel: string; onSaved: () => Promise; @@ -43,7 +41,7 @@ export interface SetupCardParams { } export function renderSetupCard(params: SetupCardParams): void { - const { container, profile, settings, profileLabel } = params; + const { container, profile, profileLabel } = params; const card = container.createDiv({ cls: 'rewrite-setup-card' }); card.createEl('h3', { text: 'Setup required' }); card.createEl('p', { @@ -89,8 +87,8 @@ export function renderSetupCard(params: SetupCardParams): void { renderApiKeyField(card, { label: 'Transcription API key', placeholder: 'Saved securely on this device', - getValue: () => keyFieldValue('transcription', settings, profile), - setValue: (v) => writeKeyField('transcription', settings, profile, v), + getValue: () => profile.transcriptionConfig.apiKey, + setValue: (v) => { profile.transcriptionConfig.apiKey = v; }, }); } @@ -132,8 +130,8 @@ export function renderSetupCard(params: SetupCardParams): void { renderApiKeyField(card, { label: 'LLM API key', placeholder: 'Saved securely on this device', - getValue: () => keyFieldValue('llm', settings, profile), - setValue: (v) => writeKeyField('llm', settings, profile, v), + getValue: () => profile.llmConfig.apiKey, + setValue: (v) => { profile.llmConfig.apiKey = v; }, }); const actions = card.createDiv({ cls: 'rewrite-setup-actions' }); @@ -163,39 +161,6 @@ function renderApiKeyField(container: HTMLElement, field: KeyField): void { }); } -function keyFieldValue( - side: 'transcription' | 'llm', - settings: GlobalSettings, - profile: EnvironmentProfile, -): string { - const family = side === 'transcription' - ? transcriptionProviderFamily(profile.transcriptionProvider) - : llmProviderFamily(profile.llmProvider); - const profileKey = side === 'transcription' ? profile.transcriptionConfig.apiKey : profile.llmConfig.apiKey; - if (profileKey) return profileKey; - if (family) return settings.apiKeys[family] ?? ''; - return ''; -} - -function writeKeyField( - side: 'transcription' | 'llm', - settings: GlobalSettings, - profile: EnvironmentProfile, - value: string, -): void { - const family = side === 'transcription' - ? transcriptionProviderFamily(profile.transcriptionProvider) - : llmProviderFamily(profile.llmProvider); - if (family) { - settings.apiKeys[family] = value; - if (side === 'transcription') profile.transcriptionConfig.apiKey = ''; - else profile.llmConfig.apiKey = ''; - } else { - if (side === 'transcription') profile.transcriptionConfig.apiKey = value; - else profile.llmConfig.apiKey = value; - } -} - function modelPlaceholderForTranscription(id: TranscriptionProviderID): string { switch (id) { case 'openai':