From b0bc85eff0794762b4b29e2e2618a240538f11a9 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:21:25 -0700 Subject: [PATCH] Update max token suggestions --- CLAUDE.md | 1 + src/llm/anthropic.ts | 2 +- src/settings/index.ts | 2 +- src/settings/tab.ts | 57 +++++++++++++++++++++++++++++++++++++++++-- styles.css | 13 ++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59c1044..ae485e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **`safeStorage` is lazy-required inside a `Platform.isDesktop` guard** in [src/secrets.ts](src/secrets.ts). Importing `electron` at module top would crash on mobile load (it's marked `external` in esbuild). Any failure is treated as "encryption unavailable", which is also the mobile path and the Linux-without-keyring path. The settings tab surfaces the active backend via `safeStorage.getSelectedStorageBackend()` so users can see why it failed (e.g. `basic_text` is Chromium's last-resort backend and counts as unencrypted). - **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react. - **Keep zxcvbn lazy and the strength API async.** [src/passphrase-strength.ts](src/passphrase-strength.ts) pulls `@zxcvbn-ts/*` via dynamic `import()` specifically so the ~1.6 MB of dictionaries are not parsed/constructed at plugin load (measured: ~9 ms startup vs ~47 ms with a static import). Do not "simplify" it back to a top-level static import or a synchronous `evaluatePassphrase` — that re-adds the cost to every Obsidian launch on every device. The async ripples (modal `updateStrength` race guard, `await isPassphraseAcceptable` in `changeEncryptionMode`) are intentional. `warmPassphraseStrength()` is fired on passphrase-modal open to hide the one-time build behind the modal animation. +- **`maxTokens` has two settings-tab views over one value.** `LLMConfig.maxTokens` (per profile, default `2560`) is the single source of truth. The normal-area "Maximum note length" dropdown (`renderNoteLength` in [src/settings/tab.ts](src/settings/tab.ts)) frames it in minutes via `NOTE_LENGTH_PRESETS` at `TOKENS_PER_MIN = 256` (≈150 wpm × ~1.3 tokens/word, padded ~20% for headings/bullets/`Speaker X:` labels; ~10 min → 2560). The Advanced "LLM max tokens" text field edits the same number raw; a value not matching a preset surfaces in the dropdown as a "Custom (N tokens, ~M min)" option. The dropdown calls `this.display()` on change so Advanced reflects the new number; the raw text field does not redraw (focus preservation), so the dropdown updates on the next full render. The Anthropic adapter's `config.maxTokens > 0 ? ... : 2560` fallback ([src/llm/anthropic.ts](src/llm/anthropic.ts)) must stay in sync with the default. The cap is on **output** tokens, so it bounds note length, not input. - **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text. - **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other. - **`setHeading()` instead of manual `

`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. In [src/settings/tab.ts](src/settings/tab.ts), section headings go through the `sectionHeading(parent, name, icon)` helper, which builds the `setHeading()` Setting and prepends a Lucide icon (via `setIcon`) to its `nameEl` styled by `.rewrite-heading-icon`. It returns the `Setting` so callers that attach a status badge (profile, shared core) still reach `nameEl`. Add new headings via this helper, not a bare `new Setting(...).setHeading()`, so they keep an icon. diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts index 74614ec..eeac218 100644 --- a/src/llm/anthropic.ts +++ b/src/llm/anthropic.ts @@ -29,7 +29,7 @@ export function createAnthropicLLM(): LLMProvider { if (!config.model) throw new Error('anthropic: model is not configured'); const body = { model: config.model, - max_tokens: config.maxTokens > 0 ? config.maxTokens : 2048, + max_tokens: config.maxTokens > 0 ? config.maxTokens : 2560, system: systemPrompt, messages: [{ role: 'user', content: userMessage }], }; diff --git a/src/settings/index.ts b/src/settings/index.ts index 4849120..3c3ea29 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -20,7 +20,7 @@ const EMPTY_LLM_CONFIG: LLMConfig = { apiKey: '', baseUrl: '', model: '', - maxTokens: 2048, + maxTokens: 2560, }; const DESKTOP_DEFAULT_PROFILE: EnvironmentProfile = { diff --git a/src/settings/tab.ts b/src/settings/tab.ts index a07e028..204fa01 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -27,6 +27,19 @@ import { PassphraseModal } from '../ui/passphrase-modal'; // Sentinel value for the "Custom..." entry in a model dropdown; never written to config.model. const CUSTOM_MODEL_OPTION = '__rewrite_custom__'; +// Cleanup output runs ~256 tokens per minute of speech (≈150 wpm × ~1.3 tokens/word, +// padded ~20% for headings, bullets, and Speaker labels in structured/diarized notes). +// The "Maximum note length" dropdown maps these minute presets onto config.maxTokens; +// the raw token count is editable in Advanced. ~10 min → 2560 is the default. +const TOKENS_PER_MIN = 256; +const NOTE_LENGTH_PRESETS: Array<{ minutes: number; tokens: number }> = [ + { minutes: 5, tokens: 1280 }, + { minutes: 10, tokens: 2560 }, + { minutes: 20, tokens: 5120 }, + { minutes: 30, tokens: 7680 }, + { minutes: 60, tokens: 15360 }, +]; + const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string; desktopOnly?: boolean }> = [ { id: 'openai', label: 'OpenAI Whisper' }, { id: 'openai-compatible', label: 'OpenAI-compatible (local server)' }, @@ -468,11 +481,48 @@ export class ReWriteSettingTab extends PluginSettingTab { await this.commit(); }); }); + + this.renderNoteLength(body, profile); } this.renderProfileAdvanced(body, profile); } + // Normal-area control for cleanup output length, framed in minutes rather than raw + // tokens. Presets map onto config.maxTokens (the single source of truth); a non-preset + // value (set via the Advanced "LLM max tokens" field) surfaces as a "Custom (...)" option. + private renderNoteLength(parent: HTMLElement, profile: EnvironmentProfile): void { + const current = profile.llmConfig.maxTokens; + const matched = NOTE_LENGTH_PRESETS.some((p) => p.tokens === current); + + const setting = new Setting(parent) + .setName('Maximum note length') + .setDesc( + 'How long a recording the cleaned note can hold before the response is cut off. ' + + 'Roughly 10 min of speech ≈ 2,560 tokens; structured or multi-speaker notes run longer. ' + + 'For an exact token count, use LLM max tokens under Advanced.', + ) + .addDropdown((d) => { + for (const p of NOTE_LENGTH_PRESETS) { + d.addOption(String(p.tokens), `~${p.minutes} min (${p.tokens.toLocaleString()} tokens)`); + } + if (!matched) { + const mins = Math.max(1, Math.round(current / TOKENS_PER_MIN)); + d.addOption(String(current), `Custom (${current.toLocaleString()} tokens, ~${mins} min)`); + } + d.setValue(String(current)); + d.onChange(async (v) => { + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n <= 0) return; + profile.llmConfig.maxTokens = n; + await this.commit(); + // Re-render so the Advanced raw-token field reflects the new value. + this.display(); + }); + }); + setting.settingEl.addClass('rewrite-note-length'); + } + private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void { if (profile.transcriptionProvider === 'none' && profile.llmProvider === 'none') return; @@ -495,13 +545,16 @@ export class ReWriteSettingTab extends PluginSettingTab { if (profile.llmProvider !== 'none') { new Setting(details) .setName('LLM max tokens') - .setDesc('Maximum tokens for the cleanup response. Default 2048.') + .setDesc( + 'Exact token cap for the cleanup response, overriding the Maximum note length presets. ' + + '~256 tokens ≈ 1 min of cleaned speech. Default 2560.', + ) .addText((t) => { t.inputEl.type = 'number'; t.setValue(String(profile.llmConfig.maxTokens)); t.onChange(async (v) => { const n = Number.parseInt(v, 10); - profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048; + profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2560; await this.commit(); }); }); diff --git a/styles.css b/styles.css index 32d5569..f6b5398 100644 --- a/styles.css +++ b/styles.css @@ -200,6 +200,19 @@ margin-left: -15px; } +/* Give "Maximum note length" first-class emphasis vs the plainer fields around it. */ +.rewrite-settings .rewrite-note-length { + margin-top: 8px; + padding: 6px 12px; + border-left: 3px solid var(--interactive-accent); + background-color: var(--background-secondary); + border-radius: 4px; +} + +.rewrite-settings .rewrite-note-length .setting-item-name { + font-weight: var(--font-semibold, 600); +} + .rewrite-settings .rewrite-profile-active-badge { display: inline-block; background-color: var(--interactive-accent);