mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
f3
This commit is contained in:
parent
354b4603a9
commit
8fe298e5a1
9 changed files with 178 additions and 220 deletions
10
CLAUDE.md
10
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<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
124
docs/FEATURES.md
Normal 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
11
src/types.ts
11
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<Record<ProviderFamily, string>>;
|
||||
activeProfileOverride: ActiveProfileOverride;
|
||||
desktopProfile: EnvironmentProfile;
|
||||
mobileProfile: EnvironmentProfile;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in a new issue