This commit is contained in:
WiseGuru 2026-05-24 13:36:13 -07:00
parent 2e2696a108
commit fb0212a337
5 changed files with 717 additions and 7 deletions

View file

@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project state
This repo is the starting point for a planned **Voice Notes plugin for Obsidian**. The current contents are the unmodified Obsidian [sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin) boilerplate (note: [manifest.json](manifest.json) still reports `id: "sample-plugin"`, `name: "Sample Plugin"` — these need to be replaced before release).
**Implementation in progress against [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md). The live status of each phase (what's committed, what's uncommitted, architectural decisions made along the way) is in [docs/claude-scratch/STATUS.md](docs/claude-scratch/STATUS.md). Read STATUS.md first; it is more current than the rest of this section, which will be rewritten wholesale in Phase 13.**
The real spec — providers, profile system, modal UX, templates, settings layout, API request shapes — lives in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md). Treat that file as the source of truth for what to build; the code in [src/](src/) is placeholder scaffolding.
This repo is becoming the **ReWrite (Voice Notes) plugin for Obsidian**. The spec (providers, profile system, modal UX, templates, settings layout, API request shapes) lives in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md), which is still the source of truth for behavior.
When implementing spec features, 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).

View file

@ -76,6 +76,15 @@ src/
## Implementation phases
### Per-phase deliverables (applies to every phase below)
Every phase ends with the same two doc touches before it counts as done:
1. **Update [docs/claude-scratch/STATUS.md](claude-scratch/STATUS.md)**: bump the "Updated" date, flip the phase row to committed/uncommitted, list uncommitted files, and trim the "What's left" section. This is the live tracker, so a future Claude can resume without re-reading the world.
2. **Update [CLAUDE.md](../CLAUDE.md)** if the phase changed anything CLAUDE.md describes (architecture, commands, gotchas, conventions). Per CLAUDE.md's own Documentation Maintenance rule. Phase 13 is the dedicated full refresh; phases before it should at minimum keep the doc from going stale (e.g. drop a pointer to STATUS.md if "Project state" is now wrong).
Treat both as part of the phase, not follow-ups. The user has already had to remind once.
### Phase 1: Scaffold rename and types (foundations)
- [manifest.json](../manifest.json): `id``rewrite-plugin`, `name``ReWrite (Voice Notes)`, `version``0.1.0` (pre-release), `minAppVersion``1.4.0`, `description` → spec wording, drop `fundingUrl`/`authorUrl`/`author` Obsidian defaults, `isDesktopOnly: false`.

View file

@ -1,5 +1,6 @@
import { Plugin } from 'obsidian';
import { loadSettings, saveSettings } from './settings';
import { ReWriteSettingTab } from './settings/tab';
import { GlobalSettings } from './types';
export default class ReWritePlugin extends Plugin {
@ -7,6 +8,7 @@ export default class ReWritePlugin extends Plugin {
async onload(): Promise<void> {
this.settings = await loadSettings(this);
this.addSettingTab(new ReWriteSettingTab(this.app, this));
}
async saveSettings(): Promise<void> {

544
src/settings/tab.ts Normal file
View file

@ -0,0 +1,544 @@
import { App, Modal, Notice, PluginSettingTab, Setting } from 'obsidian';
import type ReWritePlugin from '../main';
import {
ActiveProfileKind,
ActiveProfileOverride,
InsertMode,
LLMProviderID,
NoteTemplate,
ProviderFamily,
RecordingFormatPreference,
TranscriptionProviderID,
} from '../types';
import { detectActiveProfileKind } from '../platform';
const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string }> = [
{ id: 'openai', label: 'OpenAI Whisper' },
{ id: 'openai-compatible', label: 'OpenAI-compatible (local server)' },
{ id: 'groq', label: 'Groq' },
{ id: 'assemblyai', label: 'AssemblyAI' },
{ id: 'deepgram', label: 'Deepgram' },
{ id: 'revai', label: 'Rev.ai' },
{ id: 'webspeech', label: 'Web Speech (browser)' },
];
const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
{ id: 'anthropic', label: 'Anthropic Claude' },
{ id: 'openai', label: 'OpenAI GPT' },
{ id: 'openai-compatible', label: 'OpenAI-compatible (local server)' },
{ id: 'gemini', label: 'Google Gemini' },
{ 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' },
{ id: 'append', label: 'Append to active note' },
];
const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: string }> = [
{ id: 'webm', label: 'webm (best on Chromium/Electron)' },
{ id: 'mp4', label: 'mp4 (best on mobile/Safari)' },
];
export class ReWriteSettingTab extends PluginSettingTab {
private editingTemplateId: string | null = null;
private dragSourceIndex: number | null = null;
constructor(app: App, private readonly plugin: ReWritePlugin) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass('rewrite-settings');
this.renderActiveProfile(containerEl);
this.renderProfile(containerEl, 'desktop');
this.renderProfile(containerEl, 'mobile');
this.renderGlobalApiKeys(containerEl);
this.renderTemplates(containerEl);
this.renderRecording(containerEl);
}
private async commit(): Promise<void> {
await this.plugin.saveSettings();
}
private renderActiveProfile(parent: HTMLElement): void {
new Setting(parent).setName('Active profile').setHeading();
const s = this.plugin.settings;
const detected = detectActiveProfileKind(s);
const detectedLabel = detected === 'desktop' ? 'Desktop' : 'Mobile';
const overrideDesc = s.activeProfileOverride === 'auto'
? `Auto-detected: ${detectedLabel}.`
: `Forced: ${detectedLabel}.`;
new Setting(parent)
.setName('Profile selection')
.setDesc(overrideDesc)
.addDropdown((dd) => {
dd.addOption('auto', 'Auto-detect (recommended)');
dd.addOption('desktop', 'Force desktop');
dd.addOption('mobile', 'Force mobile');
dd.setValue(s.activeProfileOverride);
dd.onChange(async (v) => {
s.activeProfileOverride = v as ActiveProfileOverride;
await this.commit();
this.display();
});
});
}
private renderProfile(parent: HTMLElement, kind: ActiveProfileKind): void {
const profile = kind === 'desktop'
? this.plugin.settings.desktopProfile
: this.plugin.settings.mobileProfile;
const title = kind === 'desktop' ? 'Desktop profile' : 'Mobile profile';
new Setting(parent).setName(title).setHeading();
new Setting(parent)
.setName('Profile label')
.setDesc('Display name for this profile.')
.addText((t) => {
t.setValue(profile.name);
t.onChange(async (v) => {
profile.name = v;
await this.commit();
});
});
new Setting(parent)
.setName('Transcription provider')
.addDropdown((dd) => {
for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(profile.transcriptionProvider);
dd.onChange(async (v) => {
profile.transcriptionProvider = v as TranscriptionProviderID;
await this.commit();
this.display();
});
});
if (profile.transcriptionProvider !== 'webspeech') {
new Setting(parent)
.setName('Transcription model')
.setDesc(transcriptionModelHint(profile.transcriptionProvider))
.addText((t) => {
t.setValue(profile.transcriptionConfig.model);
t.onChange(async (v) => {
profile.transcriptionConfig.model = v;
await this.commit();
});
});
if (profile.transcriptionProvider === 'openai-compatible') {
new Setting(parent)
.setName('Transcription base URL')
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
.addText((t) => {
t.setValue(profile.transcriptionConfig.baseUrl);
t.onChange(async (v) => {
profile.transcriptionConfig.baseUrl = v;
await this.commit();
});
});
}
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.')
.addText((t) => {
t.inputEl.type = 'password';
t.setPlaceholder('Per-profile override');
t.setValue(profile.transcriptionConfig.apiKey);
t.onChange(async (v) => {
profile.transcriptionConfig.apiKey = v;
await this.commit();
});
});
}
new Setting(parent)
.setName('LLM provider')
.addDropdown((dd) => {
for (const opt of LLM_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(profile.llmProvider);
dd.onChange(async (v) => {
profile.llmProvider = v as LLMProviderID;
await this.commit();
this.display();
});
});
new Setting(parent)
.setName('LLM model')
.setDesc(llmModelHint(profile.llmProvider))
.addText((t) => {
t.setValue(profile.llmConfig.model);
t.onChange(async (v) => {
profile.llmConfig.model = v;
await this.commit();
});
});
if (profile.llmProvider === 'openai-compatible') {
new Setting(parent)
.setName('LLM base URL')
.setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)')
.addText((t) => {
t.setValue(profile.llmConfig.baseUrl);
t.onChange(async (v) => {
profile.llmConfig.baseUrl = v;
await this.commit();
});
});
}
new Setting(parent)
.setName('LLM max tokens')
.setDesc('Maximum tokens for the cleanup response. Default 2048.')
.addText((t) => {
t.inputEl.type = 'number';
t.setValue(String(profile.llmConfig.maxTokens));
t.onChange(async (v) => {
const n = Number.parseInt(v, 10);
profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048;
await this.commit();
});
});
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 {
new Setting(parent).setName('Templates').setHeading();
parent.createEl('p', {
text: 'Drag the handle to reorder. Templates appear in the modal dropdown in this order.',
cls: 'rewrite-section-desc',
});
const templates = this.plugin.settings.templates;
const list = parent.createDiv({ cls: 'rewrite-templates-list' });
if (templates.length === 0) {
list.createEl('p', {
text: 'No templates configured. Add one to get started.',
cls: 'rewrite-templates-empty',
});
} else {
for (let i = 0; i < templates.length; i++) {
const template = templates[i];
if (!template) continue;
this.renderTemplateItem(list, template, i);
}
}
const actions = parent.createDiv({ cls: 'rewrite-templates-actions' });
const addBtn = actions.createEl('button', { text: 'Add template', cls: 'mod-cta' });
addBtn.addEventListener('click', () => {
void this.addTemplate();
});
}
private renderTemplateItem(parent: HTMLElement, template: NoteTemplate, index: number): void {
const item = parent.createDiv({ cls: 'rewrite-template-item' });
item.draggable = true;
item.dataset.index = String(index);
item.addEventListener('dragstart', (ev) => {
this.dragSourceIndex = index;
item.addClass('is-dragging');
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', String(index));
}
});
item.addEventListener('dragend', () => {
item.removeClass('is-dragging');
this.dragSourceIndex = null;
});
item.addEventListener('dragover', (ev) => {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
item.addClass('is-drop-target');
});
item.addEventListener('dragleave', () => {
item.removeClass('is-drop-target');
});
item.addEventListener('drop', (ev) => {
ev.preventDefault();
item.removeClass('is-drop-target');
const from = this.dragSourceIndex;
this.dragSourceIndex = null;
void this.reorderTemplate(from, index);
});
const header = item.createDiv({ cls: 'rewrite-template-header' });
const handle = header.createSpan({ cls: 'rewrite-drag-handle', text: '⋮⋮' });
handle.setAttr('aria-label', 'Drag to reorder');
header.createSpan({
cls: 'rewrite-template-name',
text: template.name || '(unnamed)',
});
const isEditing = this.editingTemplateId === template.id;
const actions = header.createDiv({ cls: 'rewrite-template-actions' });
const editBtn = actions.createEl('button', { text: isEditing ? 'Close' : 'Edit' });
editBtn.addEventListener('click', () => {
this.editingTemplateId = isEditing ? null : template.id;
this.display();
});
const deleteBtn = actions.createEl('button', { text: 'Delete' });
deleteBtn.addEventListener('click', () => {
this.deleteTemplate(template);
});
if (isEditing) {
this.renderTemplateEditor(item, template);
}
}
private renderTemplateEditor(parent: HTMLElement, template: NoteTemplate): void {
const editor = parent.createDiv({ cls: 'rewrite-template-editor' });
new Setting(editor)
.setName('Name')
.addText((t) => {
t.setValue(template.name);
t.onChange(async (v) => {
template.name = v;
await this.commit();
});
});
new Setting(editor)
.setName('Prompt')
.setDesc('System prompt sent to the LLM. The raw transcript is passed as the user message.')
.addTextArea((t) => {
t.setValue(template.prompt);
t.onChange(async (v) => {
template.prompt = v;
await this.commit();
});
t.inputEl.rows = 6;
t.inputEl.addClass('rewrite-prompt-textarea');
});
new Setting(editor)
.setName('Insert mode')
.addDropdown((dd) => {
for (const opt of INSERT_MODE_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(template.insertMode);
dd.onChange(async (v) => {
template.insertMode = v as InsertMode;
await this.commit();
this.display();
});
});
if (template.insertMode === 'newFile') {
new Setting(editor)
.setName('New file folder')
.setDesc('Vault-relative folder. Leave blank for vault root.')
.addText((t) => {
t.setValue(template.newFileFolder);
t.onChange(async (v) => {
template.newFileFolder = v;
await this.commit();
});
});
new Setting(editor)
.setName('New file name template')
.setDesc('Supports {{date}} (YYYY-MM-DD) and {{time}} (HHmmss).')
.addText((t) => {
t.setValue(template.newFileNameTemplate);
t.setPlaceholder('ReWrite {{date}} {{time}}');
t.onChange(async (v) => {
template.newFileNameTemplate = v;
await this.commit();
});
});
}
}
private async addTemplate(): Promise<void> {
const newTemplate: NoteTemplate = {
id: generateTemplateId(),
name: 'Untitled template',
prompt: 'Clean up the transcript while preserving the original meaning.',
insertMode: 'cursor',
newFileFolder: '',
newFileNameTemplate: 'ReWrite {{date}} {{time}}',
};
this.plugin.settings.templates.push(newTemplate);
this.editingTemplateId = newTemplate.id;
await this.commit();
this.display();
}
private async reorderTemplate(from: number | null, to: number): Promise<void> {
if (from === null || from === to) return;
const templates = this.plugin.settings.templates;
if (from < 0 || from >= templates.length || to < 0 || to >= templates.length) return;
const [moved] = templates.splice(from, 1);
if (!moved) return;
templates.splice(to, 0, moved);
await this.commit();
this.display();
}
private deleteTemplate(template: NoteTemplate): void {
new ConfirmModal(this.app, `Delete template "${template.name}"?`, 'Delete', async () => {
const s = this.plugin.settings;
s.templates = s.templates.filter((t) => t.id !== template.id);
if (s.defaultTemplateId === template.id) s.defaultTemplateId = '';
if (s.lastUsedTemplateId === template.id) s.lastUsedTemplateId = '';
if (this.editingTemplateId === template.id) this.editingTemplateId = null;
await this.commit();
this.display();
new Notice('Template deleted.');
}).open();
}
private renderRecording(parent: HTMLElement): void {
new Setting(parent).setName('Recording').setHeading();
new Setting(parent)
.setName('Audio format preference')
.setDesc('Web Speech ignores this. Use mp4 on iOS, otherwise webm.')
.addDropdown((dd) => {
for (const opt of RECORDING_FORMAT_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(this.plugin.settings.recordingFormat);
dd.onChange(async (v) => {
this.plugin.settings.recordingFormat = v as RecordingFormatPreference;
await this.commit();
});
});
}
}
class ConfirmModal extends Modal {
constructor(
app: App,
private readonly message: string,
private readonly confirmLabel: string,
private readonly onConfirm: () => void | Promise<void>,
) {
super(app);
}
onOpen(): void {
this.contentEl.createEl('p', { text: this.message });
const actions = this.contentEl.createDiv({ cls: 'rewrite-modal-actions' });
const cancel = actions.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
const confirm = actions.createEl('button', { text: this.confirmLabel, cls: 'mod-warning' });
confirm.addEventListener('click', () => {
this.close();
void this.onConfirm();
});
}
onClose(): void {
this.contentEl.empty();
}
}
function generateTemplateId(): string {
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function transcriptionModelHint(id: TranscriptionProviderID): string {
switch (id) {
case 'openai':
return 'e.g. whisper-1';
case 'groq':
return 'e.g. whisper-large-v3-turbo';
case 'assemblyai':
return 'Optional. e.g. universal or nano';
case 'deepgram':
return 'e.g. nova-2 or nova-3';
case 'revai':
return 'Optional transcriber name';
case 'openai-compatible':
return 'Whichever model your local server exposes';
case 'webspeech':
return '';
}
}
function llmModelHint(id: LLMProviderID): string {
switch (id) {
case 'anthropic':
return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001';
case 'openai':
return 'e.g. gpt-4o-mini';
case 'gemini':
return 'e.g. gemini-2.0-flash';
case 'mistral':
return 'e.g. mistral-large-latest';
case 'openai-compatible':
return 'Whichever model your local server exposes';
}
}

View file

@ -1,8 +1,163 @@
/*
/* ReWrite plugin styles */
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.rewrite-modal .rewrite-template-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
If your plugin does not need CSS, delete this file.
.rewrite-modal .rewrite-tabs {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
*/
.rewrite-modal .rewrite-tab.is-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.rewrite-modal .rewrite-tab-body {
margin-top: 8px;
}
.rewrite-modal .rewrite-recording-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.rewrite-modal .rewrite-pulse-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--color-red);
animation: rewrite-pulse 1s ease-in-out infinite;
}
@keyframes rewrite-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.rewrite-modal .rewrite-live-transcript {
min-height: 60px;
padding: 8px;
margin-top: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
white-space: pre-wrap;
}
.rewrite-modal .rewrite-paste {
width: 100%;
min-height: 160px;
font-family: var(--font-text);
}
.rewrite-modal .rewrite-progress {
margin-top: 12px;
font-style: italic;
}
.rewrite-modal .rewrite-retry {
margin-top: 12px;
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.rewrite-setup-card {
padding: 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
margin-bottom: 12px;
}
.rewrite-setup-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* Settings tab */
.rewrite-settings .rewrite-section-desc {
color: var(--text-muted);
font-size: var(--font-ui-small);
margin-top: -4px;
margin-bottom: 8px;
}
.rewrite-settings .rewrite-templates-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.rewrite-settings .rewrite-templates-empty {
color: var(--text-muted);
font-style: italic;
}
.rewrite-settings .rewrite-template-item {
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 8px;
background-color: var(--background-secondary);
}
.rewrite-settings .rewrite-template-item.is-dragging {
opacity: 0.4;
}
.rewrite-settings .rewrite-template-item.is-drop-target {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.rewrite-settings .rewrite-template-header {
display: flex;
align-items: center;
gap: 8px;
}
.rewrite-settings .rewrite-drag-handle {
cursor: grab;
user-select: none;
color: var(--text-muted);
padding: 0 4px;
}
.rewrite-settings .rewrite-drag-handle:active {
cursor: grabbing;
}
.rewrite-settings .rewrite-template-name {
flex: 1;
font-weight: var(--font-semibold);
}
.rewrite-settings .rewrite-template-actions {
display: flex;
gap: 4px;
}
.rewrite-settings .rewrite-template-editor {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--background-modifier-border);
}
.rewrite-settings .rewrite-prompt-textarea {
width: 100%;
min-height: 120px;
font-family: var(--font-text);
}
.rewrite-settings .rewrite-templates-actions {
margin-top: 8px;
}