This commit is contained in:
WiseGuru 2026-05-24 21:26:38 -07:00
parent 354b4603a9
commit 8fe298e5a1
9 changed files with 178 additions and 220 deletions

View file

@ -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<GlobalSettings> | 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`.

124
docs/FEATURES.md Normal file
View file

@ -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 `<vault>/.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:<port>` 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 `<details>` "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.

View file

@ -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<string> {
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<string> {
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 {

View file

@ -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<void> {
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<void> {
const updates: Record<string, string> = {};
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] ?? '';
}

View file

@ -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 {

View file

@ -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<Record<ProviderFamily, string>>;
activeProfileOverride: ActiveProfileOverride;
desktopProfile: EnvironmentProfile;
mobileProfile: EnvironmentProfile;

View file

@ -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 () => {

View file

@ -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;

View file

@ -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<void>;
@ -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':