mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
f4
This commit is contained in:
parent
8fe298e5a1
commit
0c6cd44d72
8 changed files with 304 additions and 103 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -56,9 +56,11 @@ src/
|
|||
│ ├── 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
|
||||
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured
|
||||
│ └── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
|
||||
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection
|
||||
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured (voice vs text purpose)
|
||||
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
|
||||
│ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu)
|
||||
│ └── text-source.ts # resolveActiveTextSource + runTextPipeline helpers for text-source flows
|
||||
├── transcription/
|
||||
│ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider()
|
||||
│ ├── openai.ts # Whisper-shape POST (also used by openai-compatible + groq)
|
||||
|
|
@ -77,11 +79,13 @@ src/
|
|||
|
||||
[src/pipeline.ts](src/pipeline.ts) runs three stages with `onStage` callbacks for UI:
|
||||
|
||||
1. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` (text passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
|
||||
1. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
|
||||
2. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(template.prompt, transcript, config)`. On error, the raw transcript is copied to the clipboard before re-throwing, so the user keeps their words.
|
||||
3. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`.
|
||||
|
||||
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by both [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts).
|
||||
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points).
|
||||
|
||||
The `PipelineSource` union has four variants: `audio` (recorded blob), `paste` (textarea input), `webspeech` (live-captured transcript), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile.
|
||||
|
||||
## Provider system
|
||||
|
||||
|
|
@ -106,8 +110,9 @@ Registered in [src/main.ts](src/main.ts):
|
|||
|
||||
- **`rewrite-plugin:open-modal`** ("Open"): opens the main modal with the last-used template selected.
|
||||
- **`rewrite-plugin:quick-record`** ("Quick record"): starts a recording immediately with a floating mini-UI (no modal). Second press toggles to Stop. On unconfigured profile or capture-API unavailability, opens the modal instead. On post-capture pipeline error, opens the modal so the user can retry (LLM-stage failures leave the raw transcript on the clipboard).
|
||||
- **`rewrite-plugin:process-text`** ("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown via `Notice`. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active.
|
||||
|
||||
Plus an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal.
|
||||
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` and an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal.
|
||||
|
||||
## Code style
|
||||
|
||||
|
|
|
|||
|
|
@ -74,51 +74,12 @@ Once Phase A is solid:
|
|||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
### Act on existing text in a note
|
||||
|
||||
Added a `{ kind: 'text'; text: string }` source variant to the pipeline (skips transcription, same path as `paste`). Three entry points: `rewrite-plugin:process-text` command, an editor-menu item "ReWrite with template...", and a new "From note" tab in the main modal. All three resolve text from the active editor (selection if non-empty, otherwise whole note body) and open a lightweight template quick-picker before running. Setup-card gating split into voice (full check) vs text (LLM-only check) via `isProfileConfiguredForText` and a `purpose` param on `renderSetupCard`; the modal's per-tab rendering picks the right gate. Helpers live in [src/ui/text-source.ts](../src/ui/text-source.ts) (resolution + `runTextPipeline`) and [src/ui/template-picker.ts](../src/ui/template-picker.ts) (the quick-picker modal). Shipped without the per-invocation "Replace source" checkbox; can revisit if users ask.
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
61
src/main.ts
61
src/main.ts
|
|
@ -1,8 +1,10 @@
|
|||
import { Plugin } from 'obsidian';
|
||||
import { Notice, Plugin } from 'obsidian';
|
||||
import { loadSettings, saveSettings } from './settings';
|
||||
import { ReWriteSettingTab } from './settings/tab';
|
||||
import { ReWriteModal } from './ui/modal';
|
||||
import { QuickRecordController, startQuickRecord } from './ui/quick-record';
|
||||
import { resolveActiveTextSource, resolveTextFromEditor, runTextPipeline, TextResolution } from './ui/text-source';
|
||||
import { TemplatePickerModal } from './ui/template-picker';
|
||||
import { GlobalSettings } from './types';
|
||||
|
||||
export default class ReWritePlugin extends Plugin {
|
||||
|
|
@ -32,6 +34,24 @@ export default class ReWritePlugin extends Plugin {
|
|||
void this.toggleQuickRecord();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'process-text',
|
||||
name: 'Process text with template',
|
||||
callback: () => {
|
||||
this.processTextWithTemplate();
|
||||
},
|
||||
});
|
||||
|
||||
this.registerEvent(this.app.workspace.on('editor-menu', (menu, editor) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('ReWrite with template...');
|
||||
item.setIcon('mic');
|
||||
item.onClick(() => {
|
||||
this.processTextWithTemplate(resolveTextFromEditor(editor));
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
|
|
@ -56,4 +76,43 @@ export default class ReWritePlugin extends Plugin {
|
|||
this.activeQuickRecord = null;
|
||||
});
|
||||
}
|
||||
|
||||
private processTextWithTemplate(preResolved?: TextResolution): void {
|
||||
const source = preResolved ?? resolveActiveTextSource(this.app);
|
||||
if (!source) {
|
||||
new Notice('Open a Markdown note or select text to use this command.');
|
||||
return;
|
||||
}
|
||||
if (!source.text.trim()) {
|
||||
new Notice('Source text is empty.');
|
||||
return;
|
||||
}
|
||||
if (this.settings.templates.length === 0) {
|
||||
new Notice('Add a template in settings first.');
|
||||
return;
|
||||
}
|
||||
const previewText = source.scope === 'selection'
|
||||
? `Selection: ${source.text.length.toLocaleString()} chars`
|
||||
: `Whole note: ${source.text.length.toLocaleString()} chars`;
|
||||
new TemplatePickerModal({
|
||||
app: this.app,
|
||||
templates: this.settings.templates,
|
||||
defaultTemplateId: this.pickDefaultTemplateId(),
|
||||
previewText,
|
||||
onPick: (template) => {
|
||||
void runTextPipeline(this, template, source.text);
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private pickDefaultTemplateId(): string {
|
||||
const s = this.settings;
|
||||
if (s.lastUsedTemplateId && s.templates.some((t) => t.id === s.lastUsedTemplateId)) {
|
||||
return s.lastUsedTemplateId;
|
||||
}
|
||||
if (s.defaultTemplateId && s.templates.some((t) => t.id === s.defaultTemplateId)) {
|
||||
return s.defaultTemplateId;
|
||||
}
|
||||
return s.templates[0]?.id ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export type PipelineStage = 'transcribe' | 'cleanup' | 'insert';
|
|||
export type PipelineSource =
|
||||
| { kind: 'audio'; audio: Blob }
|
||||
| { kind: 'paste'; text: string }
|
||||
| { kind: 'webspeech'; transcript: string };
|
||||
| { kind: 'webspeech'; transcript: string }
|
||||
| { kind: 'text'; text: string };
|
||||
|
||||
export interface PipelineParams {
|
||||
app: App;
|
||||
|
|
@ -50,6 +51,7 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
|
|||
const source = params.source;
|
||||
switch (source.kind) {
|
||||
case 'paste':
|
||||
case 'text':
|
||||
return source.text;
|
||||
case 'webspeech':
|
||||
return source.transcript;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
|
|||
import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform';
|
||||
import { Recorder } from '../recorder';
|
||||
import { startWebSpeech, WebSpeechSession } from '../webspeech';
|
||||
import { isProfileConfigured, renderSetupCard } from './setup-card';
|
||||
import { EnvironmentProfile } from '../types';
|
||||
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
|
||||
import { resolveActiveTextSource } from './text-source';
|
||||
|
||||
export class ReWriteModal extends Modal {
|
||||
private templateId: string;
|
||||
private activeTab: 'record' | 'paste' = 'record';
|
||||
private activeTab: 'record' | 'paste' | 'fromNote' = 'record';
|
||||
private recorder: Recorder | null = null;
|
||||
private webSpeech: WebSpeechSession | null = null;
|
||||
private timerHandle: number | null = null;
|
||||
|
|
@ -51,22 +53,7 @@ export class ReWriteModal extends Modal {
|
|||
contentEl.createEl('h2', { text: 'ReWrite' });
|
||||
|
||||
const { kind, profile } = resolveActiveProfile(this.plugin.settings);
|
||||
if (!isProfileConfigured(profile)) {
|
||||
renderSetupCard({
|
||||
container: contentEl,
|
||||
profile,
|
||||
profileLabel: kind === 'desktop' ? 'Desktop' : 'Mobile',
|
||||
onSaved: async () => {
|
||||
await this.plugin.saveSettings();
|
||||
this.render();
|
||||
},
|
||||
onOpenSettings: () => {
|
||||
this.close();
|
||||
this.openSettingsTab();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profileLabel = kind === 'desktop' ? 'Desktop' : 'Mobile';
|
||||
|
||||
if (this.plugin.settings.templates.length === 0) {
|
||||
contentEl.createEl('p', {
|
||||
|
|
@ -83,6 +70,21 @@ export class ReWriteModal extends Modal {
|
|||
this.renderTemplateSelector(contentEl);
|
||||
this.renderTabBar(contentEl);
|
||||
const tabBody = contentEl.createDiv({ cls: 'rewrite-tab-body' });
|
||||
|
||||
if (this.activeTab === 'fromNote') {
|
||||
if (!isProfileConfiguredForText(profile)) {
|
||||
this.renderSetupCardInTab(tabBody, profile, profileLabel, 'text');
|
||||
return;
|
||||
}
|
||||
this.renderFromNoteTab(tabBody);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isProfileConfigured(profile)) {
|
||||
this.renderSetupCardInTab(tabBody, profile, profileLabel, 'voice');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTab === 'record') {
|
||||
this.renderRecordTab(tabBody, profile.transcriptionProvider === 'webspeech', profile.transcriptionConfig.language);
|
||||
} else {
|
||||
|
|
@ -90,6 +92,28 @@ export class ReWriteModal extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
private renderSetupCardInTab(
|
||||
parent: HTMLElement,
|
||||
profile: EnvironmentProfile,
|
||||
profileLabel: string,
|
||||
purpose: 'voice' | 'text',
|
||||
): void {
|
||||
renderSetupCard({
|
||||
container: parent,
|
||||
profile,
|
||||
profileLabel,
|
||||
purpose,
|
||||
onSaved: async () => {
|
||||
await this.plugin.saveSettings();
|
||||
this.render();
|
||||
},
|
||||
onOpenSettings: () => {
|
||||
this.close();
|
||||
this.openSettingsTab();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private renderTemplateSelector(parent: HTMLElement): void {
|
||||
const wrap = parent.createDiv({ cls: 'rewrite-template-row' });
|
||||
wrap.createEl('label', { text: 'Template' });
|
||||
|
|
@ -112,8 +136,10 @@ export class ReWriteModal extends Modal {
|
|||
const tabs = parent.createDiv({ cls: 'rewrite-tabs' });
|
||||
const record = tabs.createEl('button', { text: 'Record', cls: 'rewrite-tab' });
|
||||
const paste = tabs.createEl('button', { text: 'Paste', cls: 'rewrite-tab' });
|
||||
const fromNote = tabs.createEl('button', { text: 'From note', cls: 'rewrite-tab' });
|
||||
if (this.activeTab === 'record') record.addClass('is-active');
|
||||
else paste.addClass('is-active');
|
||||
else if (this.activeTab === 'paste') paste.addClass('is-active');
|
||||
else fromNote.addClass('is-active');
|
||||
record.addEventListener('click', () => {
|
||||
this.activeTab = 'record';
|
||||
this.render();
|
||||
|
|
@ -122,6 +148,10 @@ export class ReWriteModal extends Modal {
|
|||
this.activeTab = 'paste';
|
||||
this.render();
|
||||
});
|
||||
fromNote.addEventListener('click', () => {
|
||||
this.activeTab = 'fromNote';
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private renderRecordTab(parent: HTMLElement, isWebSpeech: boolean, language: string): void {
|
||||
|
|
@ -204,6 +234,34 @@ export class ReWriteModal extends Modal {
|
|||
textarea.focus();
|
||||
}
|
||||
|
||||
private renderFromNoteTab(parent: HTMLElement): void {
|
||||
const previewEl = parent.createEl('p', { cls: 'rewrite-from-note-preview' });
|
||||
const source = resolveActiveTextSource(this.app);
|
||||
if (!source) {
|
||||
previewEl.setText('No active Markdown note. Open a note (or select text) to use this.');
|
||||
} else if (source.scope === 'selection') {
|
||||
previewEl.setText(`Selection: ${source.text.length.toLocaleString()} chars`);
|
||||
} else {
|
||||
previewEl.setText(`Whole note: ${source.text.length.toLocaleString()} chars`);
|
||||
}
|
||||
|
||||
const button = parent.createEl('button', { text: 'Run', cls: 'mod-cta' });
|
||||
if (!source) button.disabled = true;
|
||||
button.addEventListener('click', () => {
|
||||
const fresh = resolveActiveTextSource(this.app);
|
||||
if (!fresh) {
|
||||
new Notice('Open a Markdown note or select text first.');
|
||||
return;
|
||||
}
|
||||
const trimmed = fresh.text.trim();
|
||||
if (!trimmed) {
|
||||
new Notice('Source text is empty.');
|
||||
return;
|
||||
}
|
||||
void this.execute({ kind: 'text', text: fresh.text });
|
||||
});
|
||||
}
|
||||
|
||||
private async beginCapture(
|
||||
isWebSpeech: boolean,
|
||||
language: string,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export function isProfileConfigured(profile: EnvironmentProfile): boolean {
|
|||
if (!profile.transcriptionConfig.apiKey) return false;
|
||||
if (tx === 'openai-compatible' && !profile.transcriptionConfig.baseUrl.trim()) return false;
|
||||
}
|
||||
return isProfileConfiguredForText(profile);
|
||||
}
|
||||
|
||||
export function isProfileConfiguredForText(profile: EnvironmentProfile): boolean {
|
||||
if (!profile.llmConfig.model) return false;
|
||||
if (!profile.llmConfig.apiKey) return false;
|
||||
if (profile.llmProvider === 'openai-compatible' && !profile.llmConfig.baseUrl.trim()) return false;
|
||||
|
|
@ -36,60 +40,66 @@ export interface SetupCardParams {
|
|||
container: HTMLElement;
|
||||
profile: EnvironmentProfile;
|
||||
profileLabel: string;
|
||||
purpose?: 'voice' | 'text';
|
||||
onSaved: () => Promise<void>;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function renderSetupCard(params: SetupCardParams): void {
|
||||
const { container, profile, profileLabel } = params;
|
||||
const purpose = params.purpose ?? 'voice';
|
||||
const card = container.createDiv({ cls: 'rewrite-setup-card' });
|
||||
card.createEl('h3', { text: 'Setup required' });
|
||||
card.createEl('p', {
|
||||
text: `Your ${profileLabel} profile needs a transcription provider and an LLM. Fill in the basics here or open settings for full configuration.`,
|
||||
text: purpose === 'text'
|
||||
? `Your ${profileLabel} profile needs an LLM provider to process text. Fill in the basics here or open settings.`
|
||||
: `Your ${profileLabel} profile needs a transcription provider and an LLM. Fill in the basics here or open settings for full configuration.`,
|
||||
});
|
||||
|
||||
new Setting(card)
|
||||
.setName('Transcription provider')
|
||||
.addDropdown((dd) => {
|
||||
for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label);
|
||||
dd.setValue(profile.transcriptionProvider);
|
||||
dd.onChange((v) => {
|
||||
profile.transcriptionProvider = v as TranscriptionProviderID;
|
||||
profile.transcriptionConfig.apiKey = '';
|
||||
container.empty();
|
||||
renderSetupCard(params);
|
||||
});
|
||||
});
|
||||
|
||||
if (profile.transcriptionProvider !== 'webspeech') {
|
||||
if (purpose === 'voice') {
|
||||
new Setting(card)
|
||||
.setName('Transcription model')
|
||||
.setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider))
|
||||
.addText((t) => {
|
||||
t.setValue(profile.transcriptionConfig.model);
|
||||
t.onChange((v) => {
|
||||
profile.transcriptionConfig.model = v;
|
||||
.setName('Transcription provider')
|
||||
.addDropdown((dd) => {
|
||||
for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label);
|
||||
dd.setValue(profile.transcriptionProvider);
|
||||
dd.onChange((v) => {
|
||||
profile.transcriptionProvider = v as TranscriptionProviderID;
|
||||
profile.transcriptionConfig.apiKey = '';
|
||||
container.empty();
|
||||
renderSetupCard(params);
|
||||
});
|
||||
});
|
||||
|
||||
if (profile.transcriptionProvider === 'openai-compatible') {
|
||||
if (profile.transcriptionProvider !== 'webspeech') {
|
||||
new Setting(card)
|
||||
.setName('Transcription base URL')
|
||||
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
|
||||
.setName('Transcription model')
|
||||
.setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider))
|
||||
.addText((t) => {
|
||||
t.setValue(profile.transcriptionConfig.baseUrl);
|
||||
t.setValue(profile.transcriptionConfig.model);
|
||||
t.onChange((v) => {
|
||||
profile.transcriptionConfig.baseUrl = v;
|
||||
profile.transcriptionConfig.model = v;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderApiKeyField(card, {
|
||||
label: 'Transcription API key',
|
||||
placeholder: 'Saved securely on this device',
|
||||
getValue: () => profile.transcriptionConfig.apiKey,
|
||||
setValue: (v) => { profile.transcriptionConfig.apiKey = v; },
|
||||
});
|
||||
if (profile.transcriptionProvider === 'openai-compatible') {
|
||||
new Setting(card)
|
||||
.setName('Transcription base URL')
|
||||
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
|
||||
.addText((t) => {
|
||||
t.setValue(profile.transcriptionConfig.baseUrl);
|
||||
t.onChange((v) => {
|
||||
profile.transcriptionConfig.baseUrl = v;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderApiKeyField(card, {
|
||||
label: 'Transcription API key',
|
||||
placeholder: 'Saved securely on this device',
|
||||
getValue: () => profile.transcriptionConfig.apiKey,
|
||||
setValue: (v) => { profile.transcriptionConfig.apiKey = v; },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new Setting(card)
|
||||
|
|
|
|||
50
src/ui/template-picker.ts
Normal file
50
src/ui/template-picker.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import { NoteTemplate } from '../types';
|
||||
|
||||
export interface TemplatePickerParams {
|
||||
app: App;
|
||||
templates: NoteTemplate[];
|
||||
defaultTemplateId: string;
|
||||
previewText: string;
|
||||
onPick: (template: NoteTemplate) => void;
|
||||
}
|
||||
|
||||
export class TemplatePickerModal extends Modal {
|
||||
constructor(private readonly params: TemplatePickerParams) {
|
||||
super(params.app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.modalEl.addClass('rewrite-modal');
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'Pick a template' });
|
||||
if (this.params.previewText) {
|
||||
contentEl.createEl('p', {
|
||||
text: this.params.previewText,
|
||||
cls: 'rewrite-template-picker-preview',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.params.templates.length === 0) {
|
||||
contentEl.createEl('p', { text: 'No templates configured. Add one in settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const list = contentEl.createDiv({ cls: 'rewrite-template-picker-list' });
|
||||
for (const template of this.params.templates) {
|
||||
const item = list.createEl('button', {
|
||||
text: template.name || '(unnamed)',
|
||||
cls: 'rewrite-template-picker-item',
|
||||
});
|
||||
if (template.id === this.params.defaultTemplateId) item.addClass('mod-cta');
|
||||
item.addEventListener('click', () => {
|
||||
this.close();
|
||||
this.params.onPick(template);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
56
src/ui/text-source.ts
Normal file
56
src/ui/text-source.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { App, Editor, MarkdownView, Notice } from 'obsidian';
|
||||
import type ReWritePlugin from '../main';
|
||||
import { NoteTemplate } from '../types';
|
||||
import { resolveActiveProfile } from '../platform';
|
||||
import { runPipeline } from '../pipeline';
|
||||
import { isProfileConfiguredForText } from './setup-card';
|
||||
import { ReWriteModal } from './modal';
|
||||
|
||||
export interface TextResolution {
|
||||
text: string;
|
||||
scope: 'selection' | 'note';
|
||||
}
|
||||
|
||||
export function resolveTextFromEditor(editor: Editor): TextResolution {
|
||||
const selection = editor.getSelection();
|
||||
if (selection) return { text: selection, scope: 'selection' };
|
||||
return { text: editor.getValue(), scope: 'note' };
|
||||
}
|
||||
|
||||
export function resolveActiveTextSource(app: App): TextResolution | null {
|
||||
const view = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!view) return null;
|
||||
return resolveTextFromEditor(view.editor);
|
||||
}
|
||||
|
||||
export async function runTextPipeline(
|
||||
plugin: ReWritePlugin,
|
||||
template: NoteTemplate,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const settings = plugin.settings;
|
||||
const { profile } = resolveActiveProfile(settings);
|
||||
if (!isProfileConfiguredForText(profile)) {
|
||||
new Notice('ReWrite: configure an LLM provider before processing text.');
|
||||
new ReWriteModal(plugin.app, plugin).open();
|
||||
return;
|
||||
}
|
||||
const progress = new Notice('ReWrite: processing text...', 0);
|
||||
try {
|
||||
await runPipeline({
|
||||
app: plugin.app,
|
||||
settings,
|
||||
profile,
|
||||
template,
|
||||
source: { kind: 'text', text },
|
||||
});
|
||||
progress.hide();
|
||||
plugin.settings.lastUsedTemplateId = template.id;
|
||||
await plugin.saveSettings();
|
||||
new Notice('ReWrite complete.');
|
||||
} catch (e) {
|
||||
progress.hide();
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
new Notice(`ReWrite: ${message}`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue