From 0208d7bf284cec6e6f2c0326153153654f30e615 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Tue, 6 Jan 2026 21:38:42 -0600 Subject: [PATCH 01/22] split up settings by tab --- src/main.ts | 2 +- src/providers/model-provider-factory.ts | 2 +- src/providers/openai-provider.ts | 2 +- src/settings.ts | 577 ------------------------ src/settings/index.ts | 107 +++++ src/settings/prompts.ts | 185 ++++++++ src/settings/providers.ts | 327 ++++++++++++++ src/settings/transcription.ts | 177 ++++++++ src/settings/types.ts | 103 +++++ styles.css | 32 ++ 10 files changed, 934 insertions(+), 580 deletions(-) delete mode 100644 src/settings.ts create mode 100644 src/settings/index.ts create mode 100644 src/settings/prompts.ts create mode 100644 src/settings/providers.ts create mode 100644 src/settings/transcription.ts create mode 100644 src/settings/types.ts diff --git a/src/main.ts b/src/main.ts index c0920c3..b93576a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { Notice, Plugin } from 'obsidian'; -import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings"; +import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { createTranscriptionProvider } from "./providers"; import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; diff --git a/src/providers/model-provider-factory.ts b/src/providers/model-provider-factory.ts index 5a15770..5f894bb 100644 --- a/src/providers/model-provider-factory.ts +++ b/src/providers/model-provider-factory.ts @@ -1,4 +1,4 @@ -import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT } from '../settings'; +import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT } from '../settings/index'; import { ModelProvider, ModelProviderConfig } from './types'; import { AzureOpenAIModelProvider } from './azure-openai-provider'; import { OpenAIModelProvider } from './openai-provider'; diff --git a/src/providers/openai-provider.ts b/src/providers/openai-provider.ts index 4bf1031..3f89c25 100644 --- a/src/providers/openai-provider.ts +++ b/src/providers/openai-provider.ts @@ -1,5 +1,5 @@ import { ModelProviderConfig } from './types'; -import { AIProviderType, DEFAULT_OPENAI_ENDPOINT } from '../settings'; +import { AIProviderType, DEFAULT_OPENAI_ENDPOINT } from '../settings/index'; import { BaseProvider } from './base-model-provider'; /** diff --git a/src/settings.ts b/src/settings.ts deleted file mode 100644 index 8ae1ba5..0000000 --- a/src/settings.ts +++ /dev/null @@ -1,577 +0,0 @@ -import {App, PluginSettingTab, Setting, setIcon} from "obsidian"; -import AIToolboxPlugin from "./main"; - -/** - * Default OpenAI API endpoint - */ -export const DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com/v1'; - -/** - * Supported AI provider types - */ -export type AIProviderType = 'azure-openai' | 'openai' | 'anthropic'; - -/** - * Model configuration for a provider - */ -export interface AIModelConfig { - id: string; - name: string; - deploymentName: string; // Used by Azure, can be same as model ID for other providers - modelId: string; - supportsChat?: boolean; // Whether this model supports chat/conversation - supportsTranscription?: boolean; // Whether this model supports audio transcription -} - -/** - * AI Provider configuration - */ -export interface AIProviderConfig { - id: string; - name: string; - type: AIProviderType; - endpoint: string; - apiKey: string; - models: AIModelConfig[]; -} - -/** - * Reference to a specific provider and model for a feature - */ -export interface ProviderModelSelection { - providerId: string; - modelId: string; -} - -export interface AIToolboxSettings { - impersonateBrowser: string; - ytdlpLocation: string; - ffmpegLocation: string; - outputDirectory: string; - keepVideo: boolean; - includeTimestamps: boolean; - transcriptionLanguage: string; - outputFolder: string; - // New provider-based settings - providers: AIProviderConfig[]; - transcriptionProvider: ProviderModelSelection | null; -} - -/** - * Generate a unique ID for providers and models - */ -export function generateId(): string { - return Math.random().toString(36).substring(2, 11); -} - -export const DEFAULT_SETTINGS: AIToolboxSettings = { - impersonateBrowser: 'chrome', - ytdlpLocation: '', - ffmpegLocation: '', - outputDirectory: '', - keepVideo: false, - includeTimestamps: true, - transcriptionLanguage: '', - outputFolder: '', - providers: [], - transcriptionProvider: null -} - -export class AIToolboxSettingTab extends PluginSettingTab { - plugin: AIToolboxPlugin; - private activeTab: 'transcription' | 'providers' = 'providers'; - // Track IDs that should start expanded on next render (cleared after use) - private expandOnNextRender: { providerId?: string; modelId?: string } = {}; - - constructor(app: App, plugin: AIToolboxPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const {containerEl} = this; - - containerEl.empty(); - - const tabContainer = containerEl.createDiv('settings-tab-container'); - const tabHeader = tabContainer.createDiv('settings-tab-header'); - const tabContent = tabContainer.createDiv('settings-tab-content'); - - const providersTabButton = tabHeader.createEl('button', { - text: 'Providers', - cls: 'settings-tab-button' - }); - - const transcriptionTabButton = tabHeader.createEl('button', { - text: 'Transcription', - cls: 'settings-tab-button' - }); - - const showTab = (tab: 'transcription' | 'providers') => { - this.activeTab = tab; - providersTabButton.classList.toggle('active', tab === 'providers'); - transcriptionTabButton.classList.toggle('active', tab === 'transcription'); - tabContent.empty(); - - if (tab === 'providers') { - this.displayProvidersSettings(tabContent); - } else if (tab === 'transcription') { - this.displayTranscriptionSettings(tabContent); - } else { - const _exhaustiveCheck: never = tab; - console.error(`Unknown settings tab: ${String(_exhaustiveCheck)}`); - } - }; - - providersTabButton.addEventListener('click', () => showTab('providers')); - transcriptionTabButton.addEventListener('click', () => showTab('transcription')); - - showTab(this.activeTab); - } - - private displayProvidersSettings(containerEl: HTMLElement): void { - // Add provider button - new Setting(containerEl) - .setName('AI providers') - .setDesc('Configure AI providers for transcription and other features') - .addButton(button => button - .setButtonText('Add provider') - .setCta() - .onClick(async () => { - const newProvider: AIProviderConfig = { - id: generateId(), - name: 'New provider', - type: 'azure-openai', - endpoint: '', - apiKey: '', - models: [] - }; - this.plugin.settings.providers.push(newProvider); - await this.plugin.saveSettings(); - this.display(); - })); - - // Display each provider - for (const provider of this.plugin.settings.providers) { - this.displayProviderSettings(containerEl, provider); - } - } - - private displayProviderSettings(containerEl: HTMLElement, provider: AIProviderConfig): void { - const providerContainer = containerEl.createDiv('provider-container'); - - // Check if this provider should be expanded (has a newly added model) - const shouldExpand = this.expandOnNextRender.providerId === provider.id; - - // Collapsible content container - const contentContainer = providerContainer.createDiv(`provider-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - - // Provider header with collapse toggle, name and delete button - const headerSetting = new Setting(providerContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`) - .setHeading() - .addButton(button => button - .setIcon('trash') - .setTooltip('Delete provider') - .onClick(async () => { - const index = this.plugin.settings.providers.findIndex(p => p.id === provider.id); - if (index !== -1) { - this.plugin.settings.providers.splice(index, 1); - // Clear transcription provider if it was using this provider - if (this.plugin.settings.transcriptionProvider?.providerId === provider.id) { - this.plugin.settings.transcriptionProvider = null; - } - await this.plugin.saveSettings(); - this.display(); - } - })); - - headerSetting.settingEl.addClass('provider-header'); - - const toggleCollapse = () => { - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - contentContainer.classList.toggle('is-collapsed', !isCollapsed); - contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`); - }; - - headerSetting.settingEl.addEventListener('click', (e) => { - // Don't toggle if clicking on the delete button - if (!(e.target as HTMLElement).closest('button')) { - toggleCollapse(); - } - }); - - // Move content container after header - providerContainer.appendChild(contentContainer); - - // Provider name - new Setting(contentContainer) - .setName('Name') - .setDesc('Display name for this provider') - .addText(text => text - .setValue(provider.name) - .onChange(async (value) => { - provider.name = value; - const isExpanded = contentContainer.classList.contains('is-expanded'); - headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed provider'}`); - await this.plugin.saveSettings(); - })); - - // Provider type - new Setting(contentContainer) - .setName('Type') - .setDesc('AI provider type') - .addDropdown(dropdown => dropdown - .addOption('openai', 'OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addOption('azure-openai', 'Azure OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .setValue(provider.type) - .onChange(async (value) => { - provider.type = value as AIProviderType; - // Keep provider expanded after type change - this.expandOnNextRender = { providerId: provider.id }; - await this.plugin.saveSettings(); - this.display(); - })); - - // API Key - new Setting(contentContainer) - .setName('API key') - .setDesc('Your API key for this provider') - .addText(text => { - text.inputEl.type = 'password'; - text.setPlaceholder('Enter your API key') - .setValue(provider.apiKey) - .onChange(async (value) => { - provider.apiKey = value; - await this.plugin.saveSettings(); - }); - }); - - // Endpoint (optional for OpenAI) - const isOpenAI = provider.type === 'openai'; - const endpointDesc = provider.type === 'azure-openai' - ? 'Your Azure OpenAI resource endpoint (e.g., https://your-resource.openai.azure.com)' - : isOpenAI - ? 'API endpoint URL (optional, defaults to https://api.openai.com/v1)' - : 'API endpoint URL'; - new Setting(contentContainer) - .setName(isOpenAI ? 'Endpoint (optional)' : 'Endpoint') - .setDesc(endpointDesc) - .addText(text => text - .setPlaceholder(provider.type === 'azure-openai' ? 'https://your-resource.openai.azure.com' : 'https://api.openai.com/v1') - .setValue(provider.endpoint) - .onChange(async (value) => { - provider.endpoint = value; - await this.plugin.saveSettings(); - })); - - // Models section - new Setting(contentContainer) - .setName('Models') - .setDesc('Configure available models for this provider') - .addButton(button => button - .setButtonText('Add model') - .onClick(async () => { - const newModel: AIModelConfig = { - id: generateId(), - name: 'New model', - deploymentName: '', - modelId: '' - }; - provider.models.push(newModel); - // Keep provider expanded and expand the new model - this.expandOnNextRender = { providerId: provider.id, modelId: newModel.id }; - await this.plugin.saveSettings(); - this.display(); - })); - - // Display models - for (const model of provider.models) { - this.displayModelSettings(contentContainer, provider, model); - } - - // Clear the expand state after rendering all models for this provider - if (this.expandOnNextRender.providerId === provider.id) { - this.expandOnNextRender = {}; - } - } - - private displayModelSettings(containerEl: HTMLElement, provider: AIProviderConfig, model: AIModelConfig): void { - const modelContainer = containerEl.createDiv('model-container'); - - // Check if this model should be expanded (newly added) - const shouldExpand = this.expandOnNextRender.modelId === model.id; - - // Collapsible content container - const contentContainer = modelContainer.createDiv(`model-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - - const modelDisplayName = model.name || 'Unnamed model'; - - // Model header with collapse toggle and delete button - const headerSetting = new Setting(modelContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${modelDisplayName}`) - .addButton(button => button - .setIcon('trash') - .setTooltip('Delete model') - .onClick(async () => { - const index = provider.models.findIndex(m => m.id === model.id); - if (index !== -1) { - provider.models.splice(index, 1); - // Clear transcription provider if it was using this model - if (this.plugin.settings.transcriptionProvider?.modelId === model.id) { - this.plugin.settings.transcriptionProvider = null; - } - // Keep provider expanded after deletion - this.expandOnNextRender = { providerId: provider.id }; - await this.plugin.saveSettings(); - this.display(); - } - })); - - headerSetting.settingEl.addClass('model-header'); - - const toggleCollapse = () => { - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - contentContainer.classList.toggle('is-collapsed', !isCollapsed); - contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${model.name || 'Unnamed model'}`); - }; - - headerSetting.settingEl.addEventListener('click', (e) => { - // Don't toggle if clicking on the delete button - if (!(e.target as HTMLElement).closest('button')) { - toggleCollapse(); - } - }); - - // Move content container after header - modelContainer.appendChild(contentContainer); - - // Model name - new Setting(contentContainer) - .setName('Display name') - .addText(text => text - .setPlaceholder('Whisper') - .setValue(model.name) - .onChange(async (value) => { - model.name = value; - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - headerSetting.setName(`${isCollapsed ? '▸' : '▾'} ${value || 'Unnamed model'}`); - await this.plugin.saveSettings(); - })); - - // Deployment name (for Azure) - if (provider.type === 'azure-openai') { - new Setting(contentContainer) - .setName('Deployment name') - .setDesc('The deployment name in Azure OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addText(text => text - .setPlaceholder('whisper-1') // eslint-disable-line obsidianmd/ui/sentence-case -- model identifier - .setValue(model.deploymentName) - .onChange(async (value) => { - model.deploymentName = value; - await this.plugin.saveSettings(); - })); - } - - // Model ID - new Setting(contentContainer) - .setName('Model ID') - .setDesc('The model identifier') - .addText(text => text - .setPlaceholder('whisper-1') // eslint-disable-line obsidianmd/ui/sentence-case -- model identifier - .setValue(model.modelId) - .onChange(async (value) => { - model.modelId = value; - await this.plugin.saveSettings(); - })); - - // Model capabilities section - const capabilitiesContainer = contentContainer.createDiv('model-capabilities'); - - // Chat capability toggle - const chatSetting = new Setting(capabilitiesContainer) - .addToggle(toggle => toggle - .setValue(model.supportsChat ?? false) - .onChange(async (value) => { - model.supportsChat = value; - await this.plugin.saveSettings(); - })); - const chatNameEl = chatSetting.nameEl; - const chatIcon = chatNameEl.createSpan({ cls: 'model-capability-icon' }); - setIcon(chatIcon, 'message-circle'); - chatNameEl.appendText(' Chat'); - - // Transcription capability toggle - const transcriptionSetting = new Setting(capabilitiesContainer) - .addToggle(toggle => toggle - .setValue(model.supportsTranscription ?? false) - .onChange(async (value) => { - model.supportsTranscription = value; - await this.plugin.saveSettings(); - })); - const transcriptionNameEl = transcriptionSetting.nameEl; - const transcriptionIcon = transcriptionNameEl.createSpan({ cls: 'model-capability-icon' }); - setIcon(transcriptionIcon, 'audio-lines'); - transcriptionNameEl.appendText(' Transcription'); - } - - private displayTranscriptionProviderSelection(containerEl: HTMLElement): void { - const providers = this.plugin.settings.providers; - const currentSelection = this.plugin.settings.transcriptionProvider; - - // Build options for provider/model dropdown (only models that support transcription) - const options: Record = { '': 'Select a provider and model' }; - for (const provider of providers) { - for (const model of provider.models) { - if (model.supportsTranscription) { - const key = `${provider.id}:${model.id}`; - options[key] = `${provider.name} - ${model.name}`; - } - } - } - - const currentValue = currentSelection - ? `${currentSelection.providerId}:${currentSelection.modelId}` - : ''; - - new Setting(containerEl) - .setName('Transcription provider') - .setDesc('Select the provider and model to use for audio transcription') - .addDropdown(dropdown => dropdown - .addOptions(options) - .setValue(currentValue) - .onChange(async (value) => { - if (value === '') { - this.plugin.settings.transcriptionProvider = null; - } else { - const parts = value.split(':'); - if (parts.length === 2 && parts[0] && parts[1]) { - this.plugin.settings.transcriptionProvider = { - providerId: parts[0], - modelId: parts[1] - }; - } - } - await this.plugin.saveSettings(); - })); - } - - private displayTranscriptionSettings(containerEl: HTMLElement): void { - // Transcription provider selection at the top - this.displayTranscriptionProviderSelection(containerEl); - - new Setting(containerEl) - .setName('Browser to impersonate') - .setDesc('Browser to impersonate when extracting audio for transcription') - .addDropdown(dropdown => dropdown - .addOption('chrome', 'Chrome') - .addOption('edge', 'Edge') - .addOption('safari', 'Safari') - .addOption('firefox', 'Firefox') - .addOption('brave', 'Brave') - .addOption('chromium', 'Chromium') - .setValue(this.plugin.settings.impersonateBrowser) - .onChange(async (value) => { - this.plugin.settings.impersonateBrowser = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Language') - .setDesc('Language code for transcription (e.g., en, es, fr). Leave empty for automatic detection') - .addText(text => text - .setPlaceholder('') - .setValue(this.plugin.settings.transcriptionLanguage) - .onChange(async (value) => { - this.plugin.settings.transcriptionLanguage = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Include timestamps') - .setDesc('Include timestamps in transcription notes for each chunk of text') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.includeTimestamps) - .onChange(async (value) => { - this.plugin.settings.includeTimestamps = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Notes folder') - .setDesc('Folder where transcription notes will be created (leave empty for vault root)') - .addText(text => text - .setValue(this.plugin.settings.outputFolder) - .onChange(async (value) => { - this.plugin.settings.outputFolder = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Keep video file') - .setDesc('Keep the original video file after extracting audio for transcription') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.keepVideo) - .onChange(async (value) => { - this.plugin.settings.keepVideo = value; - await this.plugin.saveSettings(); - this.display(); - })); - - if (this.plugin.settings.keepVideo) { - new Setting(containerEl) - .setName('Output directory') - .setDesc('Directory where video files will be saved (leave empty to use ~/Videos/Obsidian)') // eslint-disable-line obsidianmd/ui/sentence-case -- path example - .addText(text => text - .setPlaceholder('~/Videos/Obsidian') // eslint-disable-line obsidianmd/ui/sentence-case -- path example - .setValue(this.plugin.settings.outputDirectory) - .onChange(async (value) => { - this.plugin.settings.outputDirectory = value; - await this.plugin.saveSettings(); - })); - } - - const advancedContainer = containerEl.createDiv('settings-advanced-container is-collapsed'); - - const advancedSetting = new Setting(containerEl) - .setName('▸ Advanced') // eslint-disable-line obsidianmd/ui/sentence-case - .setHeading(); - - advancedSetting.settingEl.addClass('settings-advanced-heading'); - - const toggleAdvanced = () => { - const isCollapsed = advancedContainer.classList.contains('is-collapsed'); - advancedContainer.classList.toggle('is-collapsed', !isCollapsed); - advancedContainer.classList.toggle('is-expanded', isCollapsed); - advancedSetting.setName(`${isCollapsed ? '▾' : '▸'} Advanced`); - }; - - advancedSetting.settingEl.addEventListener('click', toggleAdvanced); - - // Move the advancedContainer after the heading - containerEl.appendChild(advancedContainer); - - new Setting(advancedContainer) - .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .setDesc('Path to yt-dlp binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addText(text => text - .setValue(this.plugin.settings.ytdlpLocation) - .onChange(async (value) => { - this.plugin.settings.ytdlpLocation = value; - await this.plugin.saveSettings(); - })); - - new Setting(advancedContainer) - .setName('FFmpeg path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .setDesc('Path to FFmpeg binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addText(text => text - .setValue(this.plugin.settings.ffmpegLocation) - .onChange(async (value) => { - this.plugin.settings.ffmpegLocation = value; - await this.plugin.saveSettings(); - })); - - } -} diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 0000000..9196294 --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1,107 @@ +import { App, PluginSettingTab } from "obsidian"; +import AIToolboxPlugin from "../main"; +import { SettingsTabType, ExpandOnNextRenderState } from "./types"; +import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; +import { displayPromptsSettings, PromptSettingsCallbacks } from "./prompts"; +import { displayTranscriptionSettings, TranscriptionSettingsCallbacks } from "./transcription"; + +// Re-export all types and constants from types.ts for backward compatibility +export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS } from "./types"; +export type { + AIProviderType, + AIModelConfig, + AIProviderConfig, + ProviderModelSelection, + PromptConfig, + AIToolboxSettings, + SettingsTabType, + ExpandOnNextRenderState +} from "./types"; + +export class AIToolboxSettingTab extends PluginSettingTab { + plugin: AIToolboxPlugin; + private activeTab: SettingsTabType = 'providers'; + // Track IDs that should start expanded on next render (cleared after use) + private expandOnNextRender: ExpandOnNextRenderState = {}; + + constructor(app: App, plugin: AIToolboxPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + + containerEl.empty(); + + const tabContainer = containerEl.createDiv('settings-tab-container'); + const tabHeader = tabContainer.createDiv('settings-tab-header'); + const tabContent = tabContainer.createDiv('settings-tab-content'); + + const providersTabButton = tabHeader.createEl('button', { + text: 'Providers', + cls: 'settings-tab-button' + }); + + const promptsTabButton = tabHeader.createEl('button', { + text: 'Prompts', + cls: 'settings-tab-button' + }); + + const transcriptionTabButton = tabHeader.createEl('button', { + text: 'Transcription', + cls: 'settings-tab-button' + }); + + const showTab = (tab: SettingsTabType) => { + this.activeTab = tab; + providersTabButton.classList.toggle('active', tab === 'providers'); + promptsTabButton.classList.toggle('active', tab === 'prompts'); + transcriptionTabButton.classList.toggle('active', tab === 'transcription'); + tabContent.empty(); + + if (tab === 'providers') { + this.displayProvidersTab(tabContent); + } else if (tab === 'prompts') { + this.displayPromptsTab(tabContent); + } else if (tab === 'transcription') { + this.displayTranscriptionTab(tabContent); + } else { + const _exhaustiveCheck: never = tab; + console.error(`Unknown settings tab: ${String(_exhaustiveCheck)}`); + } + }; + + providersTabButton.addEventListener('click', () => showTab('providers')); + promptsTabButton.addEventListener('click', () => showTab('prompts')); + transcriptionTabButton.addEventListener('click', () => showTab('transcription')); + + showTab(this.activeTab); + } + + private displayProvidersTab(containerEl: HTMLElement): void { + const callbacks: ProviderSettingsCallbacks = { + getExpandState: () => this.expandOnNextRender, + setExpandState: (state) => { this.expandOnNextRender = state; }, + refresh: () => this.display() + }; + displayProvidersSettings(containerEl, this.plugin, callbacks); + } + + private displayPromptsTab(containerEl: HTMLElement): void { + const callbacks: PromptSettingsCallbacks = { + getExpandState: () => this.expandOnNextRender, + setExpandState: (state) => { this.expandOnNextRender = state; }, + refresh: () => this.display() + }; + displayPromptsSettings(containerEl, this.plugin, callbacks); + } + + private displayTranscriptionTab(containerEl: HTMLElement): void { + const callbacks: TranscriptionSettingsCallbacks = { + refresh: () => this.display() + }; + displayTranscriptionSettings(containerEl, this.plugin, callbacks); + } +} + diff --git a/src/settings/prompts.ts b/src/settings/prompts.ts new file mode 100644 index 0000000..3188fb0 --- /dev/null +++ b/src/settings/prompts.ts @@ -0,0 +1,185 @@ +import { Setting } from "obsidian"; +import AIToolboxPlugin from "../main"; +import { + PromptConfig, + ExpandOnNextRenderState, + generateId +} from "./types"; + +/** + * Callbacks for the prompts settings tab to communicate with the main settings tab + */ +export interface PromptSettingsCallbacks { + getExpandState: () => ExpandOnNextRenderState; + setExpandState: (state: ExpandOnNextRenderState) => void; + refresh: () => void; +} + +/** + * Display the prompts settings tab content + */ +export function displayPromptsSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + callbacks: PromptSettingsCallbacks +): void { + // Add prompt button + new Setting(containerEl) + .setName('Custom prompts') + .setDesc('Configure custom prompts to use with your AI providers') + .addButton(button => button + .setButtonText('Add prompt') + .setCta() + .onClick(async () => { + const newPrompt: PromptConfig = { + id: generateId(), + name: 'New prompt', + promptText: '', + provider: null + }; + plugin.settings.prompts.push(newPrompt); + // Expand the newly created prompt + callbacks.setExpandState({ promptId: newPrompt.id }); + await plugin.saveSettings(); + callbacks.refresh(); + })); + + // Display each prompt + for (const prompt of plugin.settings.prompts) { + displayPromptSettings(containerEl, plugin, prompt, callbacks); + } +} + +function displayPromptSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + prompt: PromptConfig, + callbacks: PromptSettingsCallbacks +): void { + const promptContainer = containerEl.createDiv('prompt-container'); + const expandState = callbacks.getExpandState(); + + // Check if this prompt should be expanded (newly added) + const shouldExpand = expandState.promptId === prompt.id; + + // Collapsible content container + const contentContainer = promptContainer.createDiv(`prompt-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); + + // Prompt header with collapse toggle, name and delete button + const headerSetting = new Setting(promptContainer) + .setName(`${shouldExpand ? '▾' : '▸'} ${prompt.name || 'Unnamed prompt'}`) + .setHeading() + .addButton(button => button + .setIcon('trash') + .setTooltip('Delete prompt') + .onClick(async () => { + const index = plugin.settings.prompts.findIndex(p => p.id === prompt.id); + if (index !== -1) { + plugin.settings.prompts.splice(index, 1); + await plugin.saveSettings(); + callbacks.refresh(); + } + })); + + headerSetting.settingEl.addClass('prompt-header'); + + const toggleCollapse = () => { + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + contentContainer.classList.toggle('is-collapsed', !isCollapsed); + contentContainer.classList.toggle('is-expanded', isCollapsed); + headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${prompt.name || 'Unnamed prompt'}`); + }; + + headerSetting.settingEl.addEventListener('click', (e) => { + // Don't toggle if clicking on the delete button + if (!(e.target as HTMLElement).closest('button')) { + toggleCollapse(); + } + }); + + // Move content container after header + promptContainer.appendChild(contentContainer); + + // Prompt name + new Setting(contentContainer) + .setName('Name') + .setDesc('Display name for this prompt') + .addText(text => text + .setValue(prompt.name) + .onChange(async (value) => { + prompt.name = value; + const isExpanded = contentContainer.classList.contains('is-expanded'); + headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed prompt'}`); + await plugin.saveSettings(); + })); + + // Provider/Model selection (only models that support chat) + displayPromptProviderSelection(contentContainer, plugin, prompt); + + // Prompt text + new Setting(contentContainer) + .setName('Prompt text') + .setDesc('The prompt text to send to the AI model') + .addTextArea(textArea => { + textArea + .setPlaceholder('Enter your prompt text here...') + .setValue(prompt.promptText) + .onChange(async (value) => { + prompt.promptText = value; + await plugin.saveSettings(); + }); + textArea.inputEl.rows = 6; + textArea.inputEl.addClass('prompt-textarea'); + }); + + // Clear the expand state after rendering this prompt + if (expandState.promptId === prompt.id) { + callbacks.setExpandState({}); + } +} + +function displayPromptProviderSelection( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + prompt: PromptConfig +): void { + const providers = plugin.settings.providers; + const currentSelection = prompt.provider; + + // Build options for provider/model dropdown (only models that support chat) + const options: Record = { '': 'Select a provider and model' }; + for (const provider of providers) { + for (const model of provider.models) { + if (model.supportsChat) { + const key = `${provider.id}:${model.id}`; + options[key] = `${provider.name} - ${model.name}`; + } + } + } + + const currentValue = currentSelection + ? `${currentSelection.providerId}:${currentSelection.modelId}` + : ''; + + new Setting(containerEl) + .setName('Provider') + .setDesc('Select the provider and model to use for this prompt') + .addDropdown(dropdown => dropdown + .addOptions(options) + .setValue(currentValue) + .onChange(async (value) => { + if (value === '') { + prompt.provider = null; + } else { + const parts = value.split(':'); + if (parts.length === 2 && parts[0] && parts[1]) { + prompt.provider = { + providerId: parts[0], + modelId: parts[1] + }; + } + } + await plugin.saveSettings(); + })); +} + diff --git a/src/settings/providers.ts b/src/settings/providers.ts new file mode 100644 index 0000000..baced6c --- /dev/null +++ b/src/settings/providers.ts @@ -0,0 +1,327 @@ +import { Setting, setIcon } from "obsidian"; +import AIToolboxPlugin from "../main"; +import { + AIProviderConfig, + AIProviderType, + AIModelConfig, + ExpandOnNextRenderState, + generateId +} from "./types"; + +/** + * Callbacks for the provider settings tab to communicate with the main settings tab + */ +export interface ProviderSettingsCallbacks { + getExpandState: () => ExpandOnNextRenderState; + setExpandState: (state: ExpandOnNextRenderState) => void; + refresh: () => void; +} + +/** + * Display the providers settings tab content + */ +export function displayProvidersSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + callbacks: ProviderSettingsCallbacks +): void { + // Add provider button + new Setting(containerEl) + .setName('AI providers') + .setDesc('Configure AI providers for transcription and other features') + .addButton(button => button + .setButtonText('Add provider') + .setCta() + .onClick(async () => { + const newProvider: AIProviderConfig = { + id: generateId(), + name: 'New provider', + type: 'azure-openai', + endpoint: '', + apiKey: '', + models: [] + }; + plugin.settings.providers.push(newProvider); + await plugin.saveSettings(); + callbacks.refresh(); + })); + + // Display each provider + for (const provider of plugin.settings.providers) { + displayProviderSettings(containerEl, plugin, provider, callbacks); + } +} + +function displayProviderSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + provider: AIProviderConfig, + callbacks: ProviderSettingsCallbacks +): void { + const providerContainer = containerEl.createDiv('provider-container'); + const expandState = callbacks.getExpandState(); + + // Check if this provider should be expanded (has a newly added model) + const shouldExpand = expandState.providerId === provider.id; + + // Collapsible content container + const contentContainer = providerContainer.createDiv(`provider-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); + + // Provider header with collapse toggle, name and delete button + const headerSetting = new Setting(providerContainer) + .setName(`${shouldExpand ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`) + .setHeading() + .addButton(button => button + .setIcon('trash') + .setTooltip('Delete provider') + .onClick(async () => { + const index = plugin.settings.providers.findIndex(p => p.id === provider.id); + if (index !== -1) { + plugin.settings.providers.splice(index, 1); + // Clear transcription provider if it was using this provider + if (plugin.settings.transcriptionProvider?.providerId === provider.id) { + plugin.settings.transcriptionProvider = null; + } + await plugin.saveSettings(); + callbacks.refresh(); + } + })); + + headerSetting.settingEl.addClass('provider-header'); + + const toggleCollapse = () => { + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + contentContainer.classList.toggle('is-collapsed', !isCollapsed); + contentContainer.classList.toggle('is-expanded', isCollapsed); + headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`); + }; + + headerSetting.settingEl.addEventListener('click', (e) => { + // Don't toggle if clicking on the delete button + if (!(e.target as HTMLElement).closest('button')) { + toggleCollapse(); + } + }); + + // Move content container after header + providerContainer.appendChild(contentContainer); + + // Provider name + new Setting(contentContainer) + .setName('Name') + .setDesc('Display name for this provider') + .addText(text => text + .setValue(provider.name) + .onChange(async (value) => { + provider.name = value; + const isExpanded = contentContainer.classList.contains('is-expanded'); + headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed provider'}`); + await plugin.saveSettings(); + })); + + // Provider type + new Setting(contentContainer) + .setName('Type') + .setDesc('AI provider type') + .addDropdown(dropdown => dropdown + .addOption('openai', 'OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addOption('azure-openai', 'Azure OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .setValue(provider.type) + .onChange(async (value) => { + provider.type = value as AIProviderType; + // Keep provider expanded after type change + callbacks.setExpandState({ providerId: provider.id }); + await plugin.saveSettings(); + callbacks.refresh(); + })); + + // API Key + new Setting(contentContainer) + .setName('API key') + .setDesc('Your API key for this provider') + .addText(text => { + text.inputEl.type = 'password'; + text.setPlaceholder('Enter your API key') + .setValue(provider.apiKey) + .onChange(async (value) => { + provider.apiKey = value; + await plugin.saveSettings(); + }); + }); + + // Endpoint (optional for OpenAI) + const isOpenAI = provider.type === 'openai'; + const endpointDesc = provider.type === 'azure-openai' + ? 'Your Azure OpenAI resource endpoint (e.g., https://your-resource.openai.azure.com)' + : isOpenAI + ? 'API endpoint URL (optional, defaults to https://api.openai.com/v1)' + : 'API endpoint URL'; + new Setting(contentContainer) + .setName(isOpenAI ? 'Endpoint (optional)' : 'Endpoint') + .setDesc(endpointDesc) + .addText(text => text + .setPlaceholder(provider.type === 'azure-openai' ? 'https://your-resource.openai.azure.com' : 'https://api.openai.com/v1') + .setValue(provider.endpoint) + .onChange(async (value) => { + provider.endpoint = value; + await plugin.saveSettings(); + })); + + // Models section + new Setting(contentContainer) + .setName('Models') + .setDesc('Configure available models for this provider') + .addButton(button => button + .setButtonText('Add model') + .onClick(async () => { + const newModel: AIModelConfig = { + id: generateId(), + name: 'New model', + deploymentName: '', + modelId: '' + }; + provider.models.push(newModel); + // Keep provider expanded and expand the new model + callbacks.setExpandState({ providerId: provider.id, modelId: newModel.id }); + await plugin.saveSettings(); + callbacks.refresh(); + })); + + // Display models + for (const model of provider.models) { + displayModelSettings(contentContainer, plugin, provider, model, callbacks); + } + + // Clear the expand state after rendering all models for this provider + if (expandState.providerId === provider.id) { + callbacks.setExpandState({}); + } +} + +function displayModelSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + provider: AIProviderConfig, + model: AIModelConfig, + callbacks: ProviderSettingsCallbacks +): void { + const modelContainer = containerEl.createDiv('model-container'); + const expandState = callbacks.getExpandState(); + + // Check if this model should be expanded (newly added) + const shouldExpand = expandState.modelId === model.id; + + // Collapsible content container + const contentContainer = modelContainer.createDiv(`model-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); + + const modelDisplayName = model.name || 'Unnamed model'; + + // Model header with collapse toggle and delete button + const headerSetting = new Setting(modelContainer) + .setName(`${shouldExpand ? '▾' : '▸'} ${modelDisplayName}`) + .addButton(button => button + .setIcon('trash') + .setTooltip('Delete model') + .onClick(async () => { + const index = provider.models.findIndex(m => m.id === model.id); + if (index !== -1) { + provider.models.splice(index, 1); + // Clear transcription provider if it was using this model + if (plugin.settings.transcriptionProvider?.modelId === model.id) { + plugin.settings.transcriptionProvider = null; + } + // Keep provider expanded after deletion + callbacks.setExpandState({ providerId: provider.id }); + await plugin.saveSettings(); + callbacks.refresh(); + } + })); + + headerSetting.settingEl.addClass('model-header'); + + const toggleCollapse = () => { + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + contentContainer.classList.toggle('is-collapsed', !isCollapsed); + contentContainer.classList.toggle('is-expanded', isCollapsed); + headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${model.name || 'Unnamed model'}`); + }; + + headerSetting.settingEl.addEventListener('click', (e) => { + // Don't toggle if clicking on the delete button + if (!(e.target as HTMLElement).closest('button')) { + toggleCollapse(); + } + }); + + // Move content container after header + modelContainer.appendChild(contentContainer); + + // Model name + new Setting(contentContainer) + .setName('Display name') + .addText(text => text + .setPlaceholder('Whisper') + .setValue(model.name) + .onChange(async (value) => { + model.name = value; + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + headerSetting.setName(`${isCollapsed ? '▸' : '▾'} ${value || 'Unnamed model'}`); + await plugin.saveSettings(); + })); + + // Deployment name (for Azure) + if (provider.type === 'azure-openai') { + new Setting(contentContainer) + .setName('Deployment name') + .setDesc('The deployment name in Azure OpenAI') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addText(text => text + .setPlaceholder('whisper-1') // eslint-disable-line obsidianmd/ui/sentence-case -- model identifier + .setValue(model.deploymentName) + .onChange(async (value) => { + model.deploymentName = value; + await plugin.saveSettings(); + })); + } + + // Model ID + new Setting(contentContainer) + .setName('Model ID') + .setDesc('The model identifier') + .addText(text => text + .setPlaceholder('whisper-1') // eslint-disable-line obsidianmd/ui/sentence-case -- model identifier + .setValue(model.modelId) + .onChange(async (value) => { + model.modelId = value; + await plugin.saveSettings(); + })); + + // Model capabilities section + const capabilitiesContainer = contentContainer.createDiv('model-capabilities'); + + // Chat capability toggle + const chatSetting = new Setting(capabilitiesContainer) + .addToggle(toggle => toggle + .setValue(model.supportsChat ?? false) + .onChange(async (value) => { + model.supportsChat = value; + await plugin.saveSettings(); + })); + const chatNameEl = chatSetting.nameEl; + const chatIcon = chatNameEl.createSpan({ cls: 'model-capability-icon' }); + setIcon(chatIcon, 'message-circle'); + chatNameEl.appendText(' Chat'); + + // Transcription capability toggle + const transcriptionSetting = new Setting(capabilitiesContainer) + .addToggle(toggle => toggle + .setValue(model.supportsTranscription ?? false) + .onChange(async (value) => { + model.supportsTranscription = value; + await plugin.saveSettings(); + })); + const transcriptionNameEl = transcriptionSetting.nameEl; + const transcriptionIcon = transcriptionNameEl.createSpan({ cls: 'model-capability-icon' }); + setIcon(transcriptionIcon, 'audio-lines'); + transcriptionNameEl.appendText(' Transcription'); +} + diff --git a/src/settings/transcription.ts b/src/settings/transcription.ts new file mode 100644 index 0000000..351f0d7 --- /dev/null +++ b/src/settings/transcription.ts @@ -0,0 +1,177 @@ +import { Setting } from "obsidian"; +import AIToolboxPlugin from "../main"; + +/** + * Callbacks for the transcription settings tab to communicate with the main settings tab + */ +export interface TranscriptionSettingsCallbacks { + refresh: () => void; +} + +/** + * Display the transcription settings tab content + */ +export function displayTranscriptionSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + callbacks: TranscriptionSettingsCallbacks +): void { + // Transcription provider selection at the top + displayTranscriptionProviderSelection(containerEl, plugin); + + new Setting(containerEl) + .setName('Browser to impersonate') + .setDesc('Browser to impersonate when extracting audio for transcription') + .addDropdown(dropdown => dropdown + .addOption('chrome', 'Chrome') + .addOption('edge', 'Edge') + .addOption('safari', 'Safari') + .addOption('firefox', 'Firefox') + .addOption('brave', 'Brave') + .addOption('chromium', 'Chromium') + .setValue(plugin.settings.impersonateBrowser) + .onChange(async (value) => { + plugin.settings.impersonateBrowser = value; + await plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Language') + .setDesc('Language code for transcription (e.g., en, es, fr). Leave empty for automatic detection') + .addText(text => text + .setPlaceholder('') + .setValue(plugin.settings.transcriptionLanguage) + .onChange(async (value) => { + plugin.settings.transcriptionLanguage = value; + await plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Include timestamps') + .setDesc('Include timestamps in transcription notes for each chunk of text') + .addToggle(toggle => toggle + .setValue(plugin.settings.includeTimestamps) + .onChange(async (value) => { + plugin.settings.includeTimestamps = value; + await plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Notes folder') + .setDesc('Folder where transcription notes will be created (leave empty for vault root)') + .addText(text => text + .setValue(plugin.settings.outputFolder) + .onChange(async (value) => { + plugin.settings.outputFolder = value; + await plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Keep video file') + .setDesc('Keep the original video file after extracting audio for transcription') + .addToggle(toggle => toggle + .setValue(plugin.settings.keepVideo) + .onChange(async (value) => { + plugin.settings.keepVideo = value; + await plugin.saveSettings(); + callbacks.refresh(); + })); + + if (plugin.settings.keepVideo) { + new Setting(containerEl) + .setName('Output directory') + .setDesc('Directory where video files will be saved (leave empty to use ~/Videos/Obsidian)') // eslint-disable-line obsidianmd/ui/sentence-case -- path example + .addText(text => text + .setPlaceholder('~/Videos/Obsidian') // eslint-disable-line obsidianmd/ui/sentence-case -- path example + .setValue(plugin.settings.outputDirectory) + .onChange(async (value) => { + plugin.settings.outputDirectory = value; + await plugin.saveSettings(); + })); + } + + const advancedContainer = containerEl.createDiv('settings-advanced-container is-collapsed'); + + const advancedSetting = new Setting(containerEl) + .setName('▸ Advanced') // eslint-disable-line obsidianmd/ui/sentence-case + .setHeading(); + + advancedSetting.settingEl.addClass('settings-advanced-heading'); + + const toggleAdvanced = () => { + const isCollapsed = advancedContainer.classList.contains('is-collapsed'); + advancedContainer.classList.toggle('is-collapsed', !isCollapsed); + advancedContainer.classList.toggle('is-expanded', isCollapsed); + advancedSetting.setName(`${isCollapsed ? '▾' : '▸'} Advanced`); + }; + + advancedSetting.settingEl.addEventListener('click', toggleAdvanced); + + // Move the advancedContainer after the heading + containerEl.appendChild(advancedContainer); + + new Setting(advancedContainer) + .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .setDesc('Path to yt-dlp binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addText(text => text + .setValue(plugin.settings.ytdlpLocation) + .onChange(async (value) => { + plugin.settings.ytdlpLocation = value; + await plugin.saveSettings(); + })); + + new Setting(advancedContainer) + .setName('FFmpeg path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .setDesc('Path to FFmpeg binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addText(text => text + .setValue(plugin.settings.ffmpegLocation) + .onChange(async (value) => { + plugin.settings.ffmpegLocation = value; + await plugin.saveSettings(); + })); +} + +function displayTranscriptionProviderSelection( + containerEl: HTMLElement, + plugin: AIToolboxPlugin +): void { + const providers = plugin.settings.providers; + const currentSelection = plugin.settings.transcriptionProvider; + + // Build options for provider/model dropdown (only models that support transcription) + const options: Record = { '': 'Select a provider and model' }; + for (const provider of providers) { + for (const model of provider.models) { + if (model.supportsTranscription) { + const key = `${provider.id}:${model.id}`; + options[key] = `${provider.name} - ${model.name}`; + } + } + } + + const currentValue = currentSelection + ? `${currentSelection.providerId}:${currentSelection.modelId}` + : ''; + + new Setting(containerEl) + .setName('Transcription provider') + .setDesc('Select the provider and model to use for audio transcription') + .addDropdown(dropdown => dropdown + .addOptions(options) + .setValue(currentValue) + .onChange(async (value) => { + if (value === '') { + plugin.settings.transcriptionProvider = null; + } else { + const parts = value.split(':'); + if (parts.length === 2 && parts[0] && parts[1]) { + plugin.settings.transcriptionProvider = { + providerId: parts[0], + modelId: parts[1] + }; + } + } + await plugin.saveSettings(); + })); +} + diff --git a/src/settings/types.ts b/src/settings/types.ts new file mode 100644 index 0000000..3fbcafe --- /dev/null +++ b/src/settings/types.ts @@ -0,0 +1,103 @@ +/** + * Default OpenAI API endpoint + */ +export const DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com/v1'; + +/** + * Supported AI provider types + */ +export type AIProviderType = 'azure-openai' | 'openai' | 'anthropic'; + +/** + * Model configuration for a provider + */ +export interface AIModelConfig { + id: string; + name: string; + deploymentName: string; // Used by Azure, can be same as model ID for other providers + modelId: string; + supportsChat?: boolean; // Whether this model supports chat/conversation + supportsTranscription?: boolean; // Whether this model supports audio transcription +} + +/** + * AI Provider configuration + */ +export interface AIProviderConfig { + id: string; + name: string; + type: AIProviderType; + endpoint: string; + apiKey: string; + models: AIModelConfig[]; +} + +/** + * Reference to a specific provider and model for a feature + */ +export interface ProviderModelSelection { + providerId: string; + modelId: string; +} + +/** + * Configuration for a custom prompt + */ +export interface PromptConfig { + id: string; + name: string; + promptText: string; + provider: ProviderModelSelection | null; +} + +export interface AIToolboxSettings { + impersonateBrowser: string; + ytdlpLocation: string; + ffmpegLocation: string; + outputDirectory: string; + keepVideo: boolean; + includeTimestamps: boolean; + transcriptionLanguage: string; + outputFolder: string; + // New provider-based settings + providers: AIProviderConfig[]; + transcriptionProvider: ProviderModelSelection | null; + // Custom prompts + prompts: PromptConfig[]; +} + +/** + * Generate a unique ID for providers and models + */ +export function generateId(): string { + return Math.random().toString(36).substring(2, 11); +} + +export const DEFAULT_SETTINGS: AIToolboxSettings = { + impersonateBrowser: 'chrome', + ytdlpLocation: '', + ffmpegLocation: '', + outputDirectory: '', + keepVideo: false, + includeTimestamps: true, + transcriptionLanguage: '', + outputFolder: '', + providers: [], + transcriptionProvider: null, + prompts: [] +} + +/** + * Supported settings tab types + */ +export type SettingsTabType = 'providers' | 'prompts' | 'transcription'; + +/** + * State for tracking which items should be expanded on next render + */ +export interface ExpandOnNextRenderState { + providerId?: string; + modelId?: string; + promptId?: string; +} + diff --git a/styles.css b/styles.css index 362142f..dda1410 100644 --- a/styles.css +++ b/styles.css @@ -149,3 +149,35 @@ If your plugin does not need CSS, delete this file. width: 16px; height: 16px; } + +/* Prompt container styles */ +.prompt-container { + margin-bottom: 16px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 8px; +} + +.prompt-header { + cursor: pointer; + user-select: none; +} + +.prompt-header:hover .setting-item-name { + color: var(--text-accent); +} + +.prompt-content.is-collapsed { + display: none; +} + +.prompt-content.is-expanded { + display: block; + padding-top: 8px; +} + +.prompt-textarea { + width: 100%; + min-height: 120px; + resize: vertical; +} From 707a121382d753803ea3e7bf179bb13cf9036a70 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Tue, 6 Jan 2026 22:17:57 -0600 Subject: [PATCH 02/22] run prompt poc --- src/main.ts | 25 ++++++ src/prompts/index.ts | 3 + src/prompts/prompt-executor.ts | 105 ++++++++++++++++++++++++ src/prompts/prompt-suggester.ts | 31 +++++++ src/providers/azure-openai-provider.ts | 42 +++++++++- src/providers/base-model-provider.ts | 102 ++++++++++++++++++++++- src/providers/index.ts | 5 ++ src/providers/model-provider-factory.ts | 48 ++++++++--- src/providers/openai-provider.ts | 31 ++++++- src/providers/types.ts | 62 +++++++++++++- styles.css | 24 ++++++ 11 files changed, 456 insertions(+), 22 deletions(-) create mode 100644 src/prompts/index.ts create mode 100644 src/prompts/prompt-executor.ts create mode 100644 src/prompts/prompt-suggester.ts diff --git a/src/main.ts b/src/main.ts index b93576a..0cfc386 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { Notice, Plugin } from 'obsidian'; import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { createTranscriptionProvider } from "./providers"; import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; +import { PromptSuggesterModal, executePrompt } from "./prompts"; export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; @@ -14,10 +15,34 @@ export default class AIToolboxPlugin extends Plugin { void this.transcribeFromClipboard(); }); + // Add command to execute custom prompts + this.addCommand({ + id: 'execute-prompt', + name: 'Execute custom prompt', + callback: () => this.showPromptSuggester() + }); + // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new AIToolboxSettingTab(this.app, this)); } + /** + * Show the prompt suggester modal and execute the selected prompt. + */ + private showPromptSuggester(): void { + const prompts = this.settings.prompts; + + if (prompts.length === 0) { + new Notice('No prompts configured. Please add prompts in settings.'); + return; + } + + const modal = new PromptSuggesterModal(this.app, prompts, (prompt) => { + void executePrompt(this.app, this.settings, prompt); + }); + modal.open(); + } + /** * Complete workflow: Extract audio from clipboard URL, transcribe it, and create a note. */ diff --git a/src/prompts/index.ts b/src/prompts/index.ts new file mode 100644 index 0000000..474ad74 --- /dev/null +++ b/src/prompts/index.ts @@ -0,0 +1,3 @@ +export { PromptSuggesterModal } from './prompt-suggester'; +export { executePrompt, PromptResultModal } from './prompt-executor'; + diff --git a/src/prompts/prompt-executor.ts b/src/prompts/prompt-executor.ts new file mode 100644 index 0000000..d652701 --- /dev/null +++ b/src/prompts/prompt-executor.ts @@ -0,0 +1,105 @@ +import { App, Modal, Notice } from 'obsidian'; +import { PromptConfig, AIToolboxSettings } from '../settings'; +import { createPromptProvider, ChatMessage } from '../providers'; + +/** + * Modal to display the AI response from a prompt execution + */ +export class PromptResultModal extends Modal { + private promptName: string; + private response: string; + + constructor(app: App, promptName: string, response: string) { + super(app); + this.promptName = promptName; + this.response = response; + } + + onOpen(): void { + const { contentEl } = this; + + contentEl.createEl('h2', { text: this.promptName }); + + const responseContainer = contentEl.createDiv('prompt-response-container'); + responseContainer.createEl('pre', { + text: this.response, + cls: 'prompt-response-content' + }); + + // Add copy button + const buttonContainer = contentEl.createDiv('prompt-response-buttons'); + const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); + copyButton.addEventListener('click', async () => { + await navigator.clipboard.writeText(this.response); + new Notice('Response copied to clipboard'); + }); + + const closeButton = buttonContainer.createEl('button', { text: 'Close' }); + closeButton.addEventListener('click', () => this.close()); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} + +/** + * Execute a prompt using its configured provider and display the result. + * + * @param app - Obsidian App instance + * @param settings - Plugin settings + * @param prompt - The prompt configuration to execute + */ +export async function executePrompt( + app: App, + settings: AIToolboxSettings, + prompt: PromptConfig +): Promise { + // Validate prompt has a provider configured + if (!prompt.provider) { + new Notice(`Prompt "${prompt.name}" has no provider configured. Please configure a provider in settings.`); + return; + } + + // Validate prompt has text + if (!prompt.promptText.trim()) { + new Notice(`Prompt "${prompt.name}" has no prompt text. Please add prompt text in settings.`); + return; + } + + // Create the provider + const provider = createPromptProvider(settings, prompt); + if (!provider) { + new Notice(`Could not find the configured provider for prompt "${prompt.name}". Please check your settings.`); + return; + } + + // Validate provider supports chat + if (!provider.supportsChat()) { + new Notice(`The provider for prompt "${prompt.name}" does not support chat. Please select a chat-capable model.`); + return; + } + + try { + new Notice(`Executing prompt: ${prompt.name}...`); + + // Build chat messages with the prompt text as a user message + const messages: ChatMessage[] = [ + { role: 'user', content: prompt.promptText } + ]; + + // Send the chat request + const result = await provider.sendChat(messages); + + // Display the result in a modal + const resultModal = new PromptResultModal(app, prompt.name, result.content); + resultModal.open(); + + } catch (error) { + console.error('Prompt execution error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to execute prompt: ${errorMessage}`); + } +} + diff --git a/src/prompts/prompt-suggester.ts b/src/prompts/prompt-suggester.ts new file mode 100644 index 0000000..7e327af --- /dev/null +++ b/src/prompts/prompt-suggester.ts @@ -0,0 +1,31 @@ +import { App, FuzzySuggestModal } from 'obsidian'; +import { PromptConfig } from '../settings'; + +/** + * Modal for selecting a prompt from the configured prompts list. + * Uses fuzzy search to filter prompts by name. + */ +export class PromptSuggesterModal extends FuzzySuggestModal { + private prompts: PromptConfig[]; + private onChoose: (prompt: PromptConfig) => void; + + constructor(app: App, prompts: PromptConfig[], onChoose: (prompt: PromptConfig) => void) { + super(app); + this.prompts = prompts; + this.onChoose = onChoose; + this.setPlaceholder('Select a prompt to execute...'); + } + + getItems(): PromptConfig[] { + return this.prompts; + } + + getItemText(prompt: PromptConfig): string { + return prompt.name; + } + + onChooseItem(prompt: PromptConfig): void { + this.onChoose(prompt); + } +} + diff --git a/src/providers/azure-openai-provider.ts b/src/providers/azure-openai-provider.ts index e9997ef..071e77e 100644 --- a/src/providers/azure-openai-provider.ts +++ b/src/providers/azure-openai-provider.ts @@ -1,14 +1,15 @@ -import { ModelProviderConfig } from './types'; +import { ModelProviderConfig, ChatMessage, ChatOptions } from './types'; import { AIProviderType } from '../settings'; import { BaseProvider } from './base-model-provider'; /** * Azure OpenAI model provider implementation. - * Supports transcription using Azure-specific authentication and deployment-based endpoints. + * Supports transcription and chat using Azure-specific authentication and deployment-based endpoints. */ export class AzureOpenAIModelProvider extends BaseProvider { readonly type: AIProviderType = 'azure-openai'; - private readonly apiVersion: string; + private static readonly CHAT_API_VERSION = '2024-06-01'; + private static readonly TRANSCRIPTION_API_VERSION = '2024-06-01'; constructor(config: ModelProviderConfig) { super(config); @@ -16,7 +17,12 @@ export class AzureOpenAIModelProvider extends BaseProvider { protected buildTranscriptionUrl(): string { const endpoint = this.endpoint.replace(/\/$/, ''); - return `${endpoint}/openai/deployments/${this.deploymentName}/audio/transcriptions?api-version=2024-06-01`; + return `${endpoint}/openai/deployments/${this.deploymentName}/audio/transcriptions?api-version=${AzureOpenAIModelProvider.TRANSCRIPTION_API_VERSION}`; + } + + protected buildChatUrl(): string { + const endpoint = this.endpoint.replace(/\/$/, ''); + return `${endpoint}/openai/deployments/${this.deploymentName}/chat/completions?api-version=${AzureOpenAIModelProvider.CHAT_API_VERSION}`; } protected getAuthHeaders(): Record { @@ -36,5 +42,33 @@ export class AzureOpenAIModelProvider extends BaseProvider { throw new Error('Azure OpenAI deployment name is not configured.'); } } + + protected validateChatConfig(): void { + if (!this.endpoint) { + throw new Error('Azure OpenAI endpoint is not configured.'); + } + if (!this.apiKey) { + throw new Error('Azure OpenAI API key is not configured.'); + } + if (!this.deploymentName) { + throw new Error('Azure OpenAI deployment name is not configured.'); + } + } + + protected buildChatRequestBody(messages: ChatMessage[], options: ChatOptions): Record { + // Azure OpenAI doesn't need the model field in the body (it's in the URL) + const body: Record = { + messages: messages.map(m => ({ role: m.role, content: m.content })), + }; + + if (options.maxTokens !== undefined) { + body.max_tokens = options.maxTokens; + } + if (options.temperature !== undefined) { + body.temperature = options.temperature; + } + + return body; + } } diff --git a/src/providers/base-model-provider.ts b/src/providers/base-model-provider.ts index 7880614..4f212bd 100644 --- a/src/providers/base-model-provider.ts +++ b/src/providers/base-model-provider.ts @@ -1,7 +1,7 @@ import { Notice, requestUrl } from 'obsidian'; import * as fs from 'fs'; import { Buffer } from 'buffer'; -import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult } from './types'; +import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult, ChatMessage, ChatOptions, ChatResult } from './types'; import { AIProviderType } from '../settings'; /** @@ -12,6 +12,29 @@ export interface TranscriptionApiResponse { segments?: Array<{ text: string; start: number; end: number }>; } +/** + * Abstract base class for AI model providers. + * Contains shared implementation for transcription and other capabilities. + * Concrete providers extend this class and implement provider-specific methods. + */ +/** + * Interface for chat API response (OpenAI-compatible format) + */ +export interface ChatApiResponse { + choices: Array<{ + message: { + role: string; + content: string; + }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + /** * Abstract base class for AI model providers. * Contains shared implementation for transcription and other capabilities. @@ -27,6 +50,7 @@ export abstract class BaseProvider implements ModelProvider { protected readonly deploymentName: string; protected readonly modelDisplayName: string; private readonly _supportsTranscription: boolean; + private readonly _supportsChat: boolean; constructor(config: ModelProviderConfig) { this.providerName = config.name; @@ -36,12 +60,47 @@ export abstract class BaseProvider implements ModelProvider { this.modelId = config.modelId; this.deploymentName = config.deploymentName || config.modelId; this._supportsTranscription = config.supportsTranscription ?? false; + this._supportsChat = config.supportsChat ?? false; } supportsTranscription(): boolean { return this._supportsTranscription; } + supportsChat(): boolean { + return this._supportsChat; + } + + async sendChat(messages: ChatMessage[], options: ChatOptions = {}): Promise { + this.validateChatConfig(); + + try { + const apiUrl = this.buildChatUrl(); + const requestBody = this.buildChatRequestBody(messages, options); + + const response = await requestUrl({ + url: apiUrl, + method: 'POST', + headers: { + ...this.getAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (response.status !== 200) { + throw new Error(`${this.getProviderDisplayName()} API error: ${response.status} - ${response.text}`); + } + + const result = response.json as ChatApiResponse; + return this.parseChatResponse(result); + } catch (error) { + console.error('Chat error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Chat failed: ${errorMessage}`); + } + } + async transcribeAudio(audioFilePath: string, options: TranscriptionOptions = {}): Promise { if (!fs.existsSync(audioFilePath)) { throw new Error(`Audio file not found: ${audioFilePath}`); @@ -103,16 +162,31 @@ export abstract class BaseProvider implements ModelProvider { */ protected abstract buildTranscriptionUrl(): string; + /** + * Build the API URL for chat requests + */ + protected abstract buildChatUrl(): string; + /** * Get authentication headers for API requests */ protected abstract getAuthHeaders(): Record; /** - * Validate provider-specific configuration + * Validate provider-specific configuration for transcription */ protected abstract validateTranscriptionConfig(): void; + /** + * Validate provider-specific configuration for chat + */ + protected abstract validateChatConfig(): void; + + /** + * Build the request body for chat completion + */ + protected abstract buildChatRequestBody(messages: ChatMessage[], options: ChatOptions): Record; + /** * Get additional form fields for the multipart request (e.g., model field for OpenAI) */ @@ -120,6 +194,30 @@ export abstract class BaseProvider implements ModelProvider { return []; } + /** + * Parse the chat API response into a ChatResult + */ + protected parseChatResponse(response: ChatApiResponse): ChatResult { + const choice = response.choices[0]; + if (!choice) { + throw new Error('No response from chat API'); + } + + const result: ChatResult = { + content: choice.message.content, + }; + + if (response.usage) { + result.usage = { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + }; + } + + return result; + } + protected buildMultipartFormData( boundary: string, audioBuffer: Buffer, diff --git a/src/providers/index.ts b/src/providers/index.ts index fd9e6d4..194a4d4 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,10 @@ export type { TranscriptionOptions, TranscriptionResult, TranscriptionChunk, + ChatMessage, + ChatMessageRole, + ChatOptions, + ChatResult, } from './types'; // Base class (for extending) @@ -18,6 +22,7 @@ export { OpenAIModelProvider } from './openai-provider'; export { createModelProvider, createTranscriptionProvider, + createPromptProvider, ProviderCreationError, } from './model-provider-factory'; diff --git a/src/providers/model-provider-factory.ts b/src/providers/model-provider-factory.ts index 5f894bb..e0db44d 100644 --- a/src/providers/model-provider-factory.ts +++ b/src/providers/model-provider-factory.ts @@ -1,4 +1,4 @@ -import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT } from '../settings/index'; +import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT, PromptConfig, ProviderModelSelection } from '../settings/index'; import { ModelProvider, ModelProviderConfig } from './types'; import { AzureOpenAIModelProvider } from './azure-openai-provider'; import { OpenAIModelProvider } from './openai-provider'; @@ -55,18 +55,14 @@ export function createModelProvider(config: ModelProviderConfig): ModelProvider } /** - * Create a transcription provider from settings. - * + * Create a provider from a ProviderModelSelection. + * * @param settings - Plugin settings containing provider configuration - * @returns A configured ModelProvider for transcription, or null if not configured + * @param selection - The provider/model selection + * @returns A configured ModelProvider, or null if not found * @throws ProviderCreationError if the provider cannot be created */ -export function createTranscriptionProvider(settings: AIToolboxSettings): ModelProvider | null { - const selection = settings.transcriptionProvider; - if (!selection) { - return null; - } - +function createProviderFromSelection(settings: AIToolboxSettings, selection: ProviderModelSelection): ModelProvider | null { const provider = settings.providers.find(p => p.id === selection.providerId); if (!provider) { return null; @@ -80,3 +76,35 @@ export function createTranscriptionProvider(settings: AIToolboxSettings): ModelP const config = buildProviderConfig(provider, model); return createModelProvider(config); } + +/** + * Create a transcription provider from settings. + * + * @param settings - Plugin settings containing provider configuration + * @returns A configured ModelProvider for transcription, or null if not configured + * @throws ProviderCreationError if the provider cannot be created + */ +export function createTranscriptionProvider(settings: AIToolboxSettings): ModelProvider | null { + const selection = settings.transcriptionProvider; + if (!selection) { + return null; + } + + return createProviderFromSelection(settings, selection); +} + +/** + * Create a provider for a specific prompt configuration. + * + * @param settings - Plugin settings containing provider configuration + * @param prompt - The prompt configuration to create a provider for + * @returns A configured ModelProvider for the prompt, or null if not configured + * @throws ProviderCreationError if the provider cannot be created + */ +export function createPromptProvider(settings: AIToolboxSettings, prompt: PromptConfig): ModelProvider | null { + if (!prompt.provider) { + return null; + } + + return createProviderFromSelection(settings, prompt.provider); +} diff --git a/src/providers/openai-provider.ts b/src/providers/openai-provider.ts index 3f89c25..2beb91e 100644 --- a/src/providers/openai-provider.ts +++ b/src/providers/openai-provider.ts @@ -1,10 +1,10 @@ -import { ModelProviderConfig } from './types'; +import { ModelProviderConfig, ChatMessage, ChatOptions } from './types'; import { AIProviderType, DEFAULT_OPENAI_ENDPOINT } from '../settings/index'; import { BaseProvider } from './base-model-provider'; /** * OpenAI model provider implementation. - * Supports transcription using Bearer token authentication. + * Supports transcription and chat using Bearer token authentication. */ export class OpenAIModelProvider extends BaseProvider { readonly type: AIProviderType = 'openai'; @@ -23,6 +23,11 @@ export class OpenAIModelProvider extends BaseProvider { return `${endpoint}/audio/transcriptions`; } + protected buildChatUrl(): string { + const endpoint = this.endpoint.replace(/\/$/, ''); + return `${endpoint}/chat/completions`; + } + protected getAuthHeaders(): Record { return { 'Authorization': `Bearer ${this.apiKey}`, @@ -35,6 +40,28 @@ export class OpenAIModelProvider extends BaseProvider { } } + protected validateChatConfig(): void { + if (!this.apiKey) { + throw new Error('OpenAI API key is not configured.'); + } + } + + protected buildChatRequestBody(messages: ChatMessage[], options: ChatOptions): Record { + const body: Record = { + model: this.modelId, + messages: messages.map(m => ({ role: m.role, content: m.content })), + }; + + if (options.maxTokens !== undefined) { + body.max_tokens = options.maxTokens; + } + if (options.temperature !== undefined) { + body.temperature = options.temperature; + } + + return body; + } + /** * OpenAI API requires the model field in the request body */ diff --git a/src/providers/types.ts b/src/providers/types.ts index 2b0861b..2e18f0d 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -25,6 +25,43 @@ export interface TranscriptionResult { audioFilePath: string; } +/** + * Role for chat messages + */ +export type ChatMessageRole = 'system' | 'user' | 'assistant'; + +/** + * A single message in a chat conversation + */ +export interface ChatMessage { + role: ChatMessageRole; + content: string; +} + +/** + * Options for chat completion requests + */ +export interface ChatOptions { + /** Maximum tokens to generate */ + maxTokens?: number; + /** Temperature for response randomness (0-2) */ + temperature?: number; +} + +/** + * Result from a chat completion request + */ +export interface ChatResult { + /** The generated response content */ + content: string; + /** Token usage information if available */ + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + /** * Configuration for creating a model provider instance */ @@ -53,20 +90,20 @@ export interface ModelProviderConfig { /** * Common interface for AI model providers. - * + * * This interface defines the capabilities that each provider must implement, * allowing for polymorphic usage and dependency injection into workflows. */ export interface ModelProvider { /** The type of this provider */ readonly type: AIProviderType; - + /** Human-readable name for this provider instance */ readonly providerName: string; /** * Transcribe an audio file to text. - * + * * @param audioFilePath - Path to the audio file (mp3, wav, m4a, webm, etc.) * @param options - Transcription options (timestamps, language, etc.) * @returns Promise resolving to transcription result with text and optional chunks @@ -76,9 +113,26 @@ export interface ModelProvider { /** * Check if this provider supports audio transcription. - * + * * @returns true if transcribeAudio() is available */ supportsTranscription(): boolean; + + /** + * Send a chat completion request. + * + * @param messages - Array of chat messages forming the conversation + * @param options - Optional chat options (max tokens, temperature, etc.) + * @returns Promise resolving to chat result with generated content + * @throws Error if chat fails or is not supported by this provider + */ + sendChat(messages: ChatMessage[], options?: ChatOptions): Promise; + + /** + * Check if this provider supports chat/conversation. + * + * @returns true if sendChat() is available + */ + supportsChat(): boolean; } diff --git a/styles.css b/styles.css index dda1410..17776e0 100644 --- a/styles.css +++ b/styles.css @@ -181,3 +181,27 @@ If your plugin does not need CSS, delete this file. min-height: 120px; resize: vertical; } + +/* Prompt result modal styles */ +.prompt-response-container { + margin: 16px 0; + max-height: 400px; + overflow-y: auto; +} + +.prompt-response-content { + white-space: pre-wrap; + word-wrap: break-word; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; + font-size: 13px; + line-height: 1.5; +} + +.prompt-response-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} From 9e6374d00abf3faed8b9a79d922631b9b2a2c885 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 7 Jan 2026 13:42:23 -0600 Subject: [PATCH 03/22] stash --- src/prompts/prompt-executor.ts | 117 +++++++++++++++++++++-- src/settings/index.ts | 3 +- src/settings/prompts.ts | 29 +++++- src/settings/types.ts | 16 ++++ src/transcriptions/transcription-note.ts | 3 +- src/utils/date-utils.ts | 10 ++ 6 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 src/utils/date-utils.ts diff --git a/src/prompts/prompt-executor.ts b/src/prompts/prompt-executor.ts index d652701..3ca3807 100644 --- a/src/prompts/prompt-executor.ts +++ b/src/prompts/prompt-executor.ts @@ -1,6 +1,7 @@ -import { App, Modal, Notice } from 'obsidian'; +import { App, MarkdownView, Modal, Notice, TFile } from 'obsidian'; import { PromptConfig, AIToolboxSettings } from '../settings'; import { createPromptProvider, ChatMessage } from '../providers'; +import { generateFilenameTimestamp } from '../utils/date-utils'; /** * Modal to display the AI response from a prompt execution @@ -17,11 +18,11 @@ export class PromptResultModal extends Modal { onOpen(): void { const { contentEl } = this; - + contentEl.createEl('h2', { text: this.promptName }); - + const responseContainer = contentEl.createDiv('prompt-response-container'); - responseContainer.createEl('pre', { + responseContainer.createEl('pre', { text: this.response, cls: 'prompt-response-content' }); @@ -44,9 +45,100 @@ export class PromptResultModal extends Modal { } } +/** + * Insert text at the current cursor position in the active editor. + */ +function insertAtCursor(app: App, text: string): void { + const activeView = app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) { + new Notice('No active editor. Please open a note first.'); + return; + } + + const editor = activeView.editor; + const cursor = editor.getCursor(); + editor.replaceRange(text, cursor); + + // Move cursor to end of inserted text + const lines = text.split('\n'); + const lastLine = lines[lines.length - 1] ?? ''; + const newLine = cursor.line + lines.length - 1; + const newCh = lines.length === 1 ? cursor.ch + lastLine.length : lastLine.length; + editor.setCursor({ line: newLine, ch: newCh }); + + new Notice('Response inserted at cursor'); +} + +/** + * Create a new note with the prompt result. + * Honors Obsidian's default new note location setting. + */ +async function createNoteWithResult( + app: App, + promptName: string, + promptText: string, + response: string +): Promise { + const timestamp = generateFilenameTimestamp(); + const noteTitle = `${promptName} - ${timestamp}`; + + // Build note content with prompt and response + const noteContent = `# ${promptName} + +## Prompt + +${promptText} + +## Response + +${response} + +--- +*Generated on ${new Date().toLocaleString()}* +`; + + // Get the default new note location from Obsidian's vault config + // This method exists but is not in the public type definitions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vault = app.vault as any; + const newFileFolderPath = vault.getConfig?.('newFileFolderPath') as string | undefined; + + // Build the file path + let filePath: string; + if (newFileFolderPath && newFileFolderPath.trim() !== '') { + // Ensure folder exists + const folderPath = newFileFolderPath.replace(/\/$/, ''); + const folder = app.vault.getAbstractFileByPath(folderPath); + if (!folder) { + await app.vault.createFolder(folderPath); + } + filePath = `${folderPath}/${noteTitle}.md`; + } else { + filePath = `${noteTitle}.md`; + } + + // Handle filename conflicts by appending a number + let finalPath = filePath; + let counter = 1; + while (app.vault.getAbstractFileByPath(finalPath)) { + const basePath = filePath.replace('.md', ''); + finalPath = `${basePath} (${counter}).md`; + counter++; + } + + // Create the note + const file = await app.vault.create(finalPath, noteContent); + + // Open the newly created note + const leaf = app.workspace.getLeaf(false); + await leaf.openFile(file as TFile); + + new Notice(`Created note: ${file.name}`); +} + /** * Execute a prompt using its configured provider and display the result. - * + * * @param app - Obsidian App instance * @param settings - Plugin settings * @param prompt - The prompt configuration to execute @@ -92,9 +184,18 @@ export async function executePrompt( // Send the chat request const result = await provider.sendChat(messages); - // Display the result in a modal - const resultModal = new PromptResultModal(app, prompt.name, result.content); - resultModal.open(); + // Handle output based on configured output type + const outputType = prompt.outputType || 'popup'; + + if (outputType === 'new-note') { + await createNoteWithResult(app, prompt.name, prompt.promptText, result.content); + } else if (outputType === 'at-cursor') { + insertAtCursor(app, result.content); + } else { + // Default: show in popup modal + const resultModal = new PromptResultModal(app, prompt.name, result.content); + resultModal.open(); + } } catch (error) { console.error('Prompt execution error:', error); diff --git a/src/settings/index.ts b/src/settings/index.ts index 9196294..1080446 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -6,13 +6,14 @@ import { displayPromptsSettings, PromptSettingsCallbacks } from "./prompts"; import { displayTranscriptionSettings, TranscriptionSettingsCallbacks } from "./transcription"; // Re-export all types and constants from types.ts for backward compatibility -export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS } from "./types"; +export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS, DEFAULT_PROMPT_CONFIG } from "./types"; export type { AIProviderType, AIModelConfig, AIProviderConfig, ProviderModelSelection, PromptConfig, + PromptOutputType, AIToolboxSettings, SettingsTabType, ExpandOnNextRenderState diff --git a/src/settings/prompts.ts b/src/settings/prompts.ts index 3188fb0..95733a7 100644 --- a/src/settings/prompts.ts +++ b/src/settings/prompts.ts @@ -2,8 +2,10 @@ import { Setting } from "obsidian"; import AIToolboxPlugin from "../main"; import { PromptConfig, + PromptOutputType, ExpandOnNextRenderState, - generateId + generateId, + DEFAULT_PROMPT_CONFIG } from "./types"; /** @@ -15,6 +17,15 @@ export interface PromptSettingsCallbacks { refresh: () => void; } +/** + * Output type display labels + */ +const OUTPUT_TYPE_OPTIONS: Record = { + 'popup': 'Show in popup', + 'new-note': 'Create new note', + 'at-cursor': 'Insert at cursor' +}; + /** * Display the prompts settings tab content */ @@ -33,9 +44,7 @@ export function displayPromptsSettings( .onClick(async () => { const newPrompt: PromptConfig = { id: generateId(), - name: 'New prompt', - promptText: '', - provider: null + ...DEFAULT_PROMPT_CONFIG }; plugin.settings.prompts.push(newPrompt); // Expand the newly created prompt @@ -132,6 +141,18 @@ function displayPromptSettings( textArea.inputEl.addClass('prompt-textarea'); }); + // Output type selection + new Setting(contentContainer) + .setName('Output type') + .setDesc('Choose how to display the AI response') + .addDropdown(dropdown => dropdown + .addOptions(OUTPUT_TYPE_OPTIONS) + .setValue(prompt.outputType || 'popup') + .onChange(async (value) => { + prompt.outputType = value as PromptOutputType; + await plugin.saveSettings(); + })); + // Clear the expand state after rendering this prompt if (expandState.promptId === prompt.id) { callbacks.setExpandState({}); diff --git a/src/settings/types.ts b/src/settings/types.ts index 3fbcafe..94ace8b 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -40,6 +40,11 @@ export interface ProviderModelSelection { modelId: string; } +/** + * Output type options for prompt execution + */ +export type PromptOutputType = 'popup' | 'new-note' | 'at-cursor'; + /** * Configuration for a custom prompt */ @@ -48,8 +53,19 @@ export interface PromptConfig { name: string; promptText: string; provider: ProviderModelSelection | null; + outputType: PromptOutputType; } +/** + * Default configuration for a new prompt + */ +export const DEFAULT_PROMPT_CONFIG: Omit = { + name: 'New prompt', + promptText: '', + provider: null, + outputType: 'popup' +}; + export interface AIToolboxSettings { impersonateBrowser: string; ytdlpLocation: string; diff --git a/src/transcriptions/transcription-note.ts b/src/transcriptions/transcription-note.ts index 4068214..72c6208 100644 --- a/src/transcriptions/transcription-note.ts +++ b/src/transcriptions/transcription-note.ts @@ -1,6 +1,7 @@ import { App, Notice, TFile } from 'obsidian'; import { TranscriptionResult } from '../providers'; import { VideoMetadata, videoPlatformRegistry } from './video-platforms'; +import { generateFilenameTimestamp } from '../utils/date-utils'; /** * Creates a new Obsidian note with the transcription content. @@ -83,7 +84,7 @@ export function generateNoteFilename( videoMetadata?: VideoMetadata ): string { // Add timestamp to ensure uniqueness - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const timestamp = generateFilenameTimestamp(); // Determine title using platform handler (each platform decides how to handle metadata) const handler = videoPlatformRegistry.findHandlerForUrl(sourceUrl); diff --git a/src/utils/date-utils.ts b/src/utils/date-utils.ts new file mode 100644 index 0000000..be3014d --- /dev/null +++ b/src/utils/date-utils.ts @@ -0,0 +1,10 @@ +/** + * Generates a filename-safe timestamp string. + * Format: YYYY-MM-DDTHH-MM-SS (ISO-like format with dashes instead of colons) + * + * @returns Formatted timestamp string suitable for use in filenames + */ +export function generateFilenameTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); +} + From faf517d7964f57c78a024f188e8c6dc8595e6249 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Wed, 7 Jan 2026 16:28:55 -0600 Subject: [PATCH 04/22] rename prompts to workflows --- src/main.ts | 24 ++-- src/prompts/index.ts | 3 - src/prompts/prompt-suggester.ts | 31 ----- src/providers/index.ts | 2 +- src/providers/model-provider-factory.ts | 14 +-- src/settings/index.ts | 26 ++-- src/settings/types.ts | 26 ++-- src/settings/{prompts.ts => workflows.ts} | 112 +++++++++--------- src/workflows/index.ts | 3 + .../workflow-executor.ts} | 70 +++++------ src/workflows/workflow-suggester.ts | 31 +++++ styles.css | 22 ++-- 12 files changed, 182 insertions(+), 182 deletions(-) delete mode 100644 src/prompts/index.ts delete mode 100644 src/prompts/prompt-suggester.ts rename src/settings/{prompts.ts => workflows.ts} (56%) create mode 100644 src/workflows/index.ts rename src/{prompts/prompt-executor.ts => workflows/workflow-executor.ts} (65%) create mode 100644 src/workflows/workflow-suggester.ts diff --git a/src/main.ts b/src/main.ts index 0cfc386..e4d85a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { Notice, Plugin } from 'obsidian'; import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { createTranscriptionProvider } from "./providers"; import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; -import { PromptSuggesterModal, executePrompt } from "./prompts"; +import { WorkflowSuggesterModal, executeWorkflow } from "./workflows"; export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; @@ -15,11 +15,11 @@ export default class AIToolboxPlugin extends Plugin { void this.transcribeFromClipboard(); }); - // Add command to execute custom prompts + // Add command to execute custom workflows this.addCommand({ - id: 'execute-prompt', - name: 'Execute custom prompt', - callback: () => this.showPromptSuggester() + id: 'execute-workflow', + name: 'Execute custom workflow', + callback: () => this.showWorkflowSuggester() }); // This adds a settings tab so the user can configure various aspects of the plugin @@ -27,18 +27,18 @@ export default class AIToolboxPlugin extends Plugin { } /** - * Show the prompt suggester modal and execute the selected prompt. + * Show the workflow suggester modal and execute the selected workflow. */ - private showPromptSuggester(): void { - const prompts = this.settings.prompts; + private showWorkflowSuggester(): void { + const workflows = this.settings.workflows; - if (prompts.length === 0) { - new Notice('No prompts configured. Please add prompts in settings.'); + if (workflows.length === 0) { + new Notice('No workflows configured. Please add workflows in settings.'); return; } - const modal = new PromptSuggesterModal(this.app, prompts, (prompt) => { - void executePrompt(this.app, this.settings, prompt); + const modal = new WorkflowSuggesterModal(this.app, workflows, (workflow) => { + void executeWorkflow(this.app, this.settings, workflow); }); modal.open(); } diff --git a/src/prompts/index.ts b/src/prompts/index.ts deleted file mode 100644 index 474ad74..0000000 --- a/src/prompts/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { PromptSuggesterModal } from './prompt-suggester'; -export { executePrompt, PromptResultModal } from './prompt-executor'; - diff --git a/src/prompts/prompt-suggester.ts b/src/prompts/prompt-suggester.ts deleted file mode 100644 index 7e327af..0000000 --- a/src/prompts/prompt-suggester.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { App, FuzzySuggestModal } from 'obsidian'; -import { PromptConfig } from '../settings'; - -/** - * Modal for selecting a prompt from the configured prompts list. - * Uses fuzzy search to filter prompts by name. - */ -export class PromptSuggesterModal extends FuzzySuggestModal { - private prompts: PromptConfig[]; - private onChoose: (prompt: PromptConfig) => void; - - constructor(app: App, prompts: PromptConfig[], onChoose: (prompt: PromptConfig) => void) { - super(app); - this.prompts = prompts; - this.onChoose = onChoose; - this.setPlaceholder('Select a prompt to execute...'); - } - - getItems(): PromptConfig[] { - return this.prompts; - } - - getItemText(prompt: PromptConfig): string { - return prompt.name; - } - - onChooseItem(prompt: PromptConfig): void { - this.onChoose(prompt); - } -} - diff --git a/src/providers/index.ts b/src/providers/index.ts index 194a4d4..55653e2 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -22,7 +22,7 @@ export { OpenAIModelProvider } from './openai-provider'; export { createModelProvider, createTranscriptionProvider, - createPromptProvider, + createWorkflowProvider, ProviderCreationError, } from './model-provider-factory'; diff --git a/src/providers/model-provider-factory.ts b/src/providers/model-provider-factory.ts index e0db44d..8691373 100644 --- a/src/providers/model-provider-factory.ts +++ b/src/providers/model-provider-factory.ts @@ -1,4 +1,4 @@ -import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT, PromptConfig, ProviderModelSelection } from '../settings/index'; +import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT, WorkflowConfig, ProviderModelSelection } from '../settings/index'; import { ModelProvider, ModelProviderConfig } from './types'; import { AzureOpenAIModelProvider } from './azure-openai-provider'; import { OpenAIModelProvider } from './openai-provider'; @@ -94,17 +94,17 @@ export function createTranscriptionProvider(settings: AIToolboxSettings): ModelP } /** - * Create a provider for a specific prompt configuration. + * Create a provider for a specific workflow configuration. * * @param settings - Plugin settings containing provider configuration - * @param prompt - The prompt configuration to create a provider for - * @returns A configured ModelProvider for the prompt, or null if not configured + * @param workflow - The workflow configuration to create a provider for + * @returns A configured ModelProvider for the workflow, or null if not configured * @throws ProviderCreationError if the provider cannot be created */ -export function createPromptProvider(settings: AIToolboxSettings, prompt: PromptConfig): ModelProvider | null { - if (!prompt.provider) { +export function createWorkflowProvider(settings: AIToolboxSettings, workflow: WorkflowConfig): ModelProvider | null { + if (!workflow.provider) { return null; } - return createProviderFromSelection(settings, prompt.provider); + return createProviderFromSelection(settings, workflow.provider); } diff --git a/src/settings/index.ts b/src/settings/index.ts index 1080446..0ea2794 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -2,18 +2,18 @@ import { App, PluginSettingTab } from "obsidian"; import AIToolboxPlugin from "../main"; import { SettingsTabType, ExpandOnNextRenderState } from "./types"; import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; -import { displayPromptsSettings, PromptSettingsCallbacks } from "./prompts"; +import { displayWorkflowsSettings, WorkflowSettingsCallbacks } from "./workflows"; import { displayTranscriptionSettings, TranscriptionSettingsCallbacks } from "./transcription"; // Re-export all types and constants from types.ts for backward compatibility -export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS, DEFAULT_PROMPT_CONFIG } from "./types"; +export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS, DEFAULT_WORKFLOW_CONFIG } from "./types"; export type { AIProviderType, AIModelConfig, AIProviderConfig, ProviderModelSelection, - PromptConfig, - PromptOutputType, + WorkflowConfig, + WorkflowOutputType, AIToolboxSettings, SettingsTabType, ExpandOnNextRenderState @@ -44,8 +44,8 @@ export class AIToolboxSettingTab extends PluginSettingTab { cls: 'settings-tab-button' }); - const promptsTabButton = tabHeader.createEl('button', { - text: 'Prompts', + const workflowsTabButton = tabHeader.createEl('button', { + text: 'Workflows', cls: 'settings-tab-button' }); @@ -57,14 +57,14 @@ export class AIToolboxSettingTab extends PluginSettingTab { const showTab = (tab: SettingsTabType) => { this.activeTab = tab; providersTabButton.classList.toggle('active', tab === 'providers'); - promptsTabButton.classList.toggle('active', tab === 'prompts'); + workflowsTabButton.classList.toggle('active', tab === 'workflows'); transcriptionTabButton.classList.toggle('active', tab === 'transcription'); tabContent.empty(); if (tab === 'providers') { this.displayProvidersTab(tabContent); - } else if (tab === 'prompts') { - this.displayPromptsTab(tabContent); + } else if (tab === 'workflows') { + this.displayWorkflowsTab(tabContent); } else if (tab === 'transcription') { this.displayTranscriptionTab(tabContent); } else { @@ -74,7 +74,7 @@ export class AIToolboxSettingTab extends PluginSettingTab { }; providersTabButton.addEventListener('click', () => showTab('providers')); - promptsTabButton.addEventListener('click', () => showTab('prompts')); + workflowsTabButton.addEventListener('click', () => showTab('workflows')); transcriptionTabButton.addEventListener('click', () => showTab('transcription')); showTab(this.activeTab); @@ -89,13 +89,13 @@ export class AIToolboxSettingTab extends PluginSettingTab { displayProvidersSettings(containerEl, this.plugin, callbacks); } - private displayPromptsTab(containerEl: HTMLElement): void { - const callbacks: PromptSettingsCallbacks = { + private displayWorkflowsTab(containerEl: HTMLElement): void { + const callbacks: WorkflowSettingsCallbacks = { getExpandState: () => this.expandOnNextRender, setExpandState: (state) => { this.expandOnNextRender = state; }, refresh: () => this.display() }; - displayPromptsSettings(containerEl, this.plugin, callbacks); + displayWorkflowsSettings(containerEl, this.plugin, callbacks); } private displayTranscriptionTab(containerEl: HTMLElement): void { diff --git a/src/settings/types.ts b/src/settings/types.ts index 94ace8b..8194a88 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -41,26 +41,26 @@ export interface ProviderModelSelection { } /** - * Output type options for prompt execution + * Output type options for workflow execution */ -export type PromptOutputType = 'popup' | 'new-note' | 'at-cursor'; +export type WorkflowOutputType = 'popup' | 'new-note' | 'at-cursor'; /** - * Configuration for a custom prompt + * Configuration for a custom workflow */ -export interface PromptConfig { +export interface WorkflowConfig { id: string; name: string; promptText: string; provider: ProviderModelSelection | null; - outputType: PromptOutputType; + outputType: WorkflowOutputType; } /** - * Default configuration for a new prompt + * Default configuration for a new workflow */ -export const DEFAULT_PROMPT_CONFIG: Omit = { - name: 'New prompt', +export const DEFAULT_WORKFLOW_CONFIG: Omit = { + name: 'New workflow', promptText: '', provider: null, outputType: 'popup' @@ -78,8 +78,8 @@ export interface AIToolboxSettings { // New provider-based settings providers: AIProviderConfig[]; transcriptionProvider: ProviderModelSelection | null; - // Custom prompts - prompts: PromptConfig[]; + // Custom workflows + workflows: WorkflowConfig[]; } /** @@ -100,13 +100,13 @@ export const DEFAULT_SETTINGS: AIToolboxSettings = { outputFolder: '', providers: [], transcriptionProvider: null, - prompts: [] + workflows: [] } /** * Supported settings tab types */ -export type SettingsTabType = 'providers' | 'prompts' | 'transcription'; +export type SettingsTabType = 'providers' | 'workflows' | 'transcription'; /** * State for tracking which items should be expanded on next render @@ -114,6 +114,6 @@ export type SettingsTabType = 'providers' | 'prompts' | 'transcription'; export interface ExpandOnNextRenderState { providerId?: string; modelId?: string; - promptId?: string; + workflowId?: string; } diff --git a/src/settings/prompts.ts b/src/settings/workflows.ts similarity index 56% rename from src/settings/prompts.ts rename to src/settings/workflows.ts index 95733a7..46b928c 100644 --- a/src/settings/prompts.ts +++ b/src/settings/workflows.ts @@ -1,17 +1,17 @@ import { Setting } from "obsidian"; import AIToolboxPlugin from "../main"; import { - PromptConfig, - PromptOutputType, + WorkflowConfig, + WorkflowOutputType, ExpandOnNextRenderState, generateId, - DEFAULT_PROMPT_CONFIG + DEFAULT_WORKFLOW_CONFIG } from "./types"; /** - * Callbacks for the prompts settings tab to communicate with the main settings tab + * Callbacks for the workflows settings tab to communicate with the main settings tab */ -export interface PromptSettingsCallbacks { +export interface WorkflowSettingsCallbacks { getExpandState: () => ExpandOnNextRenderState; setExpandState: (state: ExpandOnNextRenderState) => void; refresh: () => void; @@ -20,83 +20,83 @@ export interface PromptSettingsCallbacks { /** * Output type display labels */ -const OUTPUT_TYPE_OPTIONS: Record = { +const OUTPUT_TYPE_OPTIONS: Record = { 'popup': 'Show in popup', 'new-note': 'Create new note', 'at-cursor': 'Insert at cursor' }; /** - * Display the prompts settings tab content + * Display the workflows settings tab content */ -export function displayPromptsSettings( +export function displayWorkflowsSettings( containerEl: HTMLElement, plugin: AIToolboxPlugin, - callbacks: PromptSettingsCallbacks + callbacks: WorkflowSettingsCallbacks ): void { - // Add prompt button + // Add workflow button new Setting(containerEl) - .setName('Custom prompts') - .setDesc('Configure custom prompts to use with your AI providers') + .setName('Custom workflows') + .setDesc('Configure custom workflows to use with your AI providers') .addButton(button => button - .setButtonText('Add prompt') + .setButtonText('Add workflow') .setCta() .onClick(async () => { - const newPrompt: PromptConfig = { + const newWorkflow: WorkflowConfig = { id: generateId(), - ...DEFAULT_PROMPT_CONFIG + ...DEFAULT_WORKFLOW_CONFIG }; - plugin.settings.prompts.push(newPrompt); - // Expand the newly created prompt - callbacks.setExpandState({ promptId: newPrompt.id }); + plugin.settings.workflows.push(newWorkflow); + // Expand the newly created workflow + callbacks.setExpandState({ workflowId: newWorkflow.id }); await plugin.saveSettings(); callbacks.refresh(); })); - // Display each prompt - for (const prompt of plugin.settings.prompts) { - displayPromptSettings(containerEl, plugin, prompt, callbacks); + // Display each workflow + for (const workflow of plugin.settings.workflows) { + displayWorkflowSettings(containerEl, plugin, workflow, callbacks); } } -function displayPromptSettings( +function displayWorkflowSettings( containerEl: HTMLElement, plugin: AIToolboxPlugin, - prompt: PromptConfig, - callbacks: PromptSettingsCallbacks + workflow: WorkflowConfig, + callbacks: WorkflowSettingsCallbacks ): void { - const promptContainer = containerEl.createDiv('prompt-container'); + const workflowContainer = containerEl.createDiv('workflow-container'); const expandState = callbacks.getExpandState(); - // Check if this prompt should be expanded (newly added) - const shouldExpand = expandState.promptId === prompt.id; + // Check if this workflow should be expanded (newly added) + const shouldExpand = expandState.workflowId === workflow.id; // Collapsible content container - const contentContainer = promptContainer.createDiv(`prompt-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); + const contentContainer = workflowContainer.createDiv(`workflow-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - // Prompt header with collapse toggle, name and delete button - const headerSetting = new Setting(promptContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${prompt.name || 'Unnamed prompt'}`) + // Workflow header with collapse toggle, name and delete button + const headerSetting = new Setting(workflowContainer) + .setName(`${shouldExpand ? '▾' : '▸'} ${workflow.name || 'Unnamed workflow'}`) .setHeading() .addButton(button => button .setIcon('trash') - .setTooltip('Delete prompt') + .setTooltip('Delete workflow') .onClick(async () => { - const index = plugin.settings.prompts.findIndex(p => p.id === prompt.id); + const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); if (index !== -1) { - plugin.settings.prompts.splice(index, 1); + plugin.settings.workflows.splice(index, 1); await plugin.saveSettings(); callbacks.refresh(); } })); - headerSetting.settingEl.addClass('prompt-header'); + headerSetting.settingEl.addClass('workflow-header'); const toggleCollapse = () => { const isCollapsed = contentContainer.classList.contains('is-collapsed'); contentContainer.classList.toggle('is-collapsed', !isCollapsed); contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${prompt.name || 'Unnamed prompt'}`); + headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${workflow.name || 'Unnamed workflow'}`); }; headerSetting.settingEl.addEventListener('click', (e) => { @@ -107,23 +107,23 @@ function displayPromptSettings( }); // Move content container after header - promptContainer.appendChild(contentContainer); + workflowContainer.appendChild(contentContainer); - // Prompt name + // Workflow name new Setting(contentContainer) .setName('Name') - .setDesc('Display name for this prompt') + .setDesc('Display name for this workflow') .addText(text => text - .setValue(prompt.name) + .setValue(workflow.name) .onChange(async (value) => { - prompt.name = value; + workflow.name = value; const isExpanded = contentContainer.classList.contains('is-expanded'); - headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed prompt'}`); + headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed workflow'}`); await plugin.saveSettings(); })); // Provider/Model selection (only models that support chat) - displayPromptProviderSelection(contentContainer, plugin, prompt); + displayWorkflowProviderSelection(contentContainer, plugin, workflow); // Prompt text new Setting(contentContainer) @@ -132,13 +132,13 @@ function displayPromptSettings( .addTextArea(textArea => { textArea .setPlaceholder('Enter your prompt text here...') - .setValue(prompt.promptText) + .setValue(workflow.promptText) .onChange(async (value) => { - prompt.promptText = value; + workflow.promptText = value; await plugin.saveSettings(); }); textArea.inputEl.rows = 6; - textArea.inputEl.addClass('prompt-textarea'); + textArea.inputEl.addClass('workflow-textarea'); }); // Output type selection @@ -147,25 +147,25 @@ function displayPromptSettings( .setDesc('Choose how to display the AI response') .addDropdown(dropdown => dropdown .addOptions(OUTPUT_TYPE_OPTIONS) - .setValue(prompt.outputType || 'popup') + .setValue(workflow.outputType || 'popup') .onChange(async (value) => { - prompt.outputType = value as PromptOutputType; + workflow.outputType = value as WorkflowOutputType; await plugin.saveSettings(); })); - // Clear the expand state after rendering this prompt - if (expandState.promptId === prompt.id) { + // Clear the expand state after rendering this workflow + if (expandState.workflowId === workflow.id) { callbacks.setExpandState({}); } } -function displayPromptProviderSelection( +function displayWorkflowProviderSelection( containerEl: HTMLElement, plugin: AIToolboxPlugin, - prompt: PromptConfig + workflow: WorkflowConfig ): void { const providers = plugin.settings.providers; - const currentSelection = prompt.provider; + const currentSelection = workflow.provider; // Build options for provider/model dropdown (only models that support chat) const options: Record = { '': 'Select a provider and model' }; @@ -184,17 +184,17 @@ function displayPromptProviderSelection( new Setting(containerEl) .setName('Provider') - .setDesc('Select the provider and model to use for this prompt') + .setDesc('Select the provider and model to use for this workflow') .addDropdown(dropdown => dropdown .addOptions(options) .setValue(currentValue) .onChange(async (value) => { if (value === '') { - prompt.provider = null; + workflow.provider = null; } else { const parts = value.split(':'); if (parts.length === 2 && parts[0] && parts[1]) { - prompt.provider = { + workflow.provider = { providerId: parts[0], modelId: parts[1] }; diff --git a/src/workflows/index.ts b/src/workflows/index.ts new file mode 100644 index 0000000..2d6f4cf --- /dev/null +++ b/src/workflows/index.ts @@ -0,0 +1,3 @@ +export { WorkflowSuggesterModal } from './workflow-suggester'; +export { executeWorkflow, WorkflowResultModal } from './workflow-executor'; + diff --git a/src/prompts/prompt-executor.ts b/src/workflows/workflow-executor.ts similarity index 65% rename from src/prompts/prompt-executor.ts rename to src/workflows/workflow-executor.ts index 3ca3807..dd71d71 100644 --- a/src/prompts/prompt-executor.ts +++ b/src/workflows/workflow-executor.ts @@ -1,34 +1,34 @@ import { App, MarkdownView, Modal, Notice, TFile } from 'obsidian'; -import { PromptConfig, AIToolboxSettings } from '../settings'; -import { createPromptProvider, ChatMessage } from '../providers'; +import { WorkflowConfig, AIToolboxSettings } from '../settings'; +import { createWorkflowProvider, ChatMessage } from '../providers'; import { generateFilenameTimestamp } from '../utils/date-utils'; /** - * Modal to display the AI response from a prompt execution + * Modal to display the AI response from a workflow execution */ -export class PromptResultModal extends Modal { - private promptName: string; +export class WorkflowResultModal extends Modal { + private workflowName: string; private response: string; - constructor(app: App, promptName: string, response: string) { + constructor(app: App, workflowName: string, response: string) { super(app); - this.promptName = promptName; + this.workflowName = workflowName; this.response = response; } onOpen(): void { const { contentEl } = this; - contentEl.createEl('h2', { text: this.promptName }); + contentEl.createEl('h2', { text: this.workflowName }); - const responseContainer = contentEl.createDiv('prompt-response-container'); + const responseContainer = contentEl.createDiv('workflow-response-container'); responseContainer.createEl('pre', { text: this.response, - cls: 'prompt-response-content' + cls: 'workflow-response-content' }); // Add copy button - const buttonContainer = contentEl.createDiv('prompt-response-buttons'); + const buttonContainer = contentEl.createDiv('workflow-response-buttons'); const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); copyButton.addEventListener('click', async () => { await navigator.clipboard.writeText(this.response); @@ -70,20 +70,20 @@ function insertAtCursor(app: App, text: string): void { } /** - * Create a new note with the prompt result. + * Create a new note with the workflow result. * Honors Obsidian's default new note location setting. */ async function createNoteWithResult( app: App, - promptName: string, + workflowName: string, promptText: string, response: string ): Promise { const timestamp = generateFilenameTimestamp(); - const noteTitle = `${promptName} - ${timestamp}`; + const noteTitle = `${workflowName} - ${timestamp}`; // Build note content with prompt and response - const noteContent = `# ${promptName} + const noteContent = `# ${workflowName} ## Prompt @@ -137,70 +137,70 @@ ${response} } /** - * Execute a prompt using its configured provider and display the result. + * Execute a workflow using its configured provider and display the result. * * @param app - Obsidian App instance * @param settings - Plugin settings - * @param prompt - The prompt configuration to execute + * @param workflow - The workflow configuration to execute */ -export async function executePrompt( +export async function executeWorkflow( app: App, settings: AIToolboxSettings, - prompt: PromptConfig + workflow: WorkflowConfig ): Promise { - // Validate prompt has a provider configured - if (!prompt.provider) { - new Notice(`Prompt "${prompt.name}" has no provider configured. Please configure a provider in settings.`); + // Validate workflow has a provider configured + if (!workflow.provider) { + new Notice(`Workflow "${workflow.name}" has no provider configured. Please configure a provider in settings.`); return; } - // Validate prompt has text - if (!prompt.promptText.trim()) { - new Notice(`Prompt "${prompt.name}" has no prompt text. Please add prompt text in settings.`); + // Validate workflow has text + if (!workflow.promptText.trim()) { + new Notice(`Workflow "${workflow.name}" has no prompt text. Please add prompt text in settings.`); return; } // Create the provider - const provider = createPromptProvider(settings, prompt); + const provider = createWorkflowProvider(settings, workflow); if (!provider) { - new Notice(`Could not find the configured provider for prompt "${prompt.name}". Please check your settings.`); + new Notice(`Could not find the configured provider for workflow "${workflow.name}". Please check your settings.`); return; } // Validate provider supports chat if (!provider.supportsChat()) { - new Notice(`The provider for prompt "${prompt.name}" does not support chat. Please select a chat-capable model.`); + new Notice(`The provider for workflow "${workflow.name}" does not support chat. Please select a chat-capable model.`); return; } try { - new Notice(`Executing prompt: ${prompt.name}...`); + new Notice(`Executing workflow: ${workflow.name}...`); // Build chat messages with the prompt text as a user message const messages: ChatMessage[] = [ - { role: 'user', content: prompt.promptText } + { role: 'user', content: workflow.promptText } ]; // Send the chat request const result = await provider.sendChat(messages); // Handle output based on configured output type - const outputType = prompt.outputType || 'popup'; + const outputType = workflow.outputType || 'popup'; if (outputType === 'new-note') { - await createNoteWithResult(app, prompt.name, prompt.promptText, result.content); + await createNoteWithResult(app, workflow.name, workflow.promptText, result.content); } else if (outputType === 'at-cursor') { insertAtCursor(app, result.content); } else { // Default: show in popup modal - const resultModal = new PromptResultModal(app, prompt.name, result.content); + const resultModal = new WorkflowResultModal(app, workflow.name, result.content); resultModal.open(); } } catch (error) { - console.error('Prompt execution error:', error); + console.error('Workflow execution error:', error); const errorMessage = error instanceof Error ? error.message : String(error); - new Notice(`Failed to execute prompt: ${errorMessage}`); + new Notice(`Failed to execute workflow: ${errorMessage}`); } } diff --git a/src/workflows/workflow-suggester.ts b/src/workflows/workflow-suggester.ts new file mode 100644 index 0000000..756b318 --- /dev/null +++ b/src/workflows/workflow-suggester.ts @@ -0,0 +1,31 @@ +import { App, FuzzySuggestModal } from 'obsidian'; +import { WorkflowConfig } from '../settings'; + +/** + * Modal for selecting a workflow from the configured workflows list. + * Uses fuzzy search to filter workflows by name. + */ +export class WorkflowSuggesterModal extends FuzzySuggestModal { + private workflows: WorkflowConfig[]; + private onChoose: (workflow: WorkflowConfig) => void; + + constructor(app: App, workflows: WorkflowConfig[], onChoose: (workflow: WorkflowConfig) => void) { + super(app); + this.workflows = workflows; + this.onChoose = onChoose; + this.setPlaceholder('Select a workflow to execute...'); + } + + getItems(): WorkflowConfig[] { + return this.workflows; + } + + getItemText(workflow: WorkflowConfig): string { + return workflow.name; + } + + onChooseItem(workflow: WorkflowConfig): void { + this.onChoose(workflow); + } +} + diff --git a/styles.css b/styles.css index 17776e0..b7ac0c3 100644 --- a/styles.css +++ b/styles.css @@ -150,46 +150,46 @@ If your plugin does not need CSS, delete this file. height: 16px; } -/* Prompt container styles */ -.prompt-container { +/* Workflow container styles */ +.workflow-container { margin-bottom: 16px; border: 1px solid var(--background-modifier-border); border-radius: 8px; padding: 8px; } -.prompt-header { +.workflow-header { cursor: pointer; user-select: none; } -.prompt-header:hover .setting-item-name { +.workflow-header:hover .setting-item-name { color: var(--text-accent); } -.prompt-content.is-collapsed { +.workflow-content.is-collapsed { display: none; } -.prompt-content.is-expanded { +.workflow-content.is-expanded { display: block; padding-top: 8px; } -.prompt-textarea { +.workflow-textarea { width: 100%; min-height: 120px; resize: vertical; } -/* Prompt result modal styles */ -.prompt-response-container { +/* Workflow result modal styles */ +.workflow-response-container { margin: 16px 0; max-height: 400px; overflow-y: auto; } -.prompt-response-content { +.workflow-response-content { white-space: pre-wrap; word-wrap: break-word; padding: 12px; @@ -199,7 +199,7 @@ If your plugin does not need CSS, delete this file. line-height: 1.5; } -.prompt-response-buttons { +.workflow-response-buttons { display: flex; gap: 8px; justify-content: flex-end; From f18ba1253281711743dff0f4b77ec12e3e16ab3c Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Wed, 7 Jan 2026 17:11:12 -0600 Subject: [PATCH 05/22] add test buttons to the models. --- src/settings/providers.ts | 305 +++++++++++++++++++++++++++++++++++++- src/settings/workflows.ts | 2 +- styles.css | 15 ++ 3 files changed, 319 insertions(+), 3 deletions(-) diff --git a/src/settings/providers.ts b/src/settings/providers.ts index baced6c..26fd777 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -1,12 +1,17 @@ -import { Setting, setIcon } from "obsidian"; +import { Setting, setIcon, ButtonComponent, Notice } from "obsidian"; import AIToolboxPlugin from "../main"; import { AIProviderConfig, AIProviderType, AIModelConfig, ExpandOnNextRenderState, - generateId + generateId, + DEFAULT_OPENAI_ENDPOINT } from "./types"; +import { createModelProvider, ModelProviderConfig } from "../providers"; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; /** * Callbacks for the provider settings tab to communicate with the main settings tab @@ -17,6 +22,218 @@ export interface ProviderSettingsCallbacks { refresh: () => void; } +/** + * State for test button + */ +type TestButtonState = 'ready' | 'testing' | 'success' | 'error'; + +/** + * Generate a minimal valid WAV file with audio content + * Creates a 1-second WAV file at 16kHz mono with a simple tone pattern + */ +function generateTestAudioFile(): Buffer { + const sampleRate = 16000; + const duration = 1; // 1 second + const numSamples = sampleRate * duration; + const numChannels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = numSamples * blockAlign; + const fileSize = 36 + dataSize; + + const buffer = Buffer.alloc(44 + dataSize); + let offset = 0; + + // RIFF header + buffer.write('RIFF', offset); offset += 4; + buffer.writeUInt32LE(fileSize, offset); offset += 4; + buffer.write('WAVE', offset); offset += 4; + + // fmt chunk + buffer.write('fmt ', offset); offset += 4; + buffer.writeUInt32LE(16, offset); offset += 4; // fmt chunk size + buffer.writeUInt16LE(1, offset); offset += 2; // audio format (1 = PCM) + buffer.writeUInt16LE(numChannels, offset); offset += 2; + buffer.writeUInt32LE(sampleRate, offset); offset += 4; + buffer.writeUInt32LE(byteRate, offset); offset += 4; + buffer.writeUInt16LE(blockAlign, offset); offset += 2; + buffer.writeUInt16LE(bitsPerSample, offset); offset += 2; + + // data chunk + buffer.write('data', offset); offset += 4; + buffer.writeUInt32LE(dataSize, offset); offset += 4; + + // Generate audio data - simple pattern that sounds like speech + // Using multiple frequencies to create a more natural sound + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + // Mix of frequencies to simulate speech-like sound + const freq1 = 200 + Math.sin(t * 10) * 50; // Varying fundamental frequency + const freq2 = 800 + Math.sin(t * 15) * 100; // First formant + const freq3 = 2400; // Second formant + + // Envelope to create word-like pattern + const envelope = Math.sin(t * Math.PI) * 0.3; + + const sample = envelope * ( + Math.sin(2 * Math.PI * freq1 * t) * 0.5 + + Math.sin(2 * Math.PI * freq2 * t) * 0.3 + + Math.sin(2 * Math.PI * freq3 * t) * 0.2 + ); + + const value = Math.floor(sample * 32767); + buffer.writeInt16LE(value, offset); + offset += 2; + } + + return buffer; +} + +/** + * Get a temporary test audio file path with the embedded test audio + */ +function getTestAudioFile(): string { + const tempDir = os.tmpdir(); + const testAudioPath = path.join(tempDir, 'obsidian-ai-toolbox-test.wav'); + + // Always regenerate the test audio file to ensure it's valid + const audioBuffer = generateTestAudioFile(); + fs.writeFileSync(testAudioPath, audioBuffer); + + return testAudioPath; +} + +/** + * Check if a model has all required configuration for testing + */ +function isModelConfigComplete(provider: AIProviderConfig, model: AIModelConfig): boolean { + // Must have at least one capability enabled + if (!model.supportsChat && !model.supportsTranscription) { + return false; + } + + // Must have API key + if (!provider.apiKey) { + return false; + } + + // Must have model ID + if (!model.modelId) { + return false; + } + + // Azure-specific requirements + if (provider.type === 'azure-openai') { + if (!provider.endpoint) { + return false; + } + if (!model.deploymentName) { + return false; + } + } + + return true; +} + +/** + * Build a ModelProviderConfig from provider and model settings + */ +function buildProviderConfig(provider: AIProviderConfig, model: AIModelConfig): ModelProviderConfig { + const endpoint = provider.endpoint || (provider.type === 'openai' ? DEFAULT_OPENAI_ENDPOINT : ''); + return { + id: provider.id, + name: provider.name, + modelDisplayName: model.name, + type: provider.type, + endpoint: endpoint, + apiKey: provider.apiKey, + modelId: model.modelId, + deploymentName: model.deploymentName || model.modelId, + supportsChat: model.supportsChat, + supportsTranscription: model.supportsTranscription, + }; +} + +/** + * Test a model's chat capability + */ +async function testModelChat(provider: AIProviderConfig, model: AIModelConfig): Promise<{ success: boolean; error?: string }> { + try { + const config = buildProviderConfig(provider, model); + const modelProvider = createModelProvider(config); + + // Send minimal test message + await modelProvider.sendChat([ + { role: 'user', content: 'Hello' } + ], { + maxTokens: 10 // Keep it minimal to reduce cost + }); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMessage }; + } +} + +/** + * Test a model's transcription capability + */ +async function testModelTranscription(provider: AIProviderConfig, model: AIModelConfig): Promise<{ success: boolean; error?: string }> { + try { + const config = buildProviderConfig(provider, model); + const modelProvider = createModelProvider(config); + + // Get test audio file + const testAudioPath = getTestAudioFile(); + + // Transcribe the test audio + await modelProvider.transcribeAudio(testAudioPath, { + includeTimestamps: false + }); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMessage }; + } +} + +/** + * Test a model based on its enabled capabilities + */ +async function testModel(provider: AIProviderConfig, model: AIModelConfig): Promise<{ success: boolean; error?: string }> { + const results: Array<{ capability: string; success: boolean; error?: string }> = []; + + // Test chat if enabled + if (model.supportsChat) { + const chatResult = await testModelChat(provider, model); + results.push({ capability: 'Chat', ...chatResult }); + } + + // Test transcription if enabled + if (model.supportsTranscription) { + const transcriptionResult = await testModelTranscription(provider, model); + results.push({ capability: 'Transcription', ...transcriptionResult }); + } + + // Check if all tests passed + const allPassed = results.every(r => r.success); + + if (allPassed) { + return { success: true }; + } else { + // Collect error messages + const errors = results + .filter(r => !r.success) + .map(r => `${r.capability}: ${r.error}`) + .join('; '); + return { success: false, error: errors }; + } +} + /** * Display the providers settings tab content */ @@ -269,6 +486,48 @@ function displayModelSettings( await plugin.saveSettings(); })); + // Test button - declare early so it can be referenced in field change handlers + let testButton: ButtonComponent; + let testButtonState: TestButtonState = 'ready'; + + const updateTestButton = () => { + if (!testButton) return; + + const isConfigComplete = isModelConfigComplete(provider, model); + testButton.setDisabled(!isConfigComplete || testButtonState === 'testing'); + + // Update tooltip based on enabled capabilities + const capabilities: string[] = []; + if (model.supportsChat) capabilities.push('chat'); + if (model.supportsTranscription) capabilities.push('transcription'); + const capabilityText = capabilities.length > 0 + ? capabilities.join(' and ') + : 'model'; + testButton.setTooltip(`Test ${capabilityText} capability`); + + // Update button text and class based on state + switch (testButtonState) { + case 'ready': + testButton.setButtonText('Test'); + testButton.buttonEl.removeClass('mod-warning', 'mod-success'); + break; + case 'testing': + testButton.setButtonText('Testing...'); + testButton.buttonEl.removeClass('mod-warning', 'mod-success'); + break; + case 'success': + testButton.setButtonText('✓ Success'); + testButton.buttonEl.removeClass('mod-warning'); + testButton.buttonEl.addClass('mod-success'); + break; + case 'error': + testButton.setButtonText('✗ Failed'); + testButton.buttonEl.removeClass('mod-success'); + testButton.buttonEl.addClass('mod-warning'); + break; + } + }; + // Deployment name (for Azure) if (provider.type === 'azure-openai') { new Setting(contentContainer) @@ -280,6 +539,7 @@ function displayModelSettings( .onChange(async (value) => { model.deploymentName = value; await plugin.saveSettings(); + updateTestButton(); })); } @@ -293,6 +553,7 @@ function displayModelSettings( .onChange(async (value) => { model.modelId = value; await plugin.saveSettings(); + updateTestButton(); })); // Model capabilities section @@ -305,6 +566,8 @@ function displayModelSettings( .onChange(async (value) => { model.supportsChat = value; await plugin.saveSettings(); + // Update test button state when capability changes + updateTestButton(); })); const chatNameEl = chatSetting.nameEl; const chatIcon = chatNameEl.createSpan({ cls: 'model-capability-icon' }); @@ -318,10 +581,48 @@ function displayModelSettings( .onChange(async (value) => { model.supportsTranscription = value; await plugin.saveSettings(); + // Update test button state when capability changes + updateTestButton(); })); const transcriptionNameEl = transcriptionSetting.nameEl; const transcriptionIcon = transcriptionNameEl.createSpan({ cls: 'model-capability-icon' }); setIcon(transcriptionIcon, 'audio-lines'); transcriptionNameEl.appendText(' Transcription'); + + // Test button (inline with capabilities) + new Setting(capabilitiesContainer) + .addButton(button => { + testButton = button; + + button.onClick(async () => { + // Reset to testing state + testButtonState = 'testing'; + updateTestButton(); + + // Run the test + const result = await testModel(provider, model); + + // Update state based on result + if (result.success) { + testButtonState = 'success'; + new Notice('✓ Model test successful'); + } else { + testButtonState = 'error'; + // Show error as Notice and log to console + new Notice(`✗ Model test failed: ${result.error}`, 5000); + console.error('Model test failed:', result.error); + } + updateTestButton(); + + // Reset to ready state after 3 seconds + setTimeout(() => { + testButtonState = 'ready'; + updateTestButton(); + }, 3000); + }); + + // Initial state + updateTestButton(); + }); } diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index 46b928c..a8faa77 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -36,7 +36,7 @@ export function displayWorkflowsSettings( ): void { // Add workflow button new Setting(containerEl) - .setName('Custom workflows') + .setName('Workflows') .setDesc('Configure custom workflows to use with your AI providers') .addButton(button => button .setButtonText('Add workflow') diff --git a/styles.css b/styles.css index b7ac0c3..b1c2c17 100644 --- a/styles.css +++ b/styles.css @@ -126,6 +126,7 @@ If your plugin does not need CSS, delete this file. .model-capabilities { display: flex; flex-direction: row; + align-items: center; gap: 16px; margin-top: 8px; padding-top: 8px; @@ -138,6 +139,10 @@ If your plugin does not need CSS, delete this file. border: none; } +.model-capabilities .setting-item:last-child { + margin-left: auto; +} + .model-capability-icon { display: inline-flex; align-items: center; @@ -205,3 +210,13 @@ If your plugin does not need CSS, delete this file. justify-content: flex-end; margin-top: 16px; } + +/* Test button styles */ +button.mod-success { + background-color: #2d7a3e; + color: var(--text-on-accent); +} + +button.mod-success:hover { + background-color: #256632; +} From 22c86f4ca24dfb469b1678295b361771e59f5567 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Wed, 7 Jan 2026 17:24:51 -0600 Subject: [PATCH 06/22] stuff --- AGENTS.md | 1 + src/main.ts | 2 +- src/settings/types.ts | 6 +++++- src/settings/workflows.ts | 44 ++++++++++++++++++++++++++++++++------- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f4274a..cf523ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particula - Provide defaults and validation in settings. - Write idempotent code paths so reload/unload doesn't leak listeners or intervals. - Use `this.register*` helpers for everything that needs cleanup. +- When refreshing collapsible settings sections, preserve the expand state by setting `callbacks.setExpandState({ workflowId: workflow.id })` before calling `callbacks.refresh()` if the section is currently expanded. **Don't** - Introduce network calls without an obvious user-facing reason and documentation. diff --git a/src/main.ts b/src/main.ts index e4d85a5..48e9b1e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ export default class AIToolboxPlugin extends Plugin { // Add command to execute custom workflows this.addCommand({ id: 'execute-workflow', - name: 'Execute custom workflow', + name: 'Run AI Workflow', callback: () => this.showWorkflowSuggester() }); diff --git a/src/settings/types.ts b/src/settings/types.ts index 8194a88..2eb2d1d 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -54,6 +54,8 @@ export interface WorkflowConfig { promptText: string; provider: ProviderModelSelection | null; outputType: WorkflowOutputType; + showInCommand: boolean; + availableAsInput: boolean; } /** @@ -63,7 +65,9 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { name: 'New workflow', promptText: '', provider: null, - outputType: 'popup' + outputType: 'popup', + showInCommand: true, + availableAsInput: false }; export interface AIToolboxSettings { diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index a8faa77..a8e51ef 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -141,15 +141,45 @@ function displayWorkflowSettings( textArea.inputEl.addClass('workflow-textarea'); }); - // Output type selection + // List in Workload Command toggle new Setting(contentContainer) - .setName('Output type') - .setDesc('Choose how to display the AI response') - .addDropdown(dropdown => dropdown - .addOptions(OUTPUT_TYPE_OPTIONS) - .setValue(workflow.outputType || 'popup') + .setName('List in "Run AI Workflow" Command') + .setDesc('Show this workflow in the list when running the "Run AI Workflow" command') + .addToggle(toggle => toggle + .setValue(workflow.showInCommand ?? true) + .onChange(async (value) => { + workflow.showInCommand = value; + await plugin.saveSettings(); + // Preserve expand state when refreshing + const isExpanded = contentContainer.classList.contains('is-expanded'); + if (isExpanded) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + })); + + // Output type selection (only show if workflow is listed in command) + if (workflow.showInCommand ?? true) { + new Setting(contentContainer) + .setName('Output type') + .setDesc('Choose how to display the AI response') + .addDropdown(dropdown => dropdown + .addOptions(OUTPUT_TYPE_OPTIONS) + .setValue(workflow.outputType || 'popup') + .onChange(async (value) => { + workflow.outputType = value as WorkflowOutputType; + await plugin.saveSettings(); + })); + } + + // Make available as input to other workflows toggle + new Setting(contentContainer) + .setName('Make available as input to other workflows') + .setDesc('Allow this workflow to be used as input context for other workflows') + .addToggle(toggle => toggle + .setValue(workflow.availableAsInput ?? false) .onChange(async (value) => { - workflow.outputType = value as WorkflowOutputType; + workflow.availableAsInput = value; await plugin.saveSettings(); })); From 805def9412c16e8e0592a14605deee24936afaf7 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 7 Jan 2026 19:27:50 -0600 Subject: [PATCH 07/22] add new note --- src/main.ts | 8 +++--- src/settings/types.ts | 2 ++ src/settings/workflows.ts | 42 ++++++++++++++++++++++-------- src/workflows/workflow-executor.ts | 27 ++++++++++++------- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/main.ts b/src/main.ts index 48e9b1e..ee10494 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,14 +30,14 @@ export default class AIToolboxPlugin extends Plugin { * Show the workflow suggester modal and execute the selected workflow. */ private showWorkflowSuggester(): void { - const workflows = this.settings.workflows; + const availableWorkflows = this.settings.workflows.filter(w => w.showInCommand); - if (workflows.length === 0) { - new Notice('No workflows configured. Please add workflows in settings.'); + if (availableWorkflows.length === 0) { + new Notice('No workflows available. You can configure workloads from the "Workflows" tab in settings.'); return; } - const modal = new WorkflowSuggesterModal(this.app, workflows, (workflow) => { + const modal = new WorkflowSuggesterModal(this.app, availableWorkflows, (workflow) => { void executeWorkflow(this.app, this.settings, workflow); }); modal.open(); diff --git a/src/settings/types.ts b/src/settings/types.ts index 2eb2d1d..40a890a 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -54,6 +54,7 @@ export interface WorkflowConfig { promptText: string; provider: ProviderModelSelection | null; outputType: WorkflowOutputType; + outputFolder: string; showInCommand: boolean; availableAsInput: boolean; } @@ -66,6 +67,7 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { promptText: '', provider: null, outputType: 'popup', + outputFolder: '', showInCommand: true, availableAsInput: false }; diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index a8e51ef..a623fc8 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -141,6 +141,17 @@ function displayWorkflowSettings( textArea.inputEl.addClass('workflow-textarea'); }); + // Make available as input to other workflows toggle + new Setting(contentContainer) + .setName('Make available as input to other workflows') + .setDesc('Allow this workflow to be used as input context for other workflows') + .addToggle(toggle => toggle + .setValue(workflow.availableAsInput ?? false) + .onChange(async (value) => { + workflow.availableAsInput = value; + await plugin.saveSettings(); + })); + // List in Workload Command toggle new Setting(contentContainer) .setName('List in "Run AI Workflow" Command') @@ -169,19 +180,28 @@ function displayWorkflowSettings( .onChange(async (value) => { workflow.outputType = value as WorkflowOutputType; await plugin.saveSettings(); + // Preserve expand state when refreshing (output folder visibility may change) + const isExpanded = contentContainer.classList.contains('is-expanded'); + if (isExpanded) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); })); - } - // Make available as input to other workflows toggle - new Setting(contentContainer) - .setName('Make available as input to other workflows') - .setDesc('Allow this workflow to be used as input context for other workflows') - .addToggle(toggle => toggle - .setValue(workflow.availableAsInput ?? false) - .onChange(async (value) => { - workflow.availableAsInput = value; - await plugin.saveSettings(); - })); + // Output folder (only show if output type is new-note) + if (workflow.outputType === 'new-note') { + new Setting(contentContainer) + .setName('Output folder') + .setDesc('Folder where notes will be created (leave empty to use default)') + .addText(text => text + .setPlaceholder('Default folder') + .setValue(workflow.outputFolder || '') + .onChange(async (value) => { + workflow.outputFolder = value; + await plugin.saveSettings(); + })); + } + } // Clear the expand state after rendering this workflow if (expandState.workflowId === workflow.id) { diff --git a/src/workflows/workflow-executor.ts b/src/workflows/workflow-executor.ts index dd71d71..a003e48 100644 --- a/src/workflows/workflow-executor.ts +++ b/src/workflows/workflow-executor.ts @@ -77,7 +77,8 @@ async function createNoteWithResult( app: App, workflowName: string, promptText: string, - response: string + response: string, + outputFolder?: string ): Promise { const timestamp = generateFilenameTimestamp(); const noteTitle = `${workflowName} - ${timestamp}`; @@ -97,17 +98,25 @@ ${response} *Generated on ${new Date().toLocaleString()}* `; - // Get the default new note location from Obsidian's vault config - // This method exists but is not in the public type definitions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const vault = app.vault as any; - const newFileFolderPath = vault.getConfig?.('newFileFolderPath') as string | undefined; + // Determine folder path: use workflow-specific folder if set, otherwise fall back to Obsidian's default + let folderPath: string | undefined; + if (outputFolder && outputFolder.trim() !== '') { + folderPath = outputFolder.trim().replace(/\/$/, ''); + } else { + // Get the default new note location from Obsidian's vault config + // This method exists but is not in the public type definitions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vault = app.vault as any; + const newFileFolderPath = vault.getConfig?.('newFileFolderPath') as string | undefined; + if (newFileFolderPath && newFileFolderPath.trim() !== '') { + folderPath = newFileFolderPath.replace(/\/$/, ''); + } + } // Build the file path let filePath: string; - if (newFileFolderPath && newFileFolderPath.trim() !== '') { + if (folderPath) { // Ensure folder exists - const folderPath = newFileFolderPath.replace(/\/$/, ''); const folder = app.vault.getAbstractFileByPath(folderPath); if (!folder) { await app.vault.createFolder(folderPath); @@ -188,7 +197,7 @@ export async function executeWorkflow( const outputType = workflow.outputType || 'popup'; if (outputType === 'new-note') { - await createNoteWithResult(app, workflow.name, workflow.promptText, result.content); + await createNoteWithResult(app, workflow.name, workflow.promptText, result.content, workflow.outputFolder); } else if (outputType === 'at-cursor') { insertAtCursor(app, result.content); } else { From 19717a8fa120ce8d347eaf8be9bc8f992cc3a70f Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 7 Jan 2026 19:55:56 -0600 Subject: [PATCH 08/22] componentize folders --- src/components/collapsible-section.ts | 128 +++++++++++++++++++++++++ src/components/folder-suggest.ts | 62 ++++++++++++ src/settings/providers.ts | 131 ++++++++------------------ src/settings/transcription.ts | 46 +++++---- src/settings/workflows.ts | 83 ++++++---------- styles.css | 6 +- 6 files changed, 287 insertions(+), 169 deletions(-) create mode 100644 src/components/collapsible-section.ts create mode 100644 src/components/folder-suggest.ts diff --git a/src/components/collapsible-section.ts b/src/components/collapsible-section.ts new file mode 100644 index 0000000..a2379d5 --- /dev/null +++ b/src/components/collapsible-section.ts @@ -0,0 +1,128 @@ +import { Setting } from "obsidian"; + +/** + * Configuration options for creating a collapsible section + */ +export interface CollapsibleSectionConfig { + /** Container element where the section will be created */ + containerEl: HTMLElement; + /** Display name for the section header */ + title: string; + /** CSS class for the main container (e.g., 'provider-container', 'workflow-container') */ + containerClass: string; + /** CSS class for the content area (e.g., 'provider-content', 'workflow-content') */ + contentClass: string; + /** CSS class for the header (e.g., 'provider-header', 'workflow-header') */ + headerClass: string; + /** Whether this section should start expanded */ + startExpanded: boolean; + /** Whether to show as a heading (bold) - defaults to true */ + isHeading?: boolean; + /** Callback when the delete button is clicked (if provided, delete button is shown) */ + onDelete?: () => void; + /** Callback when the title changes (for dynamic name updates) */ + onTitleChange?: (newTitle: string) => void; +} + +/** + * Result from creating a collapsible section + */ +export interface CollapsibleSectionResult { + /** The main container element */ + container: HTMLElement; + /** The collapsible content container (add your content here) */ + contentContainer: HTMLElement; + /** The header setting element */ + headerSetting: Setting; + /** Update the displayed title (with arrow indicator) */ + updateTitle: (newTitle: string) => void; + /** Check if the section is currently expanded */ + isExpanded: () => boolean; +} + +/** + * Creates a collapsible section with consistent styling and behavior. + * Handles expand/collapse toggle, arrow icons, and optional delete button. + */ +export function createCollapsibleSection(config: CollapsibleSectionConfig): CollapsibleSectionResult { + const { + containerEl, + title, + containerClass, + contentClass, + headerClass, + startExpanded, + isHeading = true, + onDelete, + } = config; + + const container = containerEl.createDiv(containerClass); + + // Create content container first (will be moved after header) + const contentContainer = container.createDiv( + `${contentClass} ${startExpanded ? 'is-expanded' : 'is-collapsed'}` + ); + + // Track current title for updates + let currentTitle = title; + + // Helper to get the formatted title with arrow + const getFormattedTitle = (name: string, expanded: boolean): string => { + return `${expanded ? '▾' : '▸'} ${name || 'Unnamed'}`; + }; + + // Create header with collapse toggle + const headerSetting = new Setting(container) + .setName(getFormattedTitle(title, startExpanded)); + + if (isHeading) { + headerSetting.setHeading(); + } + + // Add delete button if callback provided + if (onDelete) { + headerSetting.addButton(button => button + .setIcon('trash') + .setTooltip('Delete') + .onClick(onDelete)); + } + + headerSetting.settingEl.addClass(headerClass); + + // Toggle function + const toggleCollapse = () => { + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + contentContainer.classList.toggle('is-collapsed', !isCollapsed); + contentContainer.classList.toggle('is-expanded', isCollapsed); + headerSetting.setName(getFormattedTitle(currentTitle, isCollapsed)); + }; + + // Add click handler to header (excluding buttons) + headerSetting.settingEl.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).closest('button')) { + toggleCollapse(); + } + }); + + // Move content container after header + container.appendChild(contentContainer); + + // Update title function + const updateTitle = (newTitle: string) => { + currentTitle = newTitle; + const isExpanded = contentContainer.classList.contains('is-expanded'); + headerSetting.setName(getFormattedTitle(newTitle, isExpanded)); + }; + + // Check if expanded + const isExpanded = () => contentContainer.classList.contains('is-expanded'); + + return { + container, + contentContainer, + headerSetting, + updateTitle, + isExpanded, + }; +} + diff --git a/src/components/folder-suggest.ts b/src/components/folder-suggest.ts new file mode 100644 index 0000000..7c91b0f --- /dev/null +++ b/src/components/folder-suggest.ts @@ -0,0 +1,62 @@ +import { App, AbstractInputSuggest, TFolder } from "obsidian"; + +/** + * A folder suggestion component that provides autocomplete for vault folder paths. + * Uses Obsidian's AbstractInputSuggest to show folder suggestions as the user types. + */ +export class FolderSuggest extends AbstractInputSuggest { + private textInputEl: HTMLInputElement; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.textInputEl = inputEl; + } + + getSuggestions(inputStr: string): TFolder[] { + const inputLower = inputStr.toLowerCase().trim(); + const allFolders = this.getAllFolders(); + + if (inputLower === "") { + return allFolders; + } + + return allFolders.filter(folder => + folder.path.toLowerCase().includes(inputLower) + ); + } + + renderSuggestion(folder: TFolder, el: HTMLElement): void { + const displayPath = folder.path === "" ? "/" : folder.path; + el.createEl("div", { text: displayPath, cls: "folder-suggest-item" }); + } + + selectSuggestion(folder: TFolder): void { + this.textInputEl.value = folder.path; + this.textInputEl.dispatchEvent(new Event("input", { bubbles: true })); + this.close(); + } + + private getAllFolders(): TFolder[] { + const folders: TFolder[] = []; + + // Add root folder + const rootFolder = this.app.vault.getRoot(); + if (rootFolder) { + folders.push(rootFolder); + } + + // Add all other folders + const allFolders = this.app.vault.getAllFolders(); + folders.push(...allFolders); + + // Sort folders by path for better UX + folders.sort((a, b) => { + if (a.path === "") return -1; + if (b.path === "") return 1; + return a.path.localeCompare(b.path); + }); + + return folders; + } +} + diff --git a/src/settings/providers.ts b/src/settings/providers.ts index 26fd777..b3d5c33 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -9,6 +9,7 @@ import { DEFAULT_OPENAI_ENDPOINT } from "./types"; import { createModelProvider, ModelProviderConfig } from "../providers"; +import { createCollapsibleSection } from "../components/collapsible-section"; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -275,54 +276,30 @@ function displayProviderSettings( provider: AIProviderConfig, callbacks: ProviderSettingsCallbacks ): void { - const providerContainer = containerEl.createDiv('provider-container'); const expandState = callbacks.getExpandState(); - - // Check if this provider should be expanded (has a newly added model) const shouldExpand = expandState.providerId === provider.id; - // Collapsible content container - const contentContainer = providerContainer.createDiv(`provider-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - - // Provider header with collapse toggle, name and delete button - const headerSetting = new Setting(providerContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`) - .setHeading() - .addButton(button => button - .setIcon('trash') - .setTooltip('Delete provider') - .onClick(async () => { - const index = plugin.settings.providers.findIndex(p => p.id === provider.id); - if (index !== -1) { - plugin.settings.providers.splice(index, 1); - // Clear transcription provider if it was using this provider - if (plugin.settings.transcriptionProvider?.providerId === provider.id) { - plugin.settings.transcriptionProvider = null; - } - await plugin.saveSettings(); - callbacks.refresh(); + const { contentContainer, updateTitle } = createCollapsibleSection({ + containerEl, + title: provider.name || 'Unnamed provider', + containerClass: 'provider-container', + contentClass: 'provider-content', + headerClass: 'provider-header', + startExpanded: shouldExpand, + isHeading: true, + onDelete: async () => { + const index = plugin.settings.providers.findIndex(p => p.id === provider.id); + if (index !== -1) { + plugin.settings.providers.splice(index, 1); + if (plugin.settings.transcriptionProvider?.providerId === provider.id) { + plugin.settings.transcriptionProvider = null; } - })); - - headerSetting.settingEl.addClass('provider-header'); - - const toggleCollapse = () => { - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - contentContainer.classList.toggle('is-collapsed', !isCollapsed); - contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${provider.name || 'Unnamed provider'}`); - }; - - headerSetting.settingEl.addEventListener('click', (e) => { - // Don't toggle if clicking on the delete button - if (!(e.target as HTMLElement).closest('button')) { - toggleCollapse(); - } + await plugin.saveSettings(); + callbacks.refresh(); + } + }, }); - // Move content container after header - providerContainer.appendChild(contentContainer); - // Provider name new Setting(contentContainer) .setName('Name') @@ -331,8 +308,7 @@ function displayProviderSettings( .setValue(provider.name) .onChange(async (value) => { provider.name = value; - const isExpanded = contentContainer.classList.contains('is-expanded'); - headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed provider'}`); + updateTitle(value || 'Unnamed provider'); await plugin.saveSettings(); })); @@ -422,57 +398,31 @@ function displayModelSettings( model: AIModelConfig, callbacks: ProviderSettingsCallbacks ): void { - const modelContainer = containerEl.createDiv('model-container'); const expandState = callbacks.getExpandState(); - - // Check if this model should be expanded (newly added) const shouldExpand = expandState.modelId === model.id; - // Collapsible content container - const contentContainer = modelContainer.createDiv(`model-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - - const modelDisplayName = model.name || 'Unnamed model'; - - // Model header with collapse toggle and delete button - const headerSetting = new Setting(modelContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${modelDisplayName}`) - .addButton(button => button - .setIcon('trash') - .setTooltip('Delete model') - .onClick(async () => { - const index = provider.models.findIndex(m => m.id === model.id); - if (index !== -1) { - provider.models.splice(index, 1); - // Clear transcription provider if it was using this model - if (plugin.settings.transcriptionProvider?.modelId === model.id) { - plugin.settings.transcriptionProvider = null; - } - // Keep provider expanded after deletion - callbacks.setExpandState({ providerId: provider.id }); - await plugin.saveSettings(); - callbacks.refresh(); + const { contentContainer, updateTitle } = createCollapsibleSection({ + containerEl, + title: model.name || 'Unnamed model', + containerClass: 'model-container', + contentClass: 'model-content', + headerClass: 'model-header', + startExpanded: shouldExpand, + isHeading: false, + onDelete: async () => { + const index = provider.models.findIndex(m => m.id === model.id); + if (index !== -1) { + provider.models.splice(index, 1); + if (plugin.settings.transcriptionProvider?.modelId === model.id) { + plugin.settings.transcriptionProvider = null; } - })); - - headerSetting.settingEl.addClass('model-header'); - - const toggleCollapse = () => { - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - contentContainer.classList.toggle('is-collapsed', !isCollapsed); - contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${model.name || 'Unnamed model'}`); - }; - - headerSetting.settingEl.addEventListener('click', (e) => { - // Don't toggle if clicking on the delete button - if (!(e.target as HTMLElement).closest('button')) { - toggleCollapse(); - } + callbacks.setExpandState({ providerId: provider.id }); + await plugin.saveSettings(); + callbacks.refresh(); + } + }, }); - // Move content container after header - modelContainer.appendChild(contentContainer); - // Model name new Setting(contentContainer) .setName('Display name') @@ -481,8 +431,7 @@ function displayModelSettings( .setValue(model.name) .onChange(async (value) => { model.name = value; - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - headerSetting.setName(`${isCollapsed ? '▸' : '▾'} ${value || 'Unnamed model'}`); + updateTitle(value || 'Unnamed model'); await plugin.saveSettings(); })); diff --git a/src/settings/transcription.ts b/src/settings/transcription.ts index 351f0d7..3377f73 100644 --- a/src/settings/transcription.ts +++ b/src/settings/transcription.ts @@ -1,5 +1,7 @@ import { Setting } from "obsidian"; import AIToolboxPlugin from "../main"; +import { FolderSuggest } from "../components/folder-suggest"; +import { createCollapsibleSection } from "../components/collapsible-section"; /** * Callbacks for the transcription settings tab to communicate with the main settings tab @@ -59,12 +61,16 @@ export function displayTranscriptionSettings( new Setting(containerEl) .setName('Notes folder') .setDesc('Folder where transcription notes will be created (leave empty for vault root)') - .addText(text => text - .setValue(plugin.settings.outputFolder) - .onChange(async (value) => { - plugin.settings.outputFolder = value; - await plugin.saveSettings(); - })); + .addSearch(search => { + search + .setPlaceholder('Vault root') + .setValue(plugin.settings.outputFolder) + .onChange(async (value) => { + plugin.settings.outputFolder = value; + await plugin.saveSettings(); + }); + new FolderSuggest(plugin.app, search.inputEl); + }); new Setting(containerEl) .setName('Keep video file') @@ -90,25 +96,15 @@ export function displayTranscriptionSettings( })); } - const advancedContainer = containerEl.createDiv('settings-advanced-container is-collapsed'); - - const advancedSetting = new Setting(containerEl) - .setName('▸ Advanced') // eslint-disable-line obsidianmd/ui/sentence-case - .setHeading(); - - advancedSetting.settingEl.addClass('settings-advanced-heading'); - - const toggleAdvanced = () => { - const isCollapsed = advancedContainer.classList.contains('is-collapsed'); - advancedContainer.classList.toggle('is-collapsed', !isCollapsed); - advancedContainer.classList.toggle('is-expanded', isCollapsed); - advancedSetting.setName(`${isCollapsed ? '▾' : '▸'} Advanced`); - }; - - advancedSetting.settingEl.addEventListener('click', toggleAdvanced); - - // Move the advancedContainer after the heading - containerEl.appendChild(advancedContainer); + const { contentContainer: advancedContainer } = createCollapsibleSection({ + containerEl, + title: 'Advanced', + containerClass: 'settings-advanced-section', + contentClass: 'settings-advanced-container', + headerClass: 'settings-advanced-heading', + startExpanded: false, + isHeading: true, + }); new Setting(advancedContainer) .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index a623fc8..1ed73c0 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -7,6 +7,8 @@ import { generateId, DEFAULT_WORKFLOW_CONFIG } from "./types"; +import { FolderSuggest } from "../components/folder-suggest"; +import { createCollapsibleSection } from "../components/collapsible-section"; /** * Callbacks for the workflows settings tab to communicate with the main settings tab @@ -65,50 +67,27 @@ function displayWorkflowSettings( workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks ): void { - const workflowContainer = containerEl.createDiv('workflow-container'); const expandState = callbacks.getExpandState(); - - // Check if this workflow should be expanded (newly added) const shouldExpand = expandState.workflowId === workflow.id; - // Collapsible content container - const contentContainer = workflowContainer.createDiv(`workflow-content ${shouldExpand ? 'is-expanded' : 'is-collapsed'}`); - - // Workflow header with collapse toggle, name and delete button - const headerSetting = new Setting(workflowContainer) - .setName(`${shouldExpand ? '▾' : '▸'} ${workflow.name || 'Unnamed workflow'}`) - .setHeading() - .addButton(button => button - .setIcon('trash') - .setTooltip('Delete workflow') - .onClick(async () => { - const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); - if (index !== -1) { - plugin.settings.workflows.splice(index, 1); - await plugin.saveSettings(); - callbacks.refresh(); - } - })); - - headerSetting.settingEl.addClass('workflow-header'); - - const toggleCollapse = () => { - const isCollapsed = contentContainer.classList.contains('is-collapsed'); - contentContainer.classList.toggle('is-collapsed', !isCollapsed); - contentContainer.classList.toggle('is-expanded', isCollapsed); - headerSetting.setName(`${isCollapsed ? '▾' : '▸'} ${workflow.name || 'Unnamed workflow'}`); - }; - - headerSetting.settingEl.addEventListener('click', (e) => { - // Don't toggle if clicking on the delete button - if (!(e.target as HTMLElement).closest('button')) { - toggleCollapse(); - } + const { contentContainer, updateTitle, isExpanded } = createCollapsibleSection({ + containerEl, + title: workflow.name || 'Unnamed workflow', + containerClass: 'workflow-container', + contentClass: 'workflow-content', + headerClass: 'workflow-header', + startExpanded: shouldExpand, + isHeading: true, + onDelete: async () => { + const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); + if (index !== -1) { + plugin.settings.workflows.splice(index, 1); + await plugin.saveSettings(); + callbacks.refresh(); + } + }, }); - // Move content container after header - workflowContainer.appendChild(contentContainer); - // Workflow name new Setting(contentContainer) .setName('Name') @@ -117,8 +96,7 @@ function displayWorkflowSettings( .setValue(workflow.name) .onChange(async (value) => { workflow.name = value; - const isExpanded = contentContainer.classList.contains('is-expanded'); - headerSetting.setName(`${isExpanded ? '▾' : '▸'} ${value || 'Unnamed workflow'}`); + updateTitle(value || 'Unnamed workflow'); await plugin.saveSettings(); })); @@ -162,8 +140,7 @@ function displayWorkflowSettings( workflow.showInCommand = value; await plugin.saveSettings(); // Preserve expand state when refreshing - const isExpanded = contentContainer.classList.contains('is-expanded'); - if (isExpanded) { + if (isExpanded()) { callbacks.setExpandState({ workflowId: workflow.id }); } callbacks.refresh(); @@ -181,8 +158,7 @@ function displayWorkflowSettings( workflow.outputType = value as WorkflowOutputType; await plugin.saveSettings(); // Preserve expand state when refreshing (output folder visibility may change) - const isExpanded = contentContainer.classList.contains('is-expanded'); - if (isExpanded) { + if (isExpanded()) { callbacks.setExpandState({ workflowId: workflow.id }); } callbacks.refresh(); @@ -193,13 +169,16 @@ function displayWorkflowSettings( new Setting(contentContainer) .setName('Output folder') .setDesc('Folder where notes will be created (leave empty to use default)') - .addText(text => text - .setPlaceholder('Default folder') - .setValue(workflow.outputFolder || '') - .onChange(async (value) => { - workflow.outputFolder = value; - await plugin.saveSettings(); - })); + .addSearch(search => { + search + .setPlaceholder('Default folder') + .setValue(workflow.outputFolder || '') + .onChange(async (value) => { + workflow.outputFolder = value; + await plugin.saveSettings(); + }); + new FolderSuggest(plugin.app, search.inputEl); + }); } } diff --git a/styles.css b/styles.css index b1c2c17..86a6808 100644 --- a/styles.css +++ b/styles.css @@ -53,10 +53,14 @@ If your plugin does not need CSS, delete this file. user-select: none; } -.settings-advanced-heading:hover { +.settings-advanced-heading:hover .setting-item-name { color: var(--text-accent); } +.settings-advanced-section { + margin-top: 20px; +} + .settings-advanced-container { margin-left: 10px; } From 60306c0c26aed253b074d5a2c4dd9080897a2e24 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 7 Jan 2026 20:32:49 -0600 Subject: [PATCH 09/22] update path picker --- AGENTS.md | 4 + src/components/folder-suggest.ts | 62 ------ src/components/path-picker.ts | 328 +++++++++++++++++++++++++++++ src/settings/index.ts | 1 + src/settings/transcription.ts | 28 +-- src/settings/types.ts | 11 + src/settings/workflows.ts | 106 +++++++--- src/workflows/workflow-executor.ts | 44 +++- styles.css | 12 ++ 9 files changed, 490 insertions(+), 106 deletions(-) delete mode 100644 src/components/folder-suggest.ts create mode 100644 src/components/path-picker.ts diff --git a/AGENTS.md b/AGENTS.md index cf523ef..f9074d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # Obsidian community plugin +## Agent instructions + +- Do not run build or lint commands unless explicitly instructed by the user. + ## Project overview - Target: Obsidian Community Plugin (TypeScript → bundled JavaScript). diff --git a/src/components/folder-suggest.ts b/src/components/folder-suggest.ts deleted file mode 100644 index 7c91b0f..0000000 --- a/src/components/folder-suggest.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { App, AbstractInputSuggest, TFolder } from "obsidian"; - -/** - * A folder suggestion component that provides autocomplete for vault folder paths. - * Uses Obsidian's AbstractInputSuggest to show folder suggestions as the user types. - */ -export class FolderSuggest extends AbstractInputSuggest { - private textInputEl: HTMLInputElement; - - constructor(app: App, inputEl: HTMLInputElement) { - super(app, inputEl); - this.textInputEl = inputEl; - } - - getSuggestions(inputStr: string): TFolder[] { - const inputLower = inputStr.toLowerCase().trim(); - const allFolders = this.getAllFolders(); - - if (inputLower === "") { - return allFolders; - } - - return allFolders.filter(folder => - folder.path.toLowerCase().includes(inputLower) - ); - } - - renderSuggestion(folder: TFolder, el: HTMLElement): void { - const displayPath = folder.path === "" ? "/" : folder.path; - el.createEl("div", { text: displayPath, cls: "folder-suggest-item" }); - } - - selectSuggestion(folder: TFolder): void { - this.textInputEl.value = folder.path; - this.textInputEl.dispatchEvent(new Event("input", { bubbles: true })); - this.close(); - } - - private getAllFolders(): TFolder[] { - const folders: TFolder[] = []; - - // Add root folder - const rootFolder = this.app.vault.getRoot(); - if (rootFolder) { - folders.push(rootFolder); - } - - // Add all other folders - const allFolders = this.app.vault.getAllFolders(); - folders.push(...allFolders); - - // Sort folders by path for better UX - folders.sort((a, b) => { - if (a.path === "") return -1; - if (b.path === "") return 1; - return a.path.localeCompare(b.path); - }); - - return folders; - } -} - diff --git a/src/components/path-picker.ts b/src/components/path-picker.ts new file mode 100644 index 0000000..59bb48c --- /dev/null +++ b/src/components/path-picker.ts @@ -0,0 +1,328 @@ +import { App, Setting, AbstractInputSuggest, TFolder, TFile } from "obsidian"; + +/** + * Internal folder suggestion component. + */ +class FolderSuggestInternal extends AbstractInputSuggest { + private textInputEl: HTMLInputElement; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.textInputEl = inputEl; + } + + getSuggestions(inputStr: string): TFolder[] { + const inputLower = inputStr.toLowerCase().trim(); + const allFolders = this.getAllFolders(); + + if (inputLower === "") { + return allFolders; + } + + return allFolders.filter(folder => + folder.path.toLowerCase().includes(inputLower) + ); + } + + renderSuggestion(folder: TFolder, el: HTMLElement): void { + const displayPath = folder.path === "" ? "/" : folder.path; + el.createEl("div", { text: displayPath, cls: "folder-suggest-item" }); + } + + selectSuggestion(folder: TFolder): void { + this.textInputEl.value = folder.path; + this.textInputEl.dispatchEvent(new Event("input", { bubbles: true })); + this.close(); + } + + private getAllFolders(): TFolder[] { + const folders: TFolder[] = []; + + const rootFolder = this.app.vault.getRoot(); + if (rootFolder) { + folders.push(rootFolder); + } + + const allFolders = this.app.vault.getAllFolders(); + folders.push(...allFolders); + + folders.sort((a, b) => { + if (a.path === "") return -1; + if (b.path === "") return 1; + return a.path.localeCompare(b.path); + }); + + return folders; + } +} + +/** + * Internal file suggestion component that filters by folder. + */ +class FileSuggestInternal extends AbstractInputSuggest { + private textInputEl: HTMLInputElement; + private folderPath: string = ""; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.textInputEl = inputEl; + } + + setFolderPath(folderPath: string): void { + this.folderPath = folderPath; + } + + getSuggestions(inputStr: string): TFile[] { + const inputLower = inputStr.toLowerCase().trim(); + const files = this.getFilesInFolder(); + + if (inputLower === "") { + return files; + } + + return files.filter(file => { + const displayPath = this.getDisplayPath(file); + return displayPath.toLowerCase().includes(inputLower); + }); + } + + renderSuggestion(file: TFile, el: HTMLElement): void { + const displayPath = this.getDisplayPath(file); + el.createEl("div", { text: displayPath, cls: "file-suggest-item" }); + } + + selectSuggestion(file: TFile): void { + this.textInputEl.value = file.name; + this.textInputEl.dataset.fullPath = file.path; + this.textInputEl.dispatchEvent(new CustomEvent("file-selected", { + bubbles: true, + detail: { path: file.path, name: file.name } + })); + this.close(); + } + + private getDisplayPath(file: TFile): string { + if (this.folderPath && file.path.startsWith(this.folderPath + "/")) { + return file.path.substring(this.folderPath.length + 1); + } + return file.path; + } + + private getFilesInFolder(): TFile[] { + const allFiles = this.app.vault.getFiles(); + + if (!this.folderPath) { + return allFiles.sort((a, b) => a.path.localeCompare(b.path)); + } + + const normalizedFolder = this.folderPath.replace(/\/$/, ""); + const folder = this.app.vault.getAbstractFileByPath(normalizedFolder); + + if (!(folder instanceof TFolder)) { + return []; + } + + const filesInFolder = allFiles.filter(file => { + if (normalizedFolder === "") { + return true; + } + return file.path.startsWith(normalizedFolder + "/"); + }); + + filesInFolder.sort((a, b) => a.path.localeCompare(b.path)); + return filesInFolder; + } +} + +/** + * Mode for the path picker component. + */ +export type PathPickerMode = "folder-only" | "folder-file"; + +/** + * Base options for the path picker. + */ +interface PathPickerBaseOptions { + containerEl: HTMLElement; + app: App; + name: string; + description: string; + folderPlaceholder?: string; + initialFolderPath?: string; + onFolderChange?: (folderPath: string) => void; +} + +/** + * Options for folder-only mode. + */ +export interface FolderOnlyPickerOptions extends PathPickerBaseOptions { + mode: "folder-only"; +} + +/** + * Options for folder-file cascading mode. + */ +export interface FolderFilePickerOptions extends PathPickerBaseOptions { + mode: "folder-file"; + filePlaceholder?: string; + initialFilePath?: string; + onFileChange?: (filePath: string) => void; +} + +export type PathPickerOptions = FolderOnlyPickerOptions | FolderFilePickerOptions; + +/** + * Result for folder-only mode. + */ +export interface FolderOnlyPickerResult { + setting: Setting; + folderInputEl: HTMLInputElement; +} + +/** + * Result for folder-file mode. + */ +export interface FolderFilePickerResult { + setting: Setting; + folderInputEl: HTMLInputElement; + fileInputEl: HTMLInputElement; + fileSuggest: FileSuggestInternal; +} + +export type PathPickerResult = + T extends FolderOnlyPickerOptions ? FolderOnlyPickerResult : FolderFilePickerResult; + +/** + * Create a unified path picker component. + * + * Supports two modes: + * - "folder-only": Shows only a folder selection input + * - "folder-file": Shows folder and file selection inputs side-by-side + */ +export function createPathPicker(options: T): PathPickerResult { + if (options.mode === "folder-only") { + return createFolderOnlyPicker(options) as PathPickerResult; + } else { + return createFolderFilePicker(options) as PathPickerResult; + } +} + +function createFolderOnlyPicker(options: FolderOnlyPickerOptions): FolderOnlyPickerResult { + const { + containerEl, + app, + name, + description, + folderPlaceholder = "Select folder...", + initialFolderPath = "", + onFolderChange + } = options; + + let folderInputEl: HTMLInputElement; + + const setting = new Setting(containerEl) + .setName(name) + .setDesc(description) + .addSearch(folderSearch => { + folderInputEl = folderSearch.inputEl; + folderSearch + .setPlaceholder(folderPlaceholder) + .setValue(initialFolderPath) + .onChange((value) => { + onFolderChange?.(value); + }); + + new FolderSuggestInternal(app, folderSearch.inputEl); + folderSearch.inputEl.addClass("path-picker-folder-input"); + }); + + setting.settingEl.addClass("path-picker"); + + return { + setting, + folderInputEl: folderInputEl! + }; +} + +function createFolderFilePicker(options: FolderFilePickerOptions): FolderFilePickerResult { + const { + containerEl, + app, + name, + description, + folderPlaceholder = "Select folder...", + filePlaceholder = "Select file...", + initialFolderPath = "", + initialFilePath = "", + onFolderChange, + onFileChange + } = options; + + let folderInputEl: HTMLInputElement; + let fileInputEl: HTMLInputElement; + let fileSuggest: FileSuggestInternal; + + const setting = new Setting(containerEl) + .setName(name) + .setDesc(description) + .addSearch(folderSearch => { + folderInputEl = folderSearch.inputEl; + folderSearch + .setPlaceholder(folderPlaceholder) + .setValue(initialFolderPath) + .onChange((value) => { + if (fileSuggest) { + fileSuggest.setFolderPath(value); + } + // Enable/disable file input based on folder selection + if (fileInputEl) { + fileInputEl.disabled = !value; + // Clear file if folder is cleared + if (!value) { + fileInputEl.value = ""; + fileInputEl.dataset.fullPath = ""; + onFileChange?.(""); + } + } + onFolderChange?.(value); + }); + + new FolderSuggestInternal(app, folderSearch.inputEl); + folderSearch.inputEl.addClass("path-picker-folder-input"); + }) + .addSearch(fileSearch => { + fileInputEl = fileSearch.inputEl; + + // Extract just the filename from the initial path for display + const initialFileName = initialFilePath ? initialFilePath.split("/").pop() ?? "" : ""; + + fileSearch + .setPlaceholder(filePlaceholder) + .setValue(initialFileName); + + // Disable file input if no folder is selected + fileSearch.inputEl.disabled = !initialFolderPath; + + // Store the full path in dataset + fileSearch.inputEl.dataset.fullPath = initialFilePath; + + // Listen for file selection (custom event with full path) + fileSearch.inputEl.addEventListener("file-selected", ((e: CustomEvent) => { + onFileChange?.(e.detail.path); + }) as EventListener); + + fileSuggest = new FileSuggestInternal(app, fileSearch.inputEl); + fileSuggest.setFolderPath(initialFolderPath); + fileSearch.inputEl.addClass("path-picker-file-input"); + }); + + setting.settingEl.addClass("path-picker", "path-picker-folder-file"); + + return { + setting, + folderInputEl: folderInputEl!, + fileInputEl: fileInputEl!, + fileSuggest: fileSuggest! + }; +} + diff --git a/src/settings/index.ts b/src/settings/index.ts index 0ea2794..53abd4d 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -14,6 +14,7 @@ export type { ProviderModelSelection, WorkflowConfig, WorkflowOutputType, + PromptSourceType, AIToolboxSettings, SettingsTabType, ExpandOnNextRenderState diff --git a/src/settings/transcription.ts b/src/settings/transcription.ts index 3377f73..da410ca 100644 --- a/src/settings/transcription.ts +++ b/src/settings/transcription.ts @@ -1,7 +1,7 @@ import { Setting } from "obsidian"; import AIToolboxPlugin from "../main"; -import { FolderSuggest } from "../components/folder-suggest"; import { createCollapsibleSection } from "../components/collapsible-section"; +import { createPathPicker } from "../components/path-picker"; /** * Callbacks for the transcription settings tab to communicate with the main settings tab @@ -58,19 +58,19 @@ export function displayTranscriptionSettings( await plugin.saveSettings(); })); - new Setting(containerEl) - .setName('Notes folder') - .setDesc('Folder where transcription notes will be created (leave empty for vault root)') - .addSearch(search => { - search - .setPlaceholder('Vault root') - .setValue(plugin.settings.outputFolder) - .onChange(async (value) => { - plugin.settings.outputFolder = value; - await plugin.saveSettings(); - }); - new FolderSuggest(plugin.app, search.inputEl); - }); + createPathPicker({ + mode: 'folder-only', + containerEl, + app: plugin.app, + name: 'Notes folder', + description: 'Folder where transcription notes will be created (leave empty for vault root)', + folderPlaceholder: 'Vault root', + initialFolderPath: plugin.settings.outputFolder, + onFolderChange: async (folderPath: string) => { + plugin.settings.outputFolder = folderPath; + await plugin.saveSettings(); + } + }); new Setting(containerEl) .setName('Keep video file') diff --git a/src/settings/types.ts b/src/settings/types.ts index 40a890a..dd87a91 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -45,6 +45,11 @@ export interface ProviderModelSelection { */ export type WorkflowOutputType = 'popup' | 'new-note' | 'at-cursor'; +/** + * Prompt source type options for workflows + */ +export type PromptSourceType = 'inline' | 'from-file'; + /** * Configuration for a custom workflow */ @@ -52,6 +57,9 @@ export interface WorkflowConfig { id: string; name: string; promptText: string; + promptSourceType: PromptSourceType; + promptFolderPath: string; + promptFilePath: string; provider: ProviderModelSelection | null; outputType: WorkflowOutputType; outputFolder: string; @@ -65,6 +73,9 @@ export interface WorkflowConfig { export const DEFAULT_WORKFLOW_CONFIG: Omit = { name: 'New workflow', promptText: '', + promptSourceType: 'inline', + promptFolderPath: '', + promptFilePath: '', provider: null, outputType: 'popup', outputFolder: '', diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index 1ed73c0..e6e1a48 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -3,12 +3,13 @@ import AIToolboxPlugin from "../main"; import { WorkflowConfig, WorkflowOutputType, + PromptSourceType, ExpandOnNextRenderState, generateId, DEFAULT_WORKFLOW_CONFIG } from "./types"; -import { FolderSuggest } from "../components/folder-suggest"; import { createCollapsibleSection } from "../components/collapsible-section"; +import { createPathPicker } from "../components/path-picker"; /** * Callbacks for the workflows settings tab to communicate with the main settings tab @@ -28,6 +29,14 @@ const OUTPUT_TYPE_OPTIONS: Record = { 'at-cursor': 'Insert at cursor' }; +/** + * Prompt source type display labels + */ +const PROMPT_SOURCE_OPTIONS: Record = { + 'inline': 'Inline', + 'from-file': 'From file' +}; + /** * Display the workflows settings tab content */ @@ -103,21 +112,64 @@ function displayWorkflowSettings( // Provider/Model selection (only models that support chat) displayWorkflowProviderSelection(contentContainer, plugin, workflow); - // Prompt text + // Prompt source type dropdown + const promptSourceType = workflow.promptSourceType ?? 'inline'; new Setting(contentContainer) - .setName('Prompt text') - .setDesc('The prompt text to send to the AI model') - .addTextArea(textArea => { - textArea - .setPlaceholder('Enter your prompt text here...') - .setValue(workflow.promptText) - .onChange(async (value) => { - workflow.promptText = value; - await plugin.saveSettings(); - }); - textArea.inputEl.rows = 6; - textArea.inputEl.addClass('workflow-textarea'); + .setName('Prompt source') + .setDesc('Choose where the prompt text comes from') + .addDropdown(dropdown => dropdown + .addOptions(PROMPT_SOURCE_OPTIONS) + .setValue(promptSourceType) + .onChange(async (value) => { + workflow.promptSourceType = value as PromptSourceType; + await plugin.saveSettings(); + // Preserve expand state when refreshing + if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + })); + + // Prompt text textarea (only show when source is inline) + if (promptSourceType === 'inline') { + new Setting(contentContainer) + .setName('Prompt text') + .setDesc('The prompt text to send to the AI model') + .addTextArea(textArea => { + textArea + .setPlaceholder('Enter your prompt text here...') + .setValue(workflow.promptText) + .onChange(async (value) => { + workflow.promptText = value; + await plugin.saveSettings(); + }); + textArea.inputEl.rows = 6; + textArea.inputEl.addClass('workflow-textarea'); + }); + } + + // Prompt file picker (only show when source is from-file) + if (promptSourceType === 'from-file') { + createPathPicker({ + mode: 'folder-file', + containerEl: contentContainer, + app: plugin.app, + name: 'Prompt file', + description: 'First select a folder to filter, then select a file from that folder', + initialFolderPath: workflow.promptFolderPath ?? '', + initialFilePath: workflow.promptFilePath ?? '', + folderPlaceholder: 'Select folder...', + filePlaceholder: 'Select file...', + onFolderChange: async (folderPath: string) => { + workflow.promptFolderPath = folderPath; + await plugin.saveSettings(); + }, + onFileChange: async (filePath: string) => { + workflow.promptFilePath = filePath; + await plugin.saveSettings(); + } }); + } // Make available as input to other workflows toggle new Setting(contentContainer) @@ -166,19 +218,19 @@ function displayWorkflowSettings( // Output folder (only show if output type is new-note) if (workflow.outputType === 'new-note') { - new Setting(contentContainer) - .setName('Output folder') - .setDesc('Folder where notes will be created (leave empty to use default)') - .addSearch(search => { - search - .setPlaceholder('Default folder') - .setValue(workflow.outputFolder || '') - .onChange(async (value) => { - workflow.outputFolder = value; - await plugin.saveSettings(); - }); - new FolderSuggest(plugin.app, search.inputEl); - }); + createPathPicker({ + mode: 'folder-only', + containerEl: contentContainer, + app: plugin.app, + name: 'Output folder', + description: 'Folder where notes will be created (leave empty to use default)', + folderPlaceholder: 'Default folder', + initialFolderPath: workflow.outputFolder || '', + onFolderChange: async (folderPath: string) => { + workflow.outputFolder = folderPath; + await plugin.saveSettings(); + } + }); } } diff --git a/src/workflows/workflow-executor.ts b/src/workflows/workflow-executor.ts index a003e48..cd828f5 100644 --- a/src/workflows/workflow-executor.ts +++ b/src/workflows/workflow-executor.ts @@ -145,6 +145,38 @@ ${response} new Notice(`Created note: ${file.name}`); } +/** + * Get the prompt text for a workflow, loading from file if needed. + */ +async function getPromptText(app: App, workflow: WorkflowConfig): Promise { + const sourceType = workflow.promptSourceType ?? 'inline'; + + if (sourceType === 'from-file') { + const filePath = workflow.promptFilePath; + if (!filePath || !filePath.trim()) { + new Notice(`Workflow "${workflow.name}" has no prompt file configured. Please select a file in settings.`); + return null; + } + + const file = app.vault.getAbstractFileByPath(filePath); + if (!file || !(file instanceof TFile)) { + new Notice(`Prompt file "${filePath}" not found in vault for workflow "${workflow.name}".`); + return null; + } + + try { + return await app.vault.read(file); + } catch (error) { + console.error('Error reading prompt file:', error); + new Notice(`Failed to read prompt file "${filePath}": ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + // Default: use inline prompt text + return workflow.promptText; +} + /** * Execute a workflow using its configured provider and display the result. * @@ -163,8 +195,14 @@ export async function executeWorkflow( return; } + // Get the prompt text (from file or inline) + const promptText = await getPromptText(app, workflow); + if (promptText === null) { + return; // Error already shown to user + } + // Validate workflow has text - if (!workflow.promptText.trim()) { + if (!promptText.trim()) { new Notice(`Workflow "${workflow.name}" has no prompt text. Please add prompt text in settings.`); return; } @@ -187,7 +225,7 @@ export async function executeWorkflow( // Build chat messages with the prompt text as a user message const messages: ChatMessage[] = [ - { role: 'user', content: workflow.promptText } + { role: 'user', content: promptText } ]; // Send the chat request @@ -197,7 +235,7 @@ export async function executeWorkflow( const outputType = workflow.outputType || 'popup'; if (outputType === 'new-note') { - await createNoteWithResult(app, workflow.name, workflow.promptText, result.content, workflow.outputFolder); + await createNoteWithResult(app, workflow.name, promptText, result.content, workflow.outputFolder); } else if (outputType === 'at-cursor') { insertAtCursor(app, result.content); } else { diff --git a/styles.css b/styles.css index 86a6808..8201bde 100644 --- a/styles.css +++ b/styles.css @@ -224,3 +224,15 @@ button.mod-success { button.mod-success:hover { background-color: #256632; } + +/* Path picker styles */ +.path-picker-folder-file .setting-item-control { + display: flex; + gap: 8px; + flex-wrap: nowrap; +} + +.path-picker-folder-input, +.path-picker-file-input { + min-width: 140px; +} From a3bd598fa9f047f032dce3c94050921559a7c038 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 7 Jan 2026 21:27:18 -0600 Subject: [PATCH 10/22] settings tab. prep for transcriptions --- src/settings/additional-settings.ts | 84 ++++++++++++++ src/settings/index.ts | 20 ++-- src/settings/transcription.ts | 173 ---------------------------- src/settings/types.ts | 2 +- 4 files changed, 95 insertions(+), 184 deletions(-) create mode 100644 src/settings/additional-settings.ts delete mode 100644 src/settings/transcription.ts diff --git a/src/settings/additional-settings.ts b/src/settings/additional-settings.ts new file mode 100644 index 0000000..1bd5993 --- /dev/null +++ b/src/settings/additional-settings.ts @@ -0,0 +1,84 @@ +import { Setting } from "obsidian"; +import AIToolboxPlugin from "../main"; + +/** + * Callbacks for the additional settings tab to communicate with the main settings tab + */ +export interface AdditionalSettingsCallbacks { + refresh: () => void; +} + +/** + * Display the additional settings tab content + */ +export function displayAdditionalSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + callbacks: AdditionalSettingsCallbacks +): void { + let showAdvanced = false; + + const advancedColor = 'var(--text-warning)'; + const advancedToggleSetting = new Setting(containerEl) + .addToggle(toggle => toggle + .setValue(showAdvanced) + .onChange((value) => { + showAdvanced = value; + ytdlpPathSetting.settingEl.style.display = value ? '' : 'none'; + ffmpegPathSetting.settingEl.style.display = value ? '' : 'none'; + advancedLabel.style.color = value ? advancedColor : ''; + ytdlpPathSetting.nameEl.style.color = advancedColor; + ffmpegPathSetting.nameEl.style.color = advancedColor; + })); + advancedToggleSetting.settingEl.style.justifyContent = 'flex-end'; + advancedToggleSetting.nameEl.style.display = 'none'; + const advancedLabel = advancedToggleSetting.controlEl.createSpan({ text: 'Show advanced settings' }); + advancedLabel.style.fontSize = '0.85em'; + advancedLabel.style.marginRight = '8px'; + advancedToggleSetting.controlEl.prepend(advancedLabel); + + // yt-dlp section header + const ytdlpHeading = new Setting(containerEl) + .setName('yt-dlp') + .setHeading(); + ytdlpHeading.nameEl.style.fontSize = '1.17em'; + + new Setting(containerEl) + .setName('Browser to impersonate') + .setDesc('Browser to impersonate when extracting audio for transcription') + .addDropdown(dropdown => dropdown + .addOption('chrome', 'Chrome') + .addOption('edge', 'Edge') + .addOption('safari', 'Safari') + .addOption('firefox', 'Firefox') + .addOption('brave', 'Brave') + .addOption('chromium', 'Chromium') + .setValue(plugin.settings.impersonateBrowser) + .onChange(async (value) => { + plugin.settings.impersonateBrowser = value; + await plugin.saveSettings(); + })); + + const ytdlpPathSetting = new Setting(containerEl) + .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .setDesc('Path to yt-dlp binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addText(text => text + .setValue(plugin.settings.ytdlpLocation) + .onChange(async (value) => { + plugin.settings.ytdlpLocation = value; + await plugin.saveSettings(); + })); + ytdlpPathSetting.settingEl.style.display = 'none'; + + const ffmpegPathSetting = new Setting(containerEl) + .setName('FFmpeg path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .setDesc('Path to FFmpeg binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun + .addText(text => text + .setValue(plugin.settings.ffmpegLocation) + .onChange(async (value) => { + plugin.settings.ffmpegLocation = value; + await plugin.saveSettings(); + })); + ffmpegPathSetting.settingEl.style.display = 'none'; +} + diff --git a/src/settings/index.ts b/src/settings/index.ts index 53abd4d..027690f 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -3,7 +3,7 @@ import AIToolboxPlugin from "../main"; import { SettingsTabType, ExpandOnNextRenderState } from "./types"; import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; import { displayWorkflowsSettings, WorkflowSettingsCallbacks } from "./workflows"; -import { displayTranscriptionSettings, TranscriptionSettingsCallbacks } from "./transcription"; +import { displayAdditionalSettings, AdditionalSettingsCallbacks } from "./additional-settings"; // Re-export all types and constants from types.ts for backward compatibility export { DEFAULT_OPENAI_ENDPOINT, generateId, DEFAULT_SETTINGS, DEFAULT_WORKFLOW_CONFIG } from "./types"; @@ -50,8 +50,8 @@ export class AIToolboxSettingTab extends PluginSettingTab { cls: 'settings-tab-button' }); - const transcriptionTabButton = tabHeader.createEl('button', { - text: 'Transcription', + const settingsTabButton = tabHeader.createEl('button', { + text: 'Settings', cls: 'settings-tab-button' }); @@ -59,15 +59,15 @@ export class AIToolboxSettingTab extends PluginSettingTab { this.activeTab = tab; providersTabButton.classList.toggle('active', tab === 'providers'); workflowsTabButton.classList.toggle('active', tab === 'workflows'); - transcriptionTabButton.classList.toggle('active', tab === 'transcription'); + settingsTabButton.classList.toggle('active', tab === 'settings'); tabContent.empty(); if (tab === 'providers') { this.displayProvidersTab(tabContent); } else if (tab === 'workflows') { this.displayWorkflowsTab(tabContent); - } else if (tab === 'transcription') { - this.displayTranscriptionTab(tabContent); + } else if (tab === 'settings') { + this.displaySettingsTab(tabContent); } else { const _exhaustiveCheck: never = tab; console.error(`Unknown settings tab: ${String(_exhaustiveCheck)}`); @@ -76,7 +76,7 @@ export class AIToolboxSettingTab extends PluginSettingTab { providersTabButton.addEventListener('click', () => showTab('providers')); workflowsTabButton.addEventListener('click', () => showTab('workflows')); - transcriptionTabButton.addEventListener('click', () => showTab('transcription')); + settingsTabButton.addEventListener('click', () => showTab('settings')); showTab(this.activeTab); } @@ -99,11 +99,11 @@ export class AIToolboxSettingTab extends PluginSettingTab { displayWorkflowsSettings(containerEl, this.plugin, callbacks); } - private displayTranscriptionTab(containerEl: HTMLElement): void { - const callbacks: TranscriptionSettingsCallbacks = { + private displaySettingsTab(containerEl: HTMLElement): void { + const callbacks: AdditionalSettingsCallbacks = { refresh: () => this.display() }; - displayTranscriptionSettings(containerEl, this.plugin, callbacks); + displayAdditionalSettings(containerEl, this.plugin, callbacks); } } diff --git a/src/settings/transcription.ts b/src/settings/transcription.ts deleted file mode 100644 index da410ca..0000000 --- a/src/settings/transcription.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Setting } from "obsidian"; -import AIToolboxPlugin from "../main"; -import { createCollapsibleSection } from "../components/collapsible-section"; -import { createPathPicker } from "../components/path-picker"; - -/** - * Callbacks for the transcription settings tab to communicate with the main settings tab - */ -export interface TranscriptionSettingsCallbacks { - refresh: () => void; -} - -/** - * Display the transcription settings tab content - */ -export function displayTranscriptionSettings( - containerEl: HTMLElement, - plugin: AIToolboxPlugin, - callbacks: TranscriptionSettingsCallbacks -): void { - // Transcription provider selection at the top - displayTranscriptionProviderSelection(containerEl, plugin); - - new Setting(containerEl) - .setName('Browser to impersonate') - .setDesc('Browser to impersonate when extracting audio for transcription') - .addDropdown(dropdown => dropdown - .addOption('chrome', 'Chrome') - .addOption('edge', 'Edge') - .addOption('safari', 'Safari') - .addOption('firefox', 'Firefox') - .addOption('brave', 'Brave') - .addOption('chromium', 'Chromium') - .setValue(plugin.settings.impersonateBrowser) - .onChange(async (value) => { - plugin.settings.impersonateBrowser = value; - await plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Language') - .setDesc('Language code for transcription (e.g., en, es, fr). Leave empty for automatic detection') - .addText(text => text - .setPlaceholder('') - .setValue(plugin.settings.transcriptionLanguage) - .onChange(async (value) => { - plugin.settings.transcriptionLanguage = value; - await plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Include timestamps') - .setDesc('Include timestamps in transcription notes for each chunk of text') - .addToggle(toggle => toggle - .setValue(plugin.settings.includeTimestamps) - .onChange(async (value) => { - plugin.settings.includeTimestamps = value; - await plugin.saveSettings(); - })); - - createPathPicker({ - mode: 'folder-only', - containerEl, - app: plugin.app, - name: 'Notes folder', - description: 'Folder where transcription notes will be created (leave empty for vault root)', - folderPlaceholder: 'Vault root', - initialFolderPath: plugin.settings.outputFolder, - onFolderChange: async (folderPath: string) => { - plugin.settings.outputFolder = folderPath; - await plugin.saveSettings(); - } - }); - - new Setting(containerEl) - .setName('Keep video file') - .setDesc('Keep the original video file after extracting audio for transcription') - .addToggle(toggle => toggle - .setValue(plugin.settings.keepVideo) - .onChange(async (value) => { - plugin.settings.keepVideo = value; - await plugin.saveSettings(); - callbacks.refresh(); - })); - - if (plugin.settings.keepVideo) { - new Setting(containerEl) - .setName('Output directory') - .setDesc('Directory where video files will be saved (leave empty to use ~/Videos/Obsidian)') // eslint-disable-line obsidianmd/ui/sentence-case -- path example - .addText(text => text - .setPlaceholder('~/Videos/Obsidian') // eslint-disable-line obsidianmd/ui/sentence-case -- path example - .setValue(plugin.settings.outputDirectory) - .onChange(async (value) => { - plugin.settings.outputDirectory = value; - await plugin.saveSettings(); - })); - } - - const { contentContainer: advancedContainer } = createCollapsibleSection({ - containerEl, - title: 'Advanced', - containerClass: 'settings-advanced-section', - contentClass: 'settings-advanced-container', - headerClass: 'settings-advanced-heading', - startExpanded: false, - isHeading: true, - }); - - new Setting(advancedContainer) - .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .setDesc('Path to yt-dlp binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addText(text => text - .setValue(plugin.settings.ytdlpLocation) - .onChange(async (value) => { - plugin.settings.ytdlpLocation = value; - await plugin.saveSettings(); - })); - - new Setting(advancedContainer) - .setName('FFmpeg path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .setDesc('Path to FFmpeg binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun - .addText(text => text - .setValue(plugin.settings.ffmpegLocation) - .onChange(async (value) => { - plugin.settings.ffmpegLocation = value; - await plugin.saveSettings(); - })); -} - -function displayTranscriptionProviderSelection( - containerEl: HTMLElement, - plugin: AIToolboxPlugin -): void { - const providers = plugin.settings.providers; - const currentSelection = plugin.settings.transcriptionProvider; - - // Build options for provider/model dropdown (only models that support transcription) - const options: Record = { '': 'Select a provider and model' }; - for (const provider of providers) { - for (const model of provider.models) { - if (model.supportsTranscription) { - const key = `${provider.id}:${model.id}`; - options[key] = `${provider.name} - ${model.name}`; - } - } - } - - const currentValue = currentSelection - ? `${currentSelection.providerId}:${currentSelection.modelId}` - : ''; - - new Setting(containerEl) - .setName('Transcription provider') - .setDesc('Select the provider and model to use for audio transcription') - .addDropdown(dropdown => dropdown - .addOptions(options) - .setValue(currentValue) - .onChange(async (value) => { - if (value === '') { - plugin.settings.transcriptionProvider = null; - } else { - const parts = value.split(':'); - if (parts.length === 2 && parts[0] && parts[1]) { - plugin.settings.transcriptionProvider = { - providerId: parts[0], - modelId: parts[1] - }; - } - } - await plugin.saveSettings(); - })); -} - diff --git a/src/settings/types.ts b/src/settings/types.ts index dd87a91..2c18a2a 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -123,7 +123,7 @@ export const DEFAULT_SETTINGS: AIToolboxSettings = { /** * Supported settings tab types */ -export type SettingsTabType = 'providers' | 'workflows' | 'transcription'; +export type SettingsTabType = 'providers' | 'workflows' | 'settings'; /** * State for tracking which items should be expanded on next render From 36857f256449820963c201e03ad8469464935bd9 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Thu, 8 Jan 2026 10:15:45 -0600 Subject: [PATCH 11/22] stash --- src/components/collapsible-section.ts | 26 +++- src/components/workflow-type-modal.ts | 66 ++++++++ src/settings/index.ts | 1 + src/settings/types.ts | 14 +- src/settings/workflows.ts | 212 ++++++++++++++++++++++++-- src/workflows/workflow-executor.ts | 134 +++++++++++++++- styles.css | 80 ++++++++++ 7 files changed, 512 insertions(+), 21 deletions(-) create mode 100644 src/components/workflow-type-modal.ts diff --git a/src/components/collapsible-section.ts b/src/components/collapsible-section.ts index a2379d5..9f0f454 100644 --- a/src/components/collapsible-section.ts +++ b/src/components/collapsible-section.ts @@ -1,4 +1,4 @@ -import { Setting } from "obsidian"; +import { Setting, setIcon } from "obsidian"; /** * Configuration options for creating a collapsible section @@ -18,6 +18,8 @@ export interface CollapsibleSectionConfig { startExpanded: boolean; /** Whether to show as a heading (bold) - defaults to true */ isHeading?: boolean; + /** Optional icon to display before the title */ + icon?: string; /** Callback when the delete button is clicked (if provided, delete button is shown) */ onDelete?: () => void; /** Callback when the title changes (for dynamic name updates) */ @@ -53,6 +55,7 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll headerClass, startExpanded, isHeading = true, + icon, onDelete, } = config; @@ -65,9 +68,11 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll // Track current title for updates let currentTitle = title; + let iconElement: HTMLElement | null = null; - // Helper to get the formatted title with arrow + // Helper to get the formatted title with arrow and optional icon const getFormattedTitle = (name: string, expanded: boolean): string => { + // Arrow only, icon will be inserted separately return `${expanded ? '▾' : '▸'} ${name || 'Unnamed'}`; }; @@ -79,6 +84,13 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll headerSetting.setHeading(); } + // Add icon if provided (at the end, after title text) + if (icon) { + iconElement = headerSetting.nameEl.createSpan({ cls: 'workflow-header-icon' }); + setIcon(iconElement, icon); + headerSetting.nameEl.appendChild(iconElement); + } + // Add delete button if callback provided if (onDelete) { headerSetting.addButton(button => button @@ -95,6 +107,11 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll contentContainer.classList.toggle('is-collapsed', !isCollapsed); contentContainer.classList.toggle('is-expanded', isCollapsed); headerSetting.setName(getFormattedTitle(currentTitle, isCollapsed)); + + // Re-add icon at the end + if (icon && iconElement) { + headerSetting.nameEl.appendChild(iconElement); + } }; // Add click handler to header (excluding buttons) @@ -112,6 +129,11 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll currentTitle = newTitle; const isExpanded = contentContainer.classList.contains('is-expanded'); headerSetting.setName(getFormattedTitle(newTitle, isExpanded)); + + // Re-add icon at the end + if (icon && iconElement) { + headerSetting.nameEl.appendChild(iconElement); + } }; // Check if expanded diff --git a/src/components/workflow-type-modal.ts b/src/components/workflow-type-modal.ts new file mode 100644 index 0000000..ab0af94 --- /dev/null +++ b/src/components/workflow-type-modal.ts @@ -0,0 +1,66 @@ +import { App, Modal, setIcon } from "obsidian"; +import { WorkflowType } from "../settings"; + +/** + * Modal for selecting the type of workflow to create + */ +export class WorkflowTypeModal extends Modal { + private onSelect: (type: WorkflowType) => void; + + constructor(app: App, onSelect: (type: WorkflowType) => void) { + super(app); + this.onSelect = onSelect; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('workflow-type-modal'); + + // Title + contentEl.createEl('h2', { text: 'Select workflow type' }); + + // Description + contentEl.createEl('p', { + text: 'Choose the type of workflow you want to create:', + cls: 'workflow-type-description' + }); + + // Options container + const optionsContainer = contentEl.createDiv('workflow-type-options'); + + // Chat option + const chatOption = optionsContainer.createDiv('workflow-type-option'); + chatOption.addEventListener('click', () => { + this.onSelect('chat'); + this.close(); + }); + + const chatIcon = chatOption.createDiv('workflow-type-icon'); + setIcon(chatIcon, 'message-circle'); + + const chatContent = chatOption.createDiv('workflow-type-content'); + chatContent.createEl('h3', { text: 'Chat' }); + chatContent.createEl('p', { text: 'Send prompts to AI models and receive text responses' }); + + // Transcription option + const transcriptionOption = optionsContainer.createDiv('workflow-type-option'); + transcriptionOption.addEventListener('click', () => { + this.onSelect('transcription'); + this.close(); + }); + + const transcriptionIcon = transcriptionOption.createDiv('workflow-type-icon'); + setIcon(transcriptionIcon, 'audio-lines'); + + const transcriptionContent = transcriptionOption.createDiv('workflow-type-content'); + transcriptionContent.createEl('h3', { text: 'Transcription' }); + transcriptionContent.createEl('p', { text: 'Transcribe audio or video files using AI models' }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + diff --git a/src/settings/index.ts b/src/settings/index.ts index 027690f..b14a678 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -14,6 +14,7 @@ export type { ProviderModelSelection, WorkflowConfig, WorkflowOutputType, + WorkflowType, PromptSourceType, AIToolboxSettings, SettingsTabType, diff --git a/src/settings/types.ts b/src/settings/types.ts index 2c18a2a..aa55f0d 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -50,12 +50,18 @@ export type WorkflowOutputType = 'popup' | 'new-note' | 'at-cursor'; */ export type PromptSourceType = 'inline' | 'from-file'; +/** + * Workflow type options + */ +export type WorkflowType = 'chat' | 'transcription'; + /** * Configuration for a custom workflow */ export interface WorkflowConfig { id: string; name: string; + type: WorkflowType; promptText: string; promptSourceType: PromptSourceType; promptFolderPath: string; @@ -65,6 +71,9 @@ export interface WorkflowConfig { outputFolder: string; showInCommand: boolean; availableAsInput: boolean; + // Transcription-specific settings (optional for backward compatibility) + language?: string; + includeTimestamps?: boolean; } /** @@ -72,6 +81,7 @@ export interface WorkflowConfig { */ export const DEFAULT_WORKFLOW_CONFIG: Omit = { name: 'New workflow', + type: 'chat', promptText: '', promptSourceType: 'inline', promptFolderPath: '', @@ -80,7 +90,9 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { outputType: 'popup', outputFolder: '', showInCommand: true, - availableAsInput: false + availableAsInput: false, + language: '', + includeTimestamps: true }; export interface AIToolboxSettings { diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index e6e1a48..fc4c1e2 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -3,6 +3,7 @@ import AIToolboxPlugin from "../main"; import { WorkflowConfig, WorkflowOutputType, + WorkflowType, PromptSourceType, ExpandOnNextRenderState, generateId, @@ -10,6 +11,7 @@ import { } from "./types"; import { createCollapsibleSection } from "../components/collapsible-section"; import { createPathPicker } from "../components/path-picker"; +import { WorkflowTypeModal } from "../components/workflow-type-modal"; /** * Callbacks for the workflows settings tab to communicate with the main settings tab @@ -52,16 +54,21 @@ export function displayWorkflowsSettings( .addButton(button => button .setButtonText('Add workflow') .setCta() - .onClick(async () => { - const newWorkflow: WorkflowConfig = { - id: generateId(), - ...DEFAULT_WORKFLOW_CONFIG - }; - plugin.settings.workflows.push(newWorkflow); - // Expand the newly created workflow - callbacks.setExpandState({ workflowId: newWorkflow.id }); - await plugin.saveSettings(); - callbacks.refresh(); + .onClick(() => { + // Show modal to select workflow type + const modal = new WorkflowTypeModal(plugin.app, async (type: WorkflowType) => { + const newWorkflow: WorkflowConfig = { + id: generateId(), + ...DEFAULT_WORKFLOW_CONFIG, + type + }; + plugin.settings.workflows.push(newWorkflow); + // Expand the newly created workflow + callbacks.setExpandState({ workflowId: newWorkflow.id }); + await plugin.saveSettings(); + callbacks.refresh(); + }); + modal.open(); })); // Display each workflow @@ -79,6 +86,10 @@ function displayWorkflowSettings( const expandState = callbacks.getExpandState(); const shouldExpand = expandState.workflowId === workflow.id; + // Determine icon based on workflow type (default to 'chat' for backward compatibility) + const workflowType = workflow.type || 'chat'; + const icon = workflowType === 'chat' ? 'message-circle' : 'audio-lines'; + const { contentContainer, updateTitle, isExpanded } = createCollapsibleSection({ containerEl, title: workflow.name || 'Unnamed workflow', @@ -87,6 +98,7 @@ function displayWorkflowSettings( headerClass: 'workflow-header', startExpanded: shouldExpand, isHeading: true, + icon, onDelete: async () => { const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); if (index !== -1) { @@ -97,7 +109,7 @@ function displayWorkflowSettings( }, }); - // Workflow name + // Workflow name (common to all workflow types) new Setting(contentContainer) .setName('Name') .setDesc('Display name for this workflow') @@ -109,6 +121,30 @@ function displayWorkflowSettings( await plugin.saveSettings(); })); + // Route to type-specific settings + if (workflowType === 'transcription') { + displayTranscriptionWorkflowSettings(contentContainer, plugin, workflow, callbacks, isExpanded); + } else { + // Default to chat workflow settings for backward compatibility + displayChatWorkflowSettings(contentContainer, plugin, workflow, callbacks, isExpanded); + } + + // Clear the expand state after rendering this workflow + if (expandState.workflowId === workflow.id) { + callbacks.setExpandState({}); + } +} + +/** + * Display chat-specific workflow settings + */ +function displayChatWorkflowSettings( + contentContainer: HTMLElement, + plugin: AIToolboxPlugin, + workflow: WorkflowConfig, + callbacks: WorkflowSettingsCallbacks, + isExpanded: () => boolean +): void { // Provider/Model selection (only models that support chat) displayWorkflowProviderSelection(contentContainer, plugin, workflow); @@ -233,11 +269,6 @@ function displayWorkflowSettings( }); } } - - // Clear the expand state after rendering this workflow - if (expandState.workflowId === workflow.id) { - callbacks.setExpandState({}); - } } function displayWorkflowProviderSelection( @@ -285,3 +316,152 @@ function displayWorkflowProviderSelection( })); } +/** + * Display provider selection for transcription workflows + * Only shows models that support transcription + */ +function displayTranscriptionProviderSelection( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + workflow: WorkflowConfig +): void { + const providers = plugin.settings.providers; + const currentSelection = workflow.provider; + + // Build options for provider/model dropdown (only models that support transcription) + const options: Record = { '': 'Select a provider and model' }; + for (const provider of providers) { + for (const model of provider.models) { + if (model.supportsTranscription) { + const key = `${provider.id}:${model.id}`; + options[key] = `${provider.name} - ${model.name}`; + } + } + } + + const currentValue = currentSelection + ? `${currentSelection.providerId}:${currentSelection.modelId}` + : ''; + + new Setting(containerEl) + .setName('Provider') + .setDesc('Select the provider and model to use for transcription') + .addDropdown(dropdown => dropdown + .addOptions(options) + .setValue(currentValue) + .onChange(async (value) => { + if (value === '') { + workflow.provider = null; + } else { + const parts = value.split(':'); + if (parts.length === 2 && parts[0] && parts[1]) { + workflow.provider = { + providerId: parts[0], + modelId: parts[1] + }; + } + } + await plugin.saveSettings(); + })); +} + +/** + * Display transcription-specific workflow settings + */ +function displayTranscriptionWorkflowSettings( + contentContainer: HTMLElement, + plugin: AIToolboxPlugin, + workflow: WorkflowConfig, + callbacks: WorkflowSettingsCallbacks, + isExpanded: () => boolean +): void { + // Provider selection (only models that support transcription) + displayTranscriptionProviderSelection(contentContainer, plugin, workflow); + + // Language setting + new Setting(contentContainer) + .setName('Language') + .setDesc('Optional language code for transcription (e.g., "en", "es", "fr"). Leave empty for auto-detection.') + .addText(text => text + .setPlaceholder('Auto-detect') + .setValue(workflow.language || '') + .onChange(async (value) => { + workflow.language = value; + await plugin.saveSettings(); + })); + + // Include timestamps toggle + new Setting(contentContainer) + .setName('Include timestamps') + .setDesc('Include timestamps in the transcription output') + .addToggle(toggle => toggle + .setValue(workflow.includeTimestamps ?? true) + .onChange(async (value) => { + workflow.includeTimestamps = value; + await plugin.saveSettings(); + })); + + // Make available as input to other workflows toggle + new Setting(contentContainer) + .setName('Make available as input to other workflows') + .setDesc('Allow this workflow to be used as input context for other workflows') + .addToggle(toggle => toggle + .setValue(workflow.availableAsInput ?? false) + .onChange(async (value) => { + workflow.availableAsInput = value; + await plugin.saveSettings(); + })); + + // List in Workflow Command toggle + new Setting(contentContainer) + .setName('List in "Run AI Workflow" Command') + .setDesc('Show this workflow in the list when running the "Run AI Workflow" command') + .addToggle(toggle => toggle + .setValue(workflow.showInCommand ?? true) + .onChange(async (value) => { + workflow.showInCommand = value; + await plugin.saveSettings(); + // Preserve expand state when refreshing + if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + })); + + // Output type selection (only show if workflow is listed in command) + if (workflow.showInCommand ?? true) { + new Setting(contentContainer) + .setName('Output type') + .setDesc('Choose how to display the transcription result') + .addDropdown(dropdown => dropdown + .addOptions(OUTPUT_TYPE_OPTIONS) + .setValue(workflow.outputType || 'new-note') + .onChange(async (value) => { + workflow.outputType = value as WorkflowOutputType; + await plugin.saveSettings(); + // Preserve expand state when refreshing (output folder visibility may change) + if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + })); + + // Output folder (only show if output type is new-note) + if (workflow.outputType === 'new-note') { + createPathPicker({ + mode: 'folder-only', + containerEl: contentContainer, + app: plugin.app, + name: 'Output folder', + description: 'Folder where transcription notes will be created (leave empty to use default)', + folderPlaceholder: 'Default folder', + initialFolderPath: workflow.outputFolder || '', + onFolderChange: async (folderPath: string) => { + workflow.outputFolder = folderPath; + await plugin.saveSettings(); + } + }); + } + } +} + diff --git a/src/workflows/workflow-executor.ts b/src/workflows/workflow-executor.ts index cd828f5..7007e7e 100644 --- a/src/workflows/workflow-executor.ts +++ b/src/workflows/workflow-executor.ts @@ -1,7 +1,9 @@ -import { App, MarkdownView, Modal, Notice, TFile } from 'obsidian'; +import { App, MarkdownView, Modal, Notice, TFile, FuzzySuggestModal } from 'obsidian'; import { WorkflowConfig, AIToolboxSettings } from '../settings'; -import { createWorkflowProvider, ChatMessage } from '../providers'; +import { createWorkflowProvider, ChatMessage, TranscriptionOptions } from '../providers'; import { generateFilenameTimestamp } from '../utils/date-utils'; +import { createTranscriptionNote } from '../transcriptions/transcription-note'; +import * as path from 'path'; /** * Modal to display the AI response from a workflow execution @@ -179,6 +181,7 @@ async function getPromptText(app: App, workflow: WorkflowConfig): Promise { + const workflowType = workflow.type || 'chat'; + + if (workflowType === 'transcription') { + await executeTranscriptionWorkflow(app, settings, workflow); + } else { + await executeChatWorkflow(app, settings, workflow); + } +} + +/** + * Execute a chat workflow. + */ +async function executeChatWorkflow( + app: App, + settings: AIToolboxSettings, + workflow: WorkflowConfig ): Promise { // Validate workflow has a provider configured if (!workflow.provider) { @@ -251,3 +271,113 @@ export async function executeWorkflow( } } +/** + * Modal for selecting an audio file from the vault + */ +class AudioFileSelectorModal extends FuzzySuggestModal { + private audioFiles: TFile[]; + private onSelect: (file: TFile) => void; + + constructor(app: App, onSelect: (file: TFile) => void) { + super(app); + this.onSelect = onSelect; + + // Get all audio files from the vault + const supportedExtensions = ['mp3', 'wav', 'm4a', 'webm', 'ogg', 'flac', 'aac']; + this.audioFiles = app.vault.getFiles().filter(file => { + const ext = file.extension.toLowerCase(); + return supportedExtensions.includes(ext); + }); + + this.setPlaceholder('Select an audio file to transcribe...'); + } + + getItems(): TFile[] { + return this.audioFiles; + } + + getItemText(file: TFile): string { + return file.path; + } + + onChooseItem(file: TFile): void { + this.onSelect(file); + } +} + +/** + * Execute a transcription workflow. + */ +async function executeTranscriptionWorkflow( + app: App, + settings: AIToolboxSettings, + workflow: WorkflowConfig +): Promise { + // Validate workflow has a provider configured + if (!workflow.provider) { + new Notice(`Workflow "${workflow.name}" has no provider configured. Please configure a provider in settings.`); + return; + } + + // Create the provider + const provider = createWorkflowProvider(settings, workflow); + if (!provider) { + new Notice(`Could not find the configured provider for workflow "${workflow.name}". Please check your settings.`); + return; + } + + // Validate provider supports transcription + if (!provider.supportsTranscription()) { + new Notice(`The provider for workflow "${workflow.name}" does not support transcription. Please select a transcription-capable model.`); + return; + } + + // Show file selector modal + const modal = new AudioFileSelectorModal(app, async (audioFile: TFile) => { + try { + new Notice(`Transcribing ${audioFile.name}...`); + + // Get the absolute path to the audio file + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const adapter = app.vault.adapter as any; + const audioFilePath = path.join(adapter.basePath, audioFile.path); + + // Build transcription options from workflow settings + const transcriptionOptions: TranscriptionOptions = { + includeTimestamps: workflow.includeTimestamps ?? true, + language: workflow.language || undefined + }; + + // Transcribe the audio + const transcriptionResult = await provider.transcribeAudio(audioFilePath, transcriptionOptions); + + // Handle output based on configured output type + const outputType = workflow.outputType || 'new-note'; + + if (outputType === 'new-note') { + // Create a transcription note + await createTranscriptionNote( + app, + transcriptionResult, + audioFile.path, + workflow.includeTimestamps ?? true, + workflow.outputFolder || '' + ); + } else if (outputType === 'at-cursor') { + insertAtCursor(app, transcriptionResult.text); + } else { + // Show in popup modal + const resultModal = new WorkflowResultModal(app, workflow.name, transcriptionResult.text); + resultModal.open(); + } + + } catch (error) { + console.error('Transcription workflow execution error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to transcribe audio: ${errorMessage}`); + } + }); + + modal.open(); +} + diff --git a/styles.css b/styles.css index 8201bde..85afa43 100644 --- a/styles.css +++ b/styles.css @@ -236,3 +236,83 @@ button.mod-success:hover { .path-picker-file-input { min-width: 140px; } + +/* Workflow type modal styles */ +.workflow-type-modal .modal-content { + padding: 20px; +} + +.workflow-type-description { + color: var(--text-muted); + margin-bottom: 20px; +} + +.workflow-type-options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.workflow-type-option { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.workflow-type-option:hover { + background: var(--background-modifier-hover); + border-color: var(--text-accent); +} + +.workflow-type-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-secondary); + border-radius: 8px; +} + +.workflow-type-icon svg { + width: 28px; + height: 28px; + color: var(--text-accent); +} + +.workflow-type-content { + flex: 1; +} + +.workflow-type-content h3 { + margin: 0 0 4px 0; + font-size: 16px; + font-weight: 600; +} + +.workflow-type-content p { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + +/* Workflow type icon in header */ +.workflow-header-icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + vertical-align: center; +} + +.workflow-header-icon svg { + width: 15px; + height: 15px; + stroke-width: 2; +} From 690233c9eda1468f521e26aa48a786f0a46cfec8 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Thu, 8 Jan 2026 14:14:09 -0600 Subject: [PATCH 12/22] encapsulate video downloader --- src/processing/index.ts | 24 ++ .../video-platforms/index.ts | 0 .../video-platforms/tiktok-handler.ts | 0 .../video-platforms/video-platform-handler.ts | 0 .../video-platform-registry.ts | 0 .../video-platforms/youtube-handler.ts | 0 src/processing/video-processor.ts | 187 +++++++++++++++ src/settings/index.ts | 3 + src/settings/types.ts | 19 ++ src/settings/workflows.ts | 41 ++++ src/transcriptions/transcription-note.ts | 2 +- src/transcriptions/video-downloader.ts | 213 +++--------------- styles.css | 1 + 13 files changed, 304 insertions(+), 186 deletions(-) create mode 100644 src/processing/index.ts rename src/{transcriptions => processing}/video-platforms/index.ts (100%) rename src/{transcriptions => processing}/video-platforms/tiktok-handler.ts (100%) rename src/{transcriptions => processing}/video-platforms/video-platform-handler.ts (100%) rename src/{transcriptions => processing}/video-platforms/video-platform-registry.ts (100%) rename src/{transcriptions => processing}/video-platforms/youtube-handler.ts (100%) create mode 100644 src/processing/video-processor.ts diff --git a/src/processing/index.ts b/src/processing/index.ts new file mode 100644 index 0000000..8a0b422 --- /dev/null +++ b/src/processing/index.ts @@ -0,0 +1,24 @@ +// Video processor - yt-dlp related functionality +export { + VideoProcessorConfig, + YtDlpResult, + getOutputDirectory, + runYtDlp, +} from './video-processor'; + +// Video platforms - platform-specific handlers +export type { + VideoPlatformHandler, + VideoMetadata, + YtDlpOutputConfig, + YtDlpPlatformArgs, + EmbedConfig, +} from './video-platforms'; + +export { + YouTubeHandler, + TikTokHandler, + VideoPlatformRegistry, + videoPlatformRegistry, +} from './video-platforms'; + diff --git a/src/transcriptions/video-platforms/index.ts b/src/processing/video-platforms/index.ts similarity index 100% rename from src/transcriptions/video-platforms/index.ts rename to src/processing/video-platforms/index.ts diff --git a/src/transcriptions/video-platforms/tiktok-handler.ts b/src/processing/video-platforms/tiktok-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/tiktok-handler.ts rename to src/processing/video-platforms/tiktok-handler.ts diff --git a/src/transcriptions/video-platforms/video-platform-handler.ts b/src/processing/video-platforms/video-platform-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/video-platform-handler.ts rename to src/processing/video-platforms/video-platform-handler.ts diff --git a/src/transcriptions/video-platforms/video-platform-registry.ts b/src/processing/video-platforms/video-platform-registry.ts similarity index 100% rename from src/transcriptions/video-platforms/video-platform-registry.ts rename to src/processing/video-platforms/video-platform-registry.ts diff --git a/src/transcriptions/video-platforms/youtube-handler.ts b/src/processing/video-platforms/youtube-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/youtube-handler.ts rename to src/processing/video-platforms/youtube-handler.ts diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts new file mode 100644 index 0000000..5884bad --- /dev/null +++ b/src/processing/video-processor.ts @@ -0,0 +1,187 @@ +import { spawn } from 'child_process'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { Buffer } from 'buffer'; + +/** + * Configuration for video processing operations. + */ +export interface VideoProcessorConfig { + ytdlpLocation?: string; + ffmpegLocation?: string; + impersonateBrowser: string; + keepVideo: boolean; + outputDirectory?: string; +} + +/** + * Result from running yt-dlp including audio file path and video metadata. + */ +export interface YtDlpResult { + audioFilePath: string; + title?: string; + uploader?: string; + description?: string; + tags?: string[]; +} + +/** + * Structure of the yt-dlp info.json file (partial - only fields we use). + */ +interface YtDlpInfoJson { + title?: string; + uploader?: string; + description?: string; + tags?: string[]; + [key: string]: unknown; +} + +/** + * Determines the output directory for downloaded videos/audio. + */ +export function getOutputDirectory(config: VideoProcessorConfig): string { + if (config.keepVideo) { + const homeDir = os.homedir(); + const outputDir = config.outputDirectory || path.join(homeDir, 'Videos', 'Obsidian'); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + return outputDir; + } + return os.tmpdir(); +} + +/** + * Runs yt-dlp to extract audio from a video for transcription. + * Requires yt-dlp and ffmpeg to be installed and available in PATH. + * Returns the actual filepath and video metadata reported by yt-dlp. + */ +export async function runYtDlp( + url: string, + outputTemplate: string, + config: VideoProcessorConfig +): Promise { + const audioFilePath = await spawnYtDlp(url, outputTemplate, config); + + let metadata: YtDlpInfoJson | null = null; + try { + metadata = await readAndCleanupInfoJson(audioFilePath); + } catch (error) { + console.warn('Failed to read metadata, continuing without it:', error); + } + + return { + audioFilePath, + title: metadata?.title, + uploader: metadata?.uploader, + description: metadata?.description, + tags: metadata?.tags, + }; +} + +/** + * Spawns yt-dlp process and returns the audio file path. + */ +function spawnYtDlp( + url: string, + outputTemplate: string, + config: VideoProcessorConfig +): Promise { + return new Promise((resolve, reject) => { + const args = [ + '-x', // Extract audio + '--audio-format', 'mp3', // Convert to mp3 + '--audio-quality', '0', // Best quality + '--write-info-json', // Write metadata to .info.json file + ]; + + if (config.keepVideo) { + args.push('-k'); + } + + if (config.ffmpegLocation) { + args.push('--ffmpeg-location', config.ffmpegLocation); + } + + args.push( + '--impersonate', config.impersonateBrowser, + '-o', outputTemplate, + '--print', 'after_move:filepath', + url + ); + + let ytdlpCommand = 'yt-dlp'; + if (config.ytdlpLocation) { + ytdlpCommand = path.join(config.ytdlpLocation, 'yt-dlp'); + } + + const proc = spawn(ytdlpCommand, args, { + shell: false, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + const lines = stdout.trim().split('\n'); + const audioFilePath = lines[lines.length - 1]?.trim(); + + if (audioFilePath && fs.existsSync(audioFilePath)) { + resolve(audioFilePath); + } else { + reject(new Error(`yt-dlp did not return a valid filepath. Output: ${stdout}`)); + } + } else { + reject(new Error(`yt-dlp exited with code ${code}: ${stderr}`)); + } + }); + + proc.on('error', (err) => { + reject(new Error(`Failed to start yt-dlp: ${err.message}. Is yt-dlp installed?`)); + }); + }); +} + +/** + * Reads metadata from the .info.json file created by yt-dlp and cleans it up. + * The info.json file is created alongside the audio file with the same base name. + */ +async function readAndCleanupInfoJson(audioFilePath: string): Promise { + try { + const dir = path.dirname(audioFilePath); + const basename = path.basename(audioFilePath, path.extname(audioFilePath)); + const infoJsonPath = path.join(dir, `${basename}.info.json`); + + if (fs.existsSync(infoJsonPath)) { + const content = await fs.promises.readFile(infoJsonPath, 'utf-8'); + const metadata = JSON.parse(content) as YtDlpInfoJson; + + await fs.promises.unlink(infoJsonPath); + + return { + title: metadata.title || undefined, + uploader: metadata.uploader || undefined, + description: metadata.description || undefined, + tags: Array.isArray(metadata.tags) && metadata.tags.length > 0 + ? metadata.tags + : undefined, + }; + } + } catch (error) { + console.warn('Failed to read or parse info.json:', error); + } + + return null; +} + diff --git a/src/settings/index.ts b/src/settings/index.ts index b14a678..a06b041 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -16,6 +16,9 @@ export type { WorkflowOutputType, WorkflowType, PromptSourceType, + TranscriptionMediaType, + TranscriptionSourceType, + TranscriptionContextConfig, AIToolboxSettings, SettingsTabType, ExpandOnNextRenderState diff --git a/src/settings/types.ts b/src/settings/types.ts index aa55f0d..3c08bec 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -55,6 +55,24 @@ export type PromptSourceType = 'inline' | 'from-file'; */ export type WorkflowType = 'chat' | 'transcription'; +/** + * Media type for transcription context + */ +export type TranscriptionMediaType = 'video' | 'audio'; + +/** + * Source type for transcription input + */ +export type TranscriptionSourceType = 'select-file-from-vault' | 'url-from-clipboard' | 'url-from-selection'; + +/** + * Context configuration for transcription workflows + */ +export interface TranscriptionContextConfig { + mediaType: TranscriptionMediaType; + sourceType: TranscriptionSourceType; +} + /** * Configuration for a custom workflow */ @@ -74,6 +92,7 @@ export interface WorkflowConfig { // Transcription-specific settings (optional for backward compatibility) language?: string; includeTimestamps?: boolean; + transcriptionContext?: TranscriptionContextConfig; } /** diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index fc4c1e2..dbbff1e 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -5,6 +5,8 @@ import { WorkflowOutputType, WorkflowType, PromptSourceType, + TranscriptionMediaType, + TranscriptionSourceType, ExpandOnNextRenderState, generateId, DEFAULT_WORKFLOW_CONFIG @@ -378,6 +380,45 @@ function displayTranscriptionWorkflowSettings( // Provider selection (only models that support transcription) displayTranscriptionProviderSelection(contentContainer, plugin, workflow); + // Media type dropdown + const mediaTypeOptions: Record = { + 'video': 'Video', + 'audio': 'Audio' + }; + new Setting(contentContainer) + .setName('Media type') + .setDesc('The type of media to transcribe') + .addDropdown(dropdown => dropdown + .addOptions(mediaTypeOptions) + .setValue(workflow.transcriptionContext?.mediaType ?? 'video') + .onChange(async (value) => { + if (!workflow.transcriptionContext) { + workflow.transcriptionContext = { mediaType: 'video', sourceType: 'url-from-clipboard' }; + } + workflow.transcriptionContext.mediaType = value as TranscriptionMediaType; + await plugin.saveSettings(); + })); + + // Source type dropdown + const sourceTypeOptions: Record = { + 'select-file-from-vault': 'Select file from vault', + 'url-from-clipboard': 'URL from clipboard', + 'url-from-selection': 'URL from selection' + }; + new Setting(contentContainer) + .setName('Source') + .setDesc('Where to get the media file from') + .addDropdown(dropdown => dropdown + .addOptions(sourceTypeOptions) + .setValue(workflow.transcriptionContext?.sourceType ?? 'url-from-clipboard') + .onChange(async (value) => { + if (!workflow.transcriptionContext) { + workflow.transcriptionContext = { mediaType: 'video', sourceType: 'url-from-clipboard' }; + } + workflow.transcriptionContext.sourceType = value as TranscriptionSourceType; + await plugin.saveSettings(); + })); + // Language setting new Setting(contentContainer) .setName('Language') diff --git a/src/transcriptions/transcription-note.ts b/src/transcriptions/transcription-note.ts index 72c6208..7a9698c 100644 --- a/src/transcriptions/transcription-note.ts +++ b/src/transcriptions/transcription-note.ts @@ -1,6 +1,6 @@ import { App, Notice, TFile } from 'obsidian'; import { TranscriptionResult } from '../providers'; -import { VideoMetadata, videoPlatformRegistry } from './video-platforms'; +import { VideoMetadata, videoPlatformRegistry } from '../processing'; import { generateFilenameTimestamp } from '../utils/date-utils'; /** diff --git a/src/transcriptions/video-downloader.ts b/src/transcriptions/video-downloader.ts index ce11e72..e068e25 100644 --- a/src/transcriptions/video-downloader.ts +++ b/src/transcriptions/video-downloader.ts @@ -1,14 +1,16 @@ -import {Notice} from 'obsidian'; -import {spawn} from 'child_process'; +import { Notice } from 'obsidian'; import * as path from 'path'; -import * as os from 'os'; -import * as fs from 'fs'; -import { Buffer } from 'buffer'; -import {AIToolboxSettings} from '../settings'; -import { videoPlatformRegistry, VideoMetadata } from './video-platforms'; - -// Re-export VideoMetadata for backwards compatibility -export type { VideoMetadata } from './video-platforms'; +import { AIToolboxSettings } from '../settings'; +import { + videoPlatformRegistry, + VideoMetadata, + getOutputDirectory, + runYtDlp, + VideoProcessorConfig, +} from '../processing'; + +// Re-export types for backwards compatibility +export type { VideoMetadata } from '../processing'; export interface ExtractAudioResult { audioFilePath: string; @@ -16,6 +18,19 @@ export interface ExtractAudioResult { metadata?: VideoMetadata; } +/** + * Converts AIToolboxSettings to VideoProcessorConfig. + */ +function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorConfig { + return { + ytdlpLocation: settings.ytdlpLocation, + ffmpegLocation: settings.ffmpegLocation, + impersonateBrowser: settings.impersonateBrowser, + keepVideo: settings.keepVideo, + outputDirectory: settings.outputDirectory, + }; +} + /** * Downloads audio from a video URL in the clipboard for transcription. * Supports TikTok, YouTube, and other platforms supported by yt-dlp. @@ -24,7 +39,6 @@ export interface ExtractAudioResult { */ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { try { - // Read URL from clipboard const clipboardText = await navigator.clipboard.readText(); if (!clipboardText) { @@ -32,7 +46,6 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr return null; } - // Validate video URL if (!videoPlatformRegistry.isValidVideoUrl(clipboardText)) { new Notice('Clipboard does not contain a valid video URL'); return null; @@ -41,34 +54,16 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr const url = clipboardText.trim(); new Notice('Preparing video for transcription...'); - // Get platform-specific handler for output template const handler = videoPlatformRegistry.findHandlerForUrl(url); const filenameTemplate = handler ? handler.getYtDlpArgs().outputConfig.filenameTemplate : '%(title)s_%(id)s'; - let outputDir: string; - - if (settings.keepVideo) { - // Use custom directory or default Downloads folder when keeping video - const homeDir = os.homedir(); - outputDir = settings.outputDirectory || - path.join(homeDir, 'Videos', 'Obsidian'); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, {recursive: true}); - } - } else { - // Use system temporary directory when not keeping video - outputDir = os.tmpdir(); - } - - // Use platform-specific output template + const config = settingsToProcessorConfig(settings); + const outputDir = getOutputDirectory(config); const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); - // Run yt-dlp to extract audio for transcription and get metadata - const ytdlpResult = await runYtDlp(url, outputTemplate, settings); + const ytdlpResult = await runYtDlp(url, outputTemplate, config); new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); @@ -91,157 +86,5 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr } } -/** - * Result from running yt-dlp including audio file path and video metadata - */ -interface YtDlpResult { - audioFilePath: string; - title?: string; - uploader?: string; - description?: string; - tags?: string[]; -} - -/** - * Structure of the yt-dlp info.json file (partial - only fields we use) - */ -interface YtDlpInfoJson { - title?: string; - uploader?: string; - description?: string; - tags?: string[]; - [key: string]: unknown; -} - -/** - * Runs yt-dlp to extract audio from a video for transcription. - * Requires yt-dlp and ffmpeg to be installed and available in PATH. - * Returns the actual filepath and video metadata reported by yt-dlp. - */ -async function runYtDlp(url: string, outputTemplate: string, settings: AIToolboxSettings): Promise { - const audioFilePath = await spawnYtDlp(url, outputTemplate, settings); - - // Read metadata from the .info.json file - let metadata: YtDlpInfoJson | null = null; - try { - metadata = await readAndCleanupInfoJson(audioFilePath); - } catch (error) { - console.warn('Failed to read metadata, continuing without it:', error); - } - - return { - audioFilePath, - title: metadata?.title, - uploader: metadata?.uploader, - description: metadata?.description, - tags: metadata?.tags, - }; -} - -/** - * Spawns yt-dlp process and returns the audio file path. - */ -function spawnYtDlp(url: string, outputTemplate: string, settings: AIToolboxSettings): Promise { - return new Promise((resolve, reject) => { - const args = [ - '-x', // Extract audio - '--audio-format', 'mp3', // Convert to mp3 - '--audio-quality', '0', // Best quality - '--write-info-json', // Write metadata to .info.json file - ]; - - // Keep video file if setting is enabled - if (settings.keepVideo) { - args.push('-k'); - } - - // Add ffmpeg location if specified in settings - if (settings.ffmpegLocation) { - args.push('--ffmpeg-location', settings.ffmpegLocation); - } - - args.push( - '--impersonate', settings.impersonateBrowser, // Impersonate browser from settings - '-o', outputTemplate, // Output template - '--print', 'after_move:filepath', // Print the final filepath after all processing - url - ); - - let ytdlpCommand = 'yt-dlp'; - if (settings.ytdlpLocation) { - ytdlpCommand = path.join(settings.ytdlpLocation, 'yt-dlp'); - } - - const proc = spawn(ytdlpCommand, args, { - shell: false, // Don't pass back to the shell, prevents it seeing the templating as interpretable by the shell - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); - }); - - proc.on('close', (code) => { - if (code === 0) { - const lines = stdout.trim().split('\n'); - const audioFilePath = lines[lines.length - 1]?.trim(); - - if (audioFilePath && fs.existsSync(audioFilePath)) { - resolve(audioFilePath); - } else { - reject(new Error(`yt-dlp did not return a valid filepath. Output: ${stdout}`)); - } - } else { - reject(new Error(`yt-dlp exited with code ${code}: ${stderr}`)); - } - }); - - proc.on('error', (err) => { - reject(new Error(`Failed to start yt-dlp: ${err.message}. Is yt-dlp installed?`)); - }); - }); -} - -/** - * Reads metadata from the .info.json file created by yt-dlp and cleans it up. - * The info.json file is created alongside the audio file with the same base name. - */ -async function readAndCleanupInfoJson(audioFilePath: string): Promise { - try { - // The info.json file has the same base name but with .info.json extension - // e.g., "video_123.mp3" -> "video_123.info.json" - const dir = path.dirname(audioFilePath); - const basename = path.basename(audioFilePath, path.extname(audioFilePath)); - const infoJsonPath = path.join(dir, `${basename}.info.json`); - - if (fs.existsSync(infoJsonPath)) { - const content = await fs.promises.readFile(infoJsonPath, 'utf-8'); - const metadata = JSON.parse(content) as YtDlpInfoJson; - - // Clean up the info.json file after reading - await fs.promises.unlink(infoJsonPath); - - return { - title: metadata.title || undefined, - uploader: metadata.uploader || undefined, - description: metadata.description || undefined, - tags: Array.isArray(metadata.tags) && metadata.tags.length > 0 - ? metadata.tags - : undefined, - }; - } - } catch (error) { - console.warn('Failed to read or parse info.json:', error); - } - - return null; -} - diff --git a/styles.css b/styles.css index 85afa43..ec89f5f 100644 --- a/styles.css +++ b/styles.css @@ -316,3 +316,4 @@ button.mod-success:hover { height: 15px; stroke-width: 2; } + From 5864bdf52dff6f3ebaa734405ed09cc2b02f9117 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Thu, 8 Jan 2026 14:53:34 -0600 Subject: [PATCH 13/22] stash --- src/processing/audio-processor.ts | 268 ++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/processing/audio-processor.ts diff --git a/src/processing/audio-processor.ts b/src/processing/audio-processor.ts new file mode 100644 index 0000000..0dbf4ee --- /dev/null +++ b/src/processing/audio-processor.ts @@ -0,0 +1,268 @@ +import { Buffer } from 'buffer'; +import * as fs from 'fs'; + +/** + * Interface for transcription API response + */ +export interface TranscriptionApiResponse { + text: string; + segments?: Array<{ text: string; start: number; end: number }>; +} + +/** + * Individual chunk with timestamps from transcription + */ +export interface TranscriptionChunk { + text: string; + timestamp: [number, number | null]; +} + +/** + * Result from audio transcription + */ +export interface TranscriptionResult { + text: string; + chunks?: TranscriptionChunk[]; + audioFilePath: string; +} + +/** + * Interface for additional form fields to include in multipart form data + */ +export interface FormField { + name: string; + value: string; +} + +/** + * Options for building multipart form data for audio transcription + */ +export interface MultipartFormDataOptions { + boundary: string; + audioBuffer: Buffer; + fileName: string; + includeTimestamps: boolean; + language?: string; + additionalFields?: FormField[]; +} + +/** + * Options for preparing audio form data from a file path + */ +export interface PrepareAudioFormDataOptions { + audioFilePath: string; + includeTimestamps: boolean; + language?: string; + additionalFields?: FormField[]; +} + +/** + * Result from preparing audio form data + */ +export interface PreparedAudioFormData { + boundary: string; + formData: ArrayBuffer; + fileName: string; +} + +/** + * Combines string and Buffer parts into a single ArrayBuffer. + * Used internally by buildMultipartFormData. + */ +function combinePartsToArrayBuffer(parts: (string | Buffer)[]): ArrayBuffer { + const encoder = new TextEncoder(); + const buffers: Uint8Array[] = parts.map(part => { + if (typeof part === 'string') { + return encoder.encode(part); + } + return new Uint8Array(part.buffer, part.byteOffset, part.byteLength); + }); + + const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const buf of buffers) { + combined.set(new Uint8Array(buf), offset); + offset += buf.byteLength; + } + + return combined.buffer; +} + +/** + * Builds multipart form data for audio transcription API requests. + * Creates a properly formatted multipart/form-data body with the audio file + * and optional configuration fields. + * + * @param options - Configuration for the multipart form data + * @returns ArrayBuffer containing the complete multipart form data + */ +export function buildMultipartFormData(options: MultipartFormDataOptions): ArrayBuffer { + const { boundary, audioBuffer, fileName, includeTimestamps, language, additionalFields = [] } = options; + const parts: (string | Buffer)[] = []; + + // File field + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`); + parts.push(`Content-Type: application/octet-stream\r\n\r\n`); + parts.push(audioBuffer); + parts.push('\r\n'); + + // Additional provider-specific fields (e.g., model for OpenAI) + for (const field of additionalFields) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="${field.name}"\r\n\r\n`); + parts.push(`${field.value}\r\n`); + } + + // Response format + const responseFormat = includeTimestamps ? 'verbose_json' : 'json'; + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="response_format"\r\n\r\n`); + parts.push(`${responseFormat}\r\n`); + + // Language (optional) + if (language) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="language"\r\n\r\n`); + parts.push(`${language}\r\n`); + } + + // Timestamp granularities (for verbose_json) + if (includeTimestamps) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="timestamp_granularities[]"\r\n\r\n`); + parts.push(`segment\r\n`); + } + + parts.push(`--${boundary}--\r\n`); + + return combinePartsToArrayBuffer(parts); +} + +/** + * Generates a random boundary string for multipart form data. + * @returns A unique boundary string + */ +export function generateFormBoundary(): string { + return '----FormBoundary' + Math.random().toString(36).substring(2); +} + +/** + * Validates that an audio file exists at the given path. + * @param audioFilePath - Path to the audio file + * @throws Error if the file does not exist + */ +export function validateAudioFile(audioFilePath: string): void { + if (!fs.existsSync(audioFilePath)) { + throw new Error(`Audio file not found: ${audioFilePath}`); + } +} + +/** + * Extracts the filename from a file path. + * @param filePath - Full path to the file + * @param defaultName - Default name if extraction fails + * @returns The extracted filename + */ +export function extractFileName(filePath: string, defaultName = 'audio.mp3'): string { + return filePath.split(/[/\\]/).pop() || defaultName; +} + +/** + * Prepares audio form data for transcription API requests. + * Reads the audio file, generates a boundary, and builds the multipart form data. + * + * @param options - Configuration for preparing the form data + * @returns Object containing the boundary and prepared form data + * @throws Error if the audio file cannot be read + */ +export function prepareAudioFormData(options: PrepareAudioFormDataOptions): PreparedAudioFormData { + const { audioFilePath, includeTimestamps, language, additionalFields } = options; + + validateAudioFile(audioFilePath); + + const audioBuffer = fs.readFileSync(audioFilePath); + const fileName = extractFileName(audioFilePath); + const boundary = generateFormBoundary(); + + const formData = buildMultipartFormData({ + boundary, + audioBuffer, + fileName, + includeTimestamps, + language, + additionalFields, + }); + + return { boundary, formData, fileName }; +} + +/** + * Creates multipart form data with a generated test audio for API testing. + * Generates a 1-second WAV file at 16kHz mono with a simple tone pattern + * that simulates speech-like sound, directly as form data without writing to disk. + * + * @returns Object containing the boundary and prepared form data + */ +export function createTestAudioFormData(): PreparedAudioFormData { + const sampleRate = 16000; + const duration = 1; + const numSamples = sampleRate * duration; + const numChannels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = numSamples * blockAlign; + const fileSize = 36 + dataSize; + + const audioBuffer = Buffer.alloc(44 + dataSize); + let offset = 0; + + // RIFF header + audioBuffer.write('RIFF', offset); offset += 4; + audioBuffer.writeUInt32LE(fileSize, offset); offset += 4; + audioBuffer.write('WAVE', offset); offset += 4; + + // fmt chunk + audioBuffer.write('fmt ', offset); offset += 4; + audioBuffer.writeUInt32LE(16, offset); offset += 4; + audioBuffer.writeUInt16LE(1, offset); offset += 2; + audioBuffer.writeUInt16LE(numChannels, offset); offset += 2; + audioBuffer.writeUInt32LE(sampleRate, offset); offset += 4; + audioBuffer.writeUInt32LE(byteRate, offset); offset += 4; + audioBuffer.writeUInt16LE(blockAlign, offset); offset += 2; + audioBuffer.writeUInt16LE(bitsPerSample, offset); offset += 2; + + // data chunk + audioBuffer.write('data', offset); offset += 4; + audioBuffer.writeUInt32LE(dataSize, offset); offset += 4; + + // Generate audio data with speech-like pattern + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + const freq1 = 200 + Math.sin(t * 10) * 50; + const freq2 = 800 + Math.sin(t * 15) * 100; + const freq3 = 2400; + const envelope = Math.sin(t * Math.PI) * 0.3; + const sample = envelope * ( + Math.sin(2 * Math.PI * freq1 * t) * 0.5 + + Math.sin(2 * Math.PI * freq2 * t) * 0.3 + + Math.sin(2 * Math.PI * freq3 * t) * 0.2 + ); + audioBuffer.writeInt16LE(Math.floor(sample * 32767), offset); + offset += 2; + } + + const fileName = 'test-audio.wav'; + const boundary = generateFormBoundary(); + const formData = buildMultipartFormData({ + boundary, + audioBuffer, + fileName, + includeTimestamps: false, + }); + + return { boundary, formData, fileName }; +} From 6db5c88b652b1a875e7838a99590d5d48aa485d3 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Thu, 8 Jan 2026 15:23:08 -0600 Subject: [PATCH 14/22] stash --- src/processing/audio-processor.ts | 27 ++- src/processing/index.ts | 23 ++- src/providers/azure-openai-provider.ts | 2 +- ...ase-model-provider.ts => base-provider.ts} | 154 +++++------------- src/providers/index.ts | 5 +- src/providers/openai-provider.ts | 5 +- ...rovider-factory.ts => provider-factory.ts} | 0 src/providers/types.ts | 18 ++ src/settings/providers.ts | 92 +---------- 9 files changed, 107 insertions(+), 219 deletions(-) rename src/providers/{base-model-provider.ts => base-provider.ts} (61%) rename src/providers/{model-provider-factory.ts => provider-factory.ts} (100%) diff --git a/src/processing/audio-processor.ts b/src/processing/audio-processor.ts index 0dbf4ee..15d7d13 100644 --- a/src/processing/audio-processor.ts +++ b/src/processing/audio-processor.ts @@ -199,13 +199,21 @@ export function prepareAudioFormData(options: PrepareAudioFormDataOptions): Prep } /** - * Creates multipart form data with a generated test audio for API testing. + * Result from creating test audio data + */ +export interface TestAudioData { + audioBuffer: Buffer; + fileName: string; +} + +/** + * Creates a test audio buffer for API testing. * Generates a 1-second WAV file at 16kHz mono with a simple tone pattern - * that simulates speech-like sound, directly as form data without writing to disk. + * that simulates speech-like sound. * - * @returns Object containing the boundary and prepared form data + * @returns Object containing the audio buffer and filename */ -export function createTestAudioFormData(): PreparedAudioFormData { +export function createTestAudioBuffer(): TestAudioData { const sampleRate = 16000; const duration = 1; const numSamples = sampleRate * duration; @@ -255,14 +263,5 @@ export function createTestAudioFormData(): PreparedAudioFormData { offset += 2; } - const fileName = 'test-audio.wav'; - const boundary = generateFormBoundary(); - const formData = buildMultipartFormData({ - boundary, - audioBuffer, - fileName, - includeTimestamps: false, - }); - - return { boundary, formData, fileName }; + return { audioBuffer, fileName: 'test-audio.wav' }; } diff --git a/src/processing/index.ts b/src/processing/index.ts index 8a0b422..2e7d231 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -1,7 +1,26 @@ +// Audio processor - multipart form data and audio file handling +export { + buildMultipartFormData, + generateFormBoundary, + validateAudioFile, + extractFileName, + prepareAudioFormData, + createTestAudioBuffer, +} from './audio-processor'; + +export type { + TranscriptionApiResponse, + TranscriptionChunk, + TranscriptionResult, + FormField, + MultipartFormDataOptions, + PrepareAudioFormDataOptions, + PreparedAudioFormData, + TestAudioData, +} from './audio-processor'; + // Video processor - yt-dlp related functionality export { - VideoProcessorConfig, - YtDlpResult, getOutputDirectory, runYtDlp, } from './video-processor'; diff --git a/src/providers/azure-openai-provider.ts b/src/providers/azure-openai-provider.ts index 071e77e..87c115d 100644 --- a/src/providers/azure-openai-provider.ts +++ b/src/providers/azure-openai-provider.ts @@ -1,6 +1,6 @@ import { ModelProviderConfig, ChatMessage, ChatOptions } from './types'; import { AIProviderType } from '../settings'; -import { BaseProvider } from './base-model-provider'; +import { BaseProvider } from './base-provider'; /** * Azure OpenAI model provider implementation. diff --git a/src/providers/base-model-provider.ts b/src/providers/base-provider.ts similarity index 61% rename from src/providers/base-model-provider.ts rename to src/providers/base-provider.ts index 4f212bd..0d5acf6 100644 --- a/src/providers/base-model-provider.ts +++ b/src/providers/base-provider.ts @@ -1,16 +1,7 @@ import { Notice, requestUrl } from 'obsidian'; -import * as fs from 'fs'; -import { Buffer } from 'buffer'; -import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult, ChatMessage, ChatOptions, ChatResult } from './types'; +import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult, ChatMessage, ChatOptions, ChatResult, TestAudioData } from './types'; import { AIProviderType } from '../settings'; - -/** - * Interface for transcription API response - */ -export interface TranscriptionApiResponse { - text: string; - segments?: Array<{ text: string; start: number; end: number }>; -} +import { prepareAudioFormData, TranscriptionApiResponse, FormField, buildMultipartFormData, generateFormBoundary } from '../processing/audio-processor'; /** * Abstract base class for AI model providers. @@ -102,43 +93,19 @@ export abstract class BaseProvider implements ModelProvider { } async transcribeAudio(audioFilePath: string, options: TranscriptionOptions = {}): Promise { - if (!fs.existsSync(audioFilePath)) { - throw new Error(`Audio file not found: ${audioFilePath}`); - } - this.validateTranscriptionConfig(); try { new Notice(`Transcribing audio with ${this.getProviderDisplayName()}`); - const audioBuffer = fs.readFileSync(audioFilePath); - const fileName = audioFilePath.split(/[/\\]/).pop() || 'audio.mp3'; - const apiUrl = this.buildTranscriptionUrl(); - - const boundary = '----FormBoundary' + Math.random().toString(36).substring(2); - const formData = this.buildMultipartFormData( - boundary, - audioBuffer, - fileName, - options.includeTimestamps || false, - options.language - ); - - const response = await requestUrl({ - url: apiUrl, - method: 'POST', - headers: { - ...this.getAuthHeaders(), - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - }, - body: formData, + const { boundary, formData } = prepareAudioFormData({ + audioFilePath, + includeTimestamps: options.includeTimestamps || false, + language: options.language, + additionalFields: this.getAdditionalFormFields(), }); - if (response.status !== 200) { - throw new Error(`${this.getProviderDisplayName()} API error: ${response.status} - ${response.text}`); - } - - const result = response.json as TranscriptionApiResponse; + const result = await this.sendTranscriptionRequest(boundary, formData); new Notice('Transcription complete!'); return this.parseTranscriptionResponse(result, audioFilePath, options.includeTimestamps || false); @@ -150,6 +117,41 @@ export abstract class BaseProvider implements ModelProvider { } } + async transcribeAudioBuffer(testAudio: TestAudioData): Promise { + this.validateTranscriptionConfig(); + + const boundary = generateFormBoundary(); + const formData = buildMultipartFormData({ + boundary, + audioBuffer: testAudio.audioBuffer, + fileName: testAudio.fileName, + includeTimestamps: false, + additionalFields: this.getAdditionalFormFields(), + }); + + const result = await this.sendTranscriptionRequest(boundary, formData); + return result.text; + } + + private async sendTranscriptionRequest(boundary: string, formData: ArrayBuffer): Promise { + const apiUrl = this.buildTranscriptionUrl(); + const response = await requestUrl({ + url: apiUrl, + method: 'POST', + headers: { + ...this.getAuthHeaders(), + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }, + body: formData, + }); + + if (response.status !== 200) { + throw new Error(`${this.getProviderDisplayName()} API error: ${response.status} - ${response.text}`); + } + + return response.json as TranscriptionApiResponse; + } + /** * Get display name for this provider (used in notices) */ @@ -190,7 +192,7 @@ export abstract class BaseProvider implements ModelProvider { /** * Get additional form fields for the multipart request (e.g., model field for OpenAI) */ - protected getAdditionalFormFields(): Array<{ name: string; value: string }> { + protected getAdditionalFormFields(): FormField[] { return []; } @@ -218,74 +220,6 @@ export abstract class BaseProvider implements ModelProvider { return result; } - protected buildMultipartFormData( - boundary: string, - audioBuffer: Buffer, - fileName: string, - includeTimestamps: boolean, - language?: string - ): ArrayBuffer { - const parts: (string | Buffer)[] = []; - - // File field - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`); - parts.push(`Content-Type: application/octet-stream\r\n\r\n`); - parts.push(audioBuffer); - parts.push('\r\n'); - - // Additional provider-specific fields (e.g., model for OpenAI) - for (const field of this.getAdditionalFormFields()) { - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="${field.name}"\r\n\r\n`); - parts.push(`${field.value}\r\n`); - } - - // Response format - const responseFormat = includeTimestamps ? 'verbose_json' : 'json'; - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="response_format"\r\n\r\n`); - parts.push(`${responseFormat}\r\n`); - - // Language (optional) - if (language) { - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="language"\r\n\r\n`); - parts.push(`${language}\r\n`); - } - - // Timestamp granularities (for verbose_json) - if (includeTimestamps) { - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="timestamp_granularities[]"\r\n\r\n`); - parts.push(`segment\r\n`); - } - - parts.push(`--${boundary}--\r\n`); - - return this.combinePartsToArrayBuffer(parts); - } - - private combinePartsToArrayBuffer(parts: (string | Buffer)[]): ArrayBuffer { - const encoder = new TextEncoder(); - const buffers: Uint8Array[] = parts.map(part => { - if (typeof part === 'string') { - return encoder.encode(part); - } - return new Uint8Array(part.buffer, part.byteOffset, part.byteLength); - }); - - const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - for (const buf of buffers) { - combined.set(new Uint8Array(buf), offset); - offset += buf.byteLength; - } - - return combined.buffer; - } - protected parseTranscriptionResponse( response: TranscriptionApiResponse, audioFilePath: string, diff --git a/src/providers/index.ts b/src/providers/index.ts index 55653e2..de40391 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,7 @@ export type { TranscriptionOptions, TranscriptionResult, TranscriptionChunk, + TestAudioData, ChatMessage, ChatMessageRole, ChatOptions, @@ -12,7 +13,7 @@ export type { } from './types'; // Base class (for extending) -export { BaseProvider } from './base-model-provider'; +export { BaseProvider } from './base-provider'; // Model provider implementations export { AzureOpenAIModelProvider } from './azure-openai-provider'; @@ -24,5 +25,5 @@ export { createTranscriptionProvider, createWorkflowProvider, ProviderCreationError, -} from './model-provider-factory'; +} from './provider-factory'; diff --git a/src/providers/openai-provider.ts b/src/providers/openai-provider.ts index 2beb91e..0d921d3 100644 --- a/src/providers/openai-provider.ts +++ b/src/providers/openai-provider.ts @@ -1,6 +1,7 @@ import { ModelProviderConfig, ChatMessage, ChatOptions } from './types'; import { AIProviderType, DEFAULT_OPENAI_ENDPOINT } from '../settings/index'; -import { BaseProvider } from './base-model-provider'; +import { BaseProvider } from './base-provider'; +import { FormField } from '../processing/audio-processor'; /** * OpenAI model provider implementation. @@ -65,7 +66,7 @@ export class OpenAIModelProvider extends BaseProvider { /** * OpenAI API requires the model field in the request body */ - protected override getAdditionalFormFields(): Array<{ name: string; value: string }> { + protected override getAdditionalFormFields(): FormField[] { return [ { name: 'model', value: this.modelId }, ]; diff --git a/src/providers/model-provider-factory.ts b/src/providers/provider-factory.ts similarity index 100% rename from src/providers/model-provider-factory.ts rename to src/providers/provider-factory.ts diff --git a/src/providers/types.ts b/src/providers/types.ts index 2e18f0d..8ead92c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -25,6 +25,14 @@ export interface TranscriptionResult { audioFilePath: string; } +/** + * Test audio data for transcription testing + */ +export interface TestAudioData { + audioBuffer: Buffer; + fileName: string; +} + /** * Role for chat messages */ @@ -111,6 +119,16 @@ export interface ModelProvider { */ transcribeAudio(audioFilePath: string, options?: TranscriptionOptions): Promise; + /** + * Transcribe audio using a raw audio buffer. + * Useful for testing without writing to disk. + * + * @param testAudio - Audio buffer and filename + * @returns Promise resolving to the transcription text + * @throws Error if transcription fails or is not supported by this provider + */ + transcribeAudioBuffer(testAudio: TestAudioData): Promise; + /** * Check if this provider supports audio transcription. * diff --git a/src/settings/providers.ts b/src/settings/providers.ts index b3d5c33..37a259d 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -10,9 +10,7 @@ import { } from "./types"; import { createModelProvider, ModelProviderConfig } from "../providers"; import { createCollapsibleSection } from "../components/collapsible-section"; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; +import { createTestAudioBuffer } from "../processing/audio-processor"; /** * Callbacks for the provider settings tab to communicate with the main settings tab @@ -28,84 +26,6 @@ export interface ProviderSettingsCallbacks { */ type TestButtonState = 'ready' | 'testing' | 'success' | 'error'; -/** - * Generate a minimal valid WAV file with audio content - * Creates a 1-second WAV file at 16kHz mono with a simple tone pattern - */ -function generateTestAudioFile(): Buffer { - const sampleRate = 16000; - const duration = 1; // 1 second - const numSamples = sampleRate * duration; - const numChannels = 1; - const bitsPerSample = 16; - const bytesPerSample = bitsPerSample / 8; - const blockAlign = numChannels * bytesPerSample; - const byteRate = sampleRate * blockAlign; - const dataSize = numSamples * blockAlign; - const fileSize = 36 + dataSize; - - const buffer = Buffer.alloc(44 + dataSize); - let offset = 0; - - // RIFF header - buffer.write('RIFF', offset); offset += 4; - buffer.writeUInt32LE(fileSize, offset); offset += 4; - buffer.write('WAVE', offset); offset += 4; - - // fmt chunk - buffer.write('fmt ', offset); offset += 4; - buffer.writeUInt32LE(16, offset); offset += 4; // fmt chunk size - buffer.writeUInt16LE(1, offset); offset += 2; // audio format (1 = PCM) - buffer.writeUInt16LE(numChannels, offset); offset += 2; - buffer.writeUInt32LE(sampleRate, offset); offset += 4; - buffer.writeUInt32LE(byteRate, offset); offset += 4; - buffer.writeUInt16LE(blockAlign, offset); offset += 2; - buffer.writeUInt16LE(bitsPerSample, offset); offset += 2; - - // data chunk - buffer.write('data', offset); offset += 4; - buffer.writeUInt32LE(dataSize, offset); offset += 4; - - // Generate audio data - simple pattern that sounds like speech - // Using multiple frequencies to create a more natural sound - for (let i = 0; i < numSamples; i++) { - const t = i / sampleRate; - // Mix of frequencies to simulate speech-like sound - const freq1 = 200 + Math.sin(t * 10) * 50; // Varying fundamental frequency - const freq2 = 800 + Math.sin(t * 15) * 100; // First formant - const freq3 = 2400; // Second formant - - // Envelope to create word-like pattern - const envelope = Math.sin(t * Math.PI) * 0.3; - - const sample = envelope * ( - Math.sin(2 * Math.PI * freq1 * t) * 0.5 + - Math.sin(2 * Math.PI * freq2 * t) * 0.3 + - Math.sin(2 * Math.PI * freq3 * t) * 0.2 - ); - - const value = Math.floor(sample * 32767); - buffer.writeInt16LE(value, offset); - offset += 2; - } - - return buffer; -} - -/** - * Get a temporary test audio file path with the embedded test audio - */ -function getTestAudioFile(): string { - const tempDir = os.tmpdir(); - const testAudioPath = path.join(tempDir, 'obsidian-ai-toolbox-test.wav'); - - // Always regenerate the test audio file to ensure it's valid - const audioBuffer = generateTestAudioFile(); - fs.writeFileSync(testAudioPath, audioBuffer); - - return testAudioPath; -} - /** * Check if a model has all required configuration for testing */ @@ -187,13 +107,9 @@ async function testModelTranscription(provider: AIProviderConfig, model: AIModel const config = buildProviderConfig(provider, model); const modelProvider = createModelProvider(config); - // Get test audio file - const testAudioPath = getTestAudioFile(); - - // Transcribe the test audio - await modelProvider.transcribeAudio(testAudioPath, { - includeTimestamps: false - }); + // Create test audio and let the provider build form data with its fields + const testAudio = createTestAudioBuffer(); + await modelProvider.transcribeAudioBuffer(testAudio); return { success: true }; } catch (error) { From 0a6980afef083bd4ccb707e2eee5f0166a5fc57a Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Thu, 8 Jan 2026 16:09:29 -0600 Subject: [PATCH 15/22] next --- .../workflow-suggester.ts | 0 src/main.ts | 3 +- src/processing/index.ts | 7 + src/processing/video-processor.ts | 79 +++++++ .../workflow-executor.ts | 1 - src/transcriptions/transcription-workflow.ts | 2 +- src/transcriptions/video-downloader.ts | 90 ------- src/transcriptions/whisper-transcriber.ts | 220 ------------------ src/workflows/index.ts | 3 - 9 files changed, 89 insertions(+), 316 deletions(-) rename src/{workflows => components}/workflow-suggester.ts (100%) rename src/{workflows => processing}/workflow-executor.ts (99%) delete mode 100644 src/transcriptions/video-downloader.ts delete mode 100644 src/transcriptions/whisper-transcriber.ts delete mode 100644 src/workflows/index.ts diff --git a/src/workflows/workflow-suggester.ts b/src/components/workflow-suggester.ts similarity index 100% rename from src/workflows/workflow-suggester.ts rename to src/components/workflow-suggester.ts diff --git a/src/main.ts b/src/main.ts index ee10494..135cb99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,8 @@ import { Notice, Plugin } from 'obsidian'; import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { createTranscriptionProvider } from "./providers"; import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; -import { WorkflowSuggesterModal, executeWorkflow } from "./workflows"; +import { WorkflowSuggesterModal } from "./components/workflow-suggester"; +import { executeWorkflow } from "./processing/workflow-executor"; export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; diff --git a/src/processing/index.ts b/src/processing/index.ts index 2e7d231..b575225 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -23,6 +23,13 @@ export type { export { getOutputDirectory, runYtDlp, + extractAudioFromClipboard, +} from './video-processor'; + +export type { + VideoProcessorConfig, + YtDlpResult, + ExtractAudioResult, } from './video-processor'; // Video platforms - platform-specific handlers diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 5884bad..6d8a77e 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -3,6 +3,9 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { Buffer } from 'buffer'; +import { Notice } from 'obsidian'; +import { AIToolboxSettings } from '../settings'; +import { videoPlatformRegistry, VideoMetadata } from './video-platforms'; /** * Configuration for video processing operations. @@ -185,3 +188,79 @@ async function readAndCleanupInfoJson(audioFilePath: string): Promise { + try { + const clipboardText = await navigator.clipboard.readText(); + + if (!clipboardText) { + new Notice('Clipboard is empty'); + return null; + } + + if (!videoPlatformRegistry.isValidVideoUrl(clipboardText)) { + new Notice('Clipboard does not contain a valid video URL'); + return null; + } + + const url = clipboardText.trim(); + new Notice('Preparing video for transcription...'); + + const handler = videoPlatformRegistry.findHandlerForUrl(url); + const filenameTemplate = handler + ? handler.getYtDlpArgs().outputConfig.filenameTemplate + : '%(title)s_%(id)s'; + + const config = settingsToProcessorConfig(settings); + const outputDir = getOutputDirectory(config); + const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); + + const ytdlpResult = await runYtDlp(url, outputTemplate, config); + + new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); + + return { + audioFilePath: ytdlpResult.audioFilePath, + sourceUrl: url, + metadata: { + title: ytdlpResult.title, + uploader: ytdlpResult.uploader, + description: ytdlpResult.description, + tags: ytdlpResult.tags, + }, + }; + + } catch (error) { + console.error('Video audio extraction error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to extract audio for transcription: ${errorMessage}`); + return null; + } +} diff --git a/src/workflows/workflow-executor.ts b/src/processing/workflow-executor.ts similarity index 99% rename from src/workflows/workflow-executor.ts rename to src/processing/workflow-executor.ts index 7007e7e..054ed74 100644 --- a/src/workflows/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -380,4 +380,3 @@ async function executeTranscriptionWorkflow( modal.open(); } - diff --git a/src/transcriptions/transcription-workflow.ts b/src/transcriptions/transcription-workflow.ts index 2642b31..46537c8 100644 --- a/src/transcriptions/transcription-workflow.ts +++ b/src/transcriptions/transcription-workflow.ts @@ -1,7 +1,7 @@ import { App, Notice } from 'obsidian'; import { AIToolboxSettings } from '../settings'; import { ModelProvider, TranscriptionOptions } from '../providers'; -import { extractAudioFromClipboard } from './video-downloader'; +import { extractAudioFromClipboard } from '../processing'; import { createTranscriptionNote, openTranscriptionNote } from './transcription-note'; import * as fs from 'fs'; diff --git a/src/transcriptions/video-downloader.ts b/src/transcriptions/video-downloader.ts deleted file mode 100644 index e068e25..0000000 --- a/src/transcriptions/video-downloader.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Notice } from 'obsidian'; -import * as path from 'path'; -import { AIToolboxSettings } from '../settings'; -import { - videoPlatformRegistry, - VideoMetadata, - getOutputDirectory, - runYtDlp, - VideoProcessorConfig, -} from '../processing'; - -// Re-export types for backwards compatibility -export type { VideoMetadata } from '../processing'; - -export interface ExtractAudioResult { - audioFilePath: string; - sourceUrl: string; - metadata?: VideoMetadata; -} - -/** - * Converts AIToolboxSettings to VideoProcessorConfig. - */ -function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorConfig { - return { - ytdlpLocation: settings.ytdlpLocation, - ffmpegLocation: settings.ffmpegLocation, - impersonateBrowser: settings.impersonateBrowser, - keepVideo: settings.keepVideo, - outputDirectory: settings.outputDirectory, - }; -} - -/** - * Downloads audio from a video URL in the clipboard for transcription. - * Supports TikTok, YouTube, and other platforms supported by yt-dlp. - * Uses yt-dlp directly via child_process for audio extraction. - * Requires yt-dlp and ffmpeg to be installed and available in PATH. - */ -export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { - try { - const clipboardText = await navigator.clipboard.readText(); - - if (!clipboardText) { - new Notice('Clipboard is empty'); - return null; - } - - if (!videoPlatformRegistry.isValidVideoUrl(clipboardText)) { - new Notice('Clipboard does not contain a valid video URL'); - return null; - } - - const url = clipboardText.trim(); - new Notice('Preparing video for transcription...'); - - const handler = videoPlatformRegistry.findHandlerForUrl(url); - const filenameTemplate = handler - ? handler.getYtDlpArgs().outputConfig.filenameTemplate - : '%(title)s_%(id)s'; - - const config = settingsToProcessorConfig(settings); - const outputDir = getOutputDirectory(config); - const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); - - const ytdlpResult = await runYtDlp(url, outputTemplate, config); - - new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); - - return { - audioFilePath: ytdlpResult.audioFilePath, - sourceUrl: url, - metadata: { - title: ytdlpResult.title, - uploader: ytdlpResult.uploader, - description: ytdlpResult.description, - tags: ytdlpResult.tags, - }, - }; - - } catch (error) { - console.error('Video audio extraction error:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - new Notice(`Failed to extract audio for transcription: ${errorMessage}`); - return null; - } -} - - - diff --git a/src/transcriptions/whisper-transcriber.ts b/src/transcriptions/whisper-transcriber.ts deleted file mode 100644 index 1836133..0000000 --- a/src/transcriptions/whisper-transcriber.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Notice, requestUrl } from 'obsidian'; -import * as fs from 'fs'; -import { Buffer } from 'buffer'; - -/** - * Interface for transcription results with text and optional timestamps - */ -export interface TranscriptionResult { - text: string; - chunks?: TranscriptionChunk[]; - audioFilePath: string; -} - -/** - * Interface for individual transcription chunks with timestamps - */ -export interface TranscriptionChunk { - text: string; - timestamp: [number, number | null]; -} - -/** - * Configuration for Azure OpenAI Whisper API - */ -export interface AzureWhisperConfig { - endpoint: string; - apiKey: string; - deploymentName: string; -} - -/** - * Interface for Azure OpenAI Whisper API response - */ -interface WhisperApiResponse { - text: string; - segments?: Array<{ text: string; start: number; end: number }>; -} - -/** - * Validate that the Azure OpenAI configuration is complete. - */ -function validateConfig(config: AzureWhisperConfig): void { - if (!config.endpoint) { - throw new Error('Azure OpenAI endpoint is not configured. Please set it in settings.'); - } - if (!config.apiKey) { - throw new Error('Azure OpenAI API key is not configured. Please set it in settings.'); - } - if (!config.deploymentName) { - throw new Error('Azure OpenAI Whisper deployment name is not configured. Please set it in settings.'); - } -} - -/** - * Build the Azure OpenAI Whisper API URL. - */ -function buildApiUrl(config: AzureWhisperConfig): string { - // Remove trailing slash from endpoint if present - const endpoint = config.endpoint.replace(/\/$/, ''); - // Azure OpenAI Whisper API version - const apiVersion = '2024-06-01'; - return `${endpoint}/openai/deployments/${config.deploymentName}/audio/transcriptions?api-version=${apiVersion}`; -} - -/** - * Build multipart form data for the API request. - */ -function buildMultipartFormData( - boundary: string, - audioBuffer: Buffer, - fileName: string, - includeTimestamps: boolean, - language?: string -): ArrayBuffer { - const parts: (string | Buffer)[] = []; - - // Add file field - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`); - parts.push(`Content-Type: application/octet-stream\r\n\r\n`); - parts.push(audioBuffer); - parts.push('\r\n'); - - // Add response_format field for timestamps - const responseFormat = includeTimestamps ? 'verbose_json' : 'json'; - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="response_format"\r\n\r\n`); - parts.push(`${responseFormat}\r\n`); - - // Add language field if specified - if (language) { - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="language"\r\n\r\n`); - parts.push(`${language}\r\n`); - } - - // Add timestamp_granularities for verbose_json - if (includeTimestamps) { - parts.push(`--${boundary}\r\n`); - parts.push(`Content-Disposition: form-data; name="timestamp_granularities[]"\r\n\r\n`); - parts.push(`segment\r\n`); - } - - // End boundary - parts.push(`--${boundary}--\r\n`); - - // Combine all parts into a single ArrayBuffer - const encoder = new TextEncoder(); - const buffers: Uint8Array[] = parts.map(part => { - if (typeof part === 'string') { - return encoder.encode(part); - } - return new Uint8Array(part.buffer, part.byteOffset, part.byteLength); - }); - - const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - for (const buf of buffers) { - combined.set(new Uint8Array(buf), offset); - offset += buf.byteLength; - } - - return combined.buffer; -} - -/** - * Parse the Azure OpenAI Whisper API response into TranscriptionResult. - */ -function parseTranscriptionResponse( - response: { text: string; segments?: Array<{ text: string; start: number; end: number }> }, - audioFilePath: string, - includeTimestamps: boolean -): TranscriptionResult { - const result: TranscriptionResult = { - text: response.text, - audioFilePath, - }; - - if (includeTimestamps && response.segments) { - result.chunks = response.segments.map(segment => ({ - text: segment.text.trim(), - timestamp: [segment.start, segment.end] as [number, number], - })); - } - - return result; -} - -/** - * Transcribe an audio file to text using Azure OpenAI Whisper API. - * - * @param audioFilePath - Path to the audio file (mp3, wav, m4a, webm, etc.) - * @param config - Azure OpenAI Whisper configuration - * @param options - Transcription options - * @returns TranscriptionResult with text and optional timestamps - */ -export async function transcribe( - audioFilePath: string, - config: AzureWhisperConfig, - options: { - includeTimestamps?: boolean; - language?: string; - } = {} -): Promise { - validateConfig(config); - - // Verify audio file exists - if (!fs.existsSync(audioFilePath)) { - throw new Error(`Audio file not found: ${audioFilePath}`); - } - - try { - // eslint-disable-next-line obsidianmd/ui/sentence-case -- proper nouns - new Notice('Transcribing audio with Azure OpenAI Whisper'); - - // Read the audio file - const audioBuffer = fs.readFileSync(audioFilePath); - const fileName = audioFilePath.split(/[/\\]/).pop() || 'audio.mp3'; - - // Build the API URL - const apiUrl = buildApiUrl(config); - - // Create multipart form data manually - const boundary = '----FormBoundary' + Math.random().toString(36).substring(2); - const formData = buildMultipartFormData( - boundary, - audioBuffer, - fileName, - options.includeTimestamps || false, - options.language - ); - - // Make the API request using Obsidian's requestUrl - const response = await requestUrl({ - url: apiUrl, - method: 'POST', - headers: { - 'api-key': config.apiKey, - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - }, - body: formData, - }); - - if (response.status !== 200) { - throw new Error(`Azure OpenAI API error: ${response.status} - ${response.text}`); - } - - const result = response.json as WhisperApiResponse; - new Notice('Transcription complete!'); - - // Parse and format the response - return parseTranscriptionResponse(result, audioFilePath, options.includeTimestamps || false); - } catch (error) { - console.error('Transcription error:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - new Notice(`Transcription failed: ${errorMessage}`); - throw error; - } -} \ No newline at end of file diff --git a/src/workflows/index.ts b/src/workflows/index.ts deleted file mode 100644 index 2d6f4cf..0000000 --- a/src/workflows/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { WorkflowSuggesterModal } from './workflow-suggester'; -export { executeWorkflow, WorkflowResultModal } from './workflow-executor'; - From ce7974c5c9eb6132796d3a9cc00281572b94f11d Mon Sep 17 00:00:00 2001 From: dalinicus Date: Thu, 8 Jan 2026 19:16:14 -0600 Subject: [PATCH 16/22] wire up input and output handlers. --- AGENTS.md | 77 +++- src/handlers/index.ts | 17 + .../input/clipboard-url-input-handler.ts | 26 ++ src/handlers/input/index.ts | 8 + .../input/selection-url-input-handler.ts | 43 +++ src/handlers/input/types.ts | 45 +++ .../input/vault-file-input-handler.ts | 82 ++++ .../output/at-cursor-output-handler.ts | 30 ++ src/handlers/output/index.ts | 8 + .../output/new-note-output-handler.ts | 62 ++++ src/handlers/output/popup-output-handler.ts | 59 +++ src/handlers/output/types.ts | 34 ++ src/main.ts | 30 +- src/processing/index.ts | 1 + src/processing/video-processor.ts | 48 ++- src/processing/workflow-executor.ts | 350 +++++++----------- src/providers/index.ts | 1 - src/providers/provider-factory.ts | 16 - src/settings/providers.ts | 6 - src/settings/types.ts | 10 +- src/settings/workflows.ts | 30 +- src/transcriptions/transcription-note.ts | 201 ---------- src/transcriptions/transcription-workflow.ts | 84 ----- 23 files changed, 686 insertions(+), 582 deletions(-) create mode 100644 src/handlers/index.ts create mode 100644 src/handlers/input/clipboard-url-input-handler.ts create mode 100644 src/handlers/input/index.ts create mode 100644 src/handlers/input/selection-url-input-handler.ts create mode 100644 src/handlers/input/types.ts create mode 100644 src/handlers/input/vault-file-input-handler.ts create mode 100644 src/handlers/output/at-cursor-output-handler.ts create mode 100644 src/handlers/output/index.ts create mode 100644 src/handlers/output/new-note-output-handler.ts create mode 100644 src/handlers/output/popup-output-handler.ts create mode 100644 src/handlers/output/types.ts delete mode 100644 src/transcriptions/transcription-note.ts delete mode 100644 src/transcriptions/transcription-workflow.ts diff --git a/AGENTS.md b/AGENTS.md index f9074d9..fc737eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ ## Agent instructions -- Do not run build or lint commands unless explicitly instructed by the user. +- Run builds (`npm run build`) to verify your changes compile correctly. +- Only run linting (`eslint ./src/`) when preparing for a pull request. ## Project overview @@ -239,6 +240,80 @@ this.registerDomEvent(window, "resize", () => { /* ... */ }); this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); ``` +## Handler architecture + +The plugin uses a handler-based architecture to separate concerns for workflow execution: + +### Input handlers (`src/handlers/input/`) + +Input handlers acquire media for transcription workflows. Each handler implements the `InputHandler` interface: + +```ts +interface InputHandler { + getInput(context: InputContext): Promise; +} +``` + +**Available input handlers:** +- `VaultFileInputHandler` - Prompts user to select an audio file from the vault +- `ClipboardUrlInputHandler` - Extracts audio from a video URL in the clipboard (uses yt-dlp) +- `SelectionUrlInputHandler` - Extracts audio from a video URL in the current text selection + +**InputResult structure:** +```ts +interface InputResult { + audioFilePath: string; // Absolute path to the audio file + sourceUrl?: string; // Source URL if extracted from video + metadata?: VideoMetadata; // Title, uploader, description, tags +} +``` + +### Output handlers (`src/handlers/output/`) + +Output handlers present workflow results to the user. Each handler implements the `OutputHandler` interface: + +```ts +interface OutputHandler { + handleOutput(responseText: string, context: OutputContext): Promise; +} +``` + +**Available output handlers:** +- `PopupOutputHandler` - Displays result in a modal popup +- `NewNoteOutputHandler` - Creates a new note with the result +- `AtCursorOutputHandler` - Inserts result at the current cursor position + +### Naming conventions + +- Input handler classes end with `InputHandler` (e.g., `VaultFileInputHandler`) +- Output handler classes end with `OutputHandler` (e.g., `PopupOutputHandler`) +- Handler files use kebab-case matching the class name (e.g., `vault-file-input-handler.ts`) + +### Extending the handler system + +**To add a new input handler:** +1. Create a new file in `src/handlers/input/` (e.g., `my-custom-input-handler.ts`) +2. Implement the `InputHandler` interface +3. Export from `src/handlers/input/index.ts` +4. Add the new source type to `TranscriptionSourceType` in `src/settings/types.ts` +5. Update `createInputHandler()` in `src/processing/workflow-executor.ts` + +**To add a new output handler:** +1. Create a new file in `src/handlers/output/` (e.g., `my-custom-output-handler.ts`) +2. Implement the `OutputHandler` interface +3. Export from `src/handlers/output/index.ts` +4. Add the new output type to `WorkflowOutputType` in `src/settings/types.ts` +5. Update `createOutputHandler()` in `src/processing/workflow-executor.ts` + +### Workflow execution flow + +``` +┌─────────────────┐ ┌───────────────┐ ┌────────────────┐ ┌────────────────┐ +│ User triggers │ ──▶ │ Input Handler │ ──▶ │ AI Provider │ ──▶ │ Output Handler │ +│ workflow │ │ (get media) │ │ (transcribe) │ │ (show result) │ +└─────────────────┘ └───────────────┘ └────────────────┘ └────────────────┘ +``` + ## Troubleshooting - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `/.obsidian/plugins//`. diff --git a/src/handlers/index.ts b/src/handlers/index.ts new file mode 100644 index 0000000..45839a4 --- /dev/null +++ b/src/handlers/index.ts @@ -0,0 +1,17 @@ +// Input handlers +export type { InputHandler, InputContext, InputResult } from './input'; +export { + VaultFileInputHandler, + ClipboardUrlInputHandler, + SelectionUrlInputHandler +} from './input'; + +// Output handlers +export type { OutputHandler, OutputContext } from './output'; +export { + AtCursorOutputHandler, + PopupOutputHandler, + NewNoteOutputHandler, + WorkflowResultModal +} from './output'; + diff --git a/src/handlers/input/clipboard-url-input-handler.ts b/src/handlers/input/clipboard-url-input-handler.ts new file mode 100644 index 0000000..3acdcae --- /dev/null +++ b/src/handlers/input/clipboard-url-input-handler.ts @@ -0,0 +1,26 @@ +import { Notice } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import { extractAudioFromClipboard } from '../../processing'; + +/** + * Input handler that extracts audio from a video URL in the clipboard. + * Uses yt-dlp to download and convert the video to audio. + */ +export class ClipboardUrlInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + new Notice('Checking clipboard for video URL...'); + + const result = await extractAudioFromClipboard(context.settings); + + if (!result) { + return null; + } + + return { + audioFilePath: result.audioFilePath, + sourceUrl: result.sourceUrl, + metadata: result.metadata + }; + } +} + diff --git a/src/handlers/input/index.ts b/src/handlers/input/index.ts new file mode 100644 index 0000000..9afca07 --- /dev/null +++ b/src/handlers/input/index.ts @@ -0,0 +1,8 @@ +// Input handler types +export type { InputHandler, InputContext, InputResult } from './types'; + +// Input handler implementations +export { VaultFileInputHandler } from './vault-file-input-handler'; +export { ClipboardUrlInputHandler } from './clipboard-url-input-handler'; +export { SelectionUrlInputHandler } from './selection-url-input-handler'; + diff --git a/src/handlers/input/selection-url-input-handler.ts b/src/handlers/input/selection-url-input-handler.ts new file mode 100644 index 0000000..4cea098 --- /dev/null +++ b/src/handlers/input/selection-url-input-handler.ts @@ -0,0 +1,43 @@ +import { MarkdownView, Notice } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import { extractAudioFromUrl } from '../../processing'; + +/** + * Input handler that extracts audio from a video URL in the current text selection. + * Uses yt-dlp to download and convert the video to audio. + */ +export class SelectionUrlInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + new Notice('No active editor. Please open a note and select a URL.'); + return null; + } + + const editor = activeView.editor; + const selection = editor.getSelection(); + + if (!selection || !selection.trim()) { + new Notice('No text selected. Please select a video URL.'); + return null; + } + + const url = selection.trim(); + + new Notice('Processing URL from selection...'); + + const result = await extractAudioFromUrl(url, context.settings); + + if (!result) { + return null; + } + + return { + audioFilePath: result.audioFilePath, + sourceUrl: result.sourceUrl, + metadata: result.metadata + }; + } +} + diff --git a/src/handlers/input/types.ts b/src/handlers/input/types.ts new file mode 100644 index 0000000..6e0d6e7 --- /dev/null +++ b/src/handlers/input/types.ts @@ -0,0 +1,45 @@ +import { App } from 'obsidian'; +import { AIToolboxSettings, WorkflowConfig } from '../../settings'; +import { VideoMetadata } from '../../processing'; + +/** + * Result from acquiring input for transcription. + * Contains the audio file path and optional metadata. + */ +export interface InputResult { + /** Absolute path to the audio file */ + audioFilePath: string; + /** Source URL if the audio was extracted from a video URL */ + sourceUrl?: string; + /** Metadata from the video/audio source */ + metadata?: VideoMetadata; +} + +/** + * Context provided to input handlers for acquiring transcription input. + */ +export interface InputContext { + /** The Obsidian App instance */ + app: App; + /** Plugin settings */ + settings: AIToolboxSettings; + /** The workflow configuration being executed */ + workflow: WorkflowConfig; +} + +/** + * Common interface for input handlers. + * + * Each input handler handles a specific way of acquiring media input + * for transcription (e.g., file selection, clipboard URL, selection URL). + */ +export interface InputHandler { + /** + * Acquire input for transcription. + * + * @param context - Context information about the workflow execution + * @returns Promise that resolves with the input result, or null if cancelled/failed + */ + getInput(context: InputContext): Promise; +} + diff --git a/src/handlers/input/vault-file-input-handler.ts b/src/handlers/input/vault-file-input-handler.ts new file mode 100644 index 0000000..a90002d --- /dev/null +++ b/src/handlers/input/vault-file-input-handler.ts @@ -0,0 +1,82 @@ +import { TFile, FuzzySuggestModal } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import * as path from 'path'; + +/** + * Supported audio file extensions for transcription. + */ +const SUPPORTED_AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'webm', 'ogg', 'flac', 'aac']; + +/** + * Modal for selecting an audio file from the vault. + */ +class AudioFileSelectorModal extends FuzzySuggestModal { + private audioFiles: TFile[]; + private onSelect: (file: TFile | null) => void; + private wasSelected = false; + + constructor( + app: import('obsidian').App, + onSelect: (file: TFile | null) => void + ) { + super(app); + this.onSelect = onSelect; + + this.audioFiles = app.vault.getFiles().filter(file => { + const ext = file.extension.toLowerCase(); + return SUPPORTED_AUDIO_EXTENSIONS.includes(ext); + }); + + this.setPlaceholder('Select an audio file to transcribe...'); + } + + getItems(): TFile[] { + return this.audioFiles; + } + + getItemText(file: TFile): string { + return file.path; + } + + onChooseItem(file: TFile): void { + this.wasSelected = true; + this.onSelect(file); + } + + onClose(): void { + if (!this.wasSelected) { + this.onSelect(null); + } + } +} + +/** + * Input handler that prompts the user to select an audio file from the vault. + */ +export class VaultFileInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + return new Promise((resolve) => { + const modal = new AudioFileSelectorModal(context.app, (file) => { + if (!file) { + resolve(null); + return; + } + + // Get the absolute path to the audio file + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const adapter = context.app.vault.adapter as any; + const audioFilePath = path.join(adapter.basePath, file.path); + + resolve({ + audioFilePath, + metadata: { + title: file.basename, + } + }); + }); + + modal.open(); + }); + } +} + diff --git a/src/handlers/output/at-cursor-output-handler.ts b/src/handlers/output/at-cursor-output-handler.ts new file mode 100644 index 0000000..fbb03fb --- /dev/null +++ b/src/handlers/output/at-cursor-output-handler.ts @@ -0,0 +1,30 @@ +import { MarkdownView, Notice } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; + +/** + * Output handler that inserts the AI response at the current cursor position + * in the active Obsidian editor. + */ +export class AtCursorOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) { + new Notice('No active editor. Please open a note first.'); + return; + } + + const editor = activeView.editor; + const cursor = editor.getCursor(); + editor.replaceRange(responseText, cursor); + + // Move cursor to end of inserted text + const lines = responseText.split('\n'); + const lastLine = lines[lines.length - 1] ?? ''; + const newLine = cursor.line + lines.length - 1; + const newCh = lines.length === 1 ? cursor.ch + lastLine.length : lastLine.length; + editor.setCursor({ line: newLine, ch: newCh }); + + new Notice('Response inserted at cursor'); + } +} + diff --git a/src/handlers/output/index.ts b/src/handlers/output/index.ts new file mode 100644 index 0000000..ab98a0e --- /dev/null +++ b/src/handlers/output/index.ts @@ -0,0 +1,8 @@ +// Output handler types +export type { OutputHandler, OutputContext } from './types'; + +// Output handler implementations +export { AtCursorOutputHandler } from './at-cursor-output-handler'; +export { PopupOutputHandler, WorkflowResultModal } from './popup-output-handler'; +export { NewNoteOutputHandler } from './new-note-output-handler'; + diff --git a/src/handlers/output/new-note-output-handler.ts b/src/handlers/output/new-note-output-handler.ts new file mode 100644 index 0000000..fd63da4 --- /dev/null +++ b/src/handlers/output/new-note-output-handler.ts @@ -0,0 +1,62 @@ +import { Notice, TFile } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; +import { generateFilenameTimestamp } from '../../utils/date-utils'; + +/** + * Output handler that creates a new note containing the AI response. + * The note contains only the raw response text with no additional formatting. + */ +export class NewNoteOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const { app, workflow } = context; + + const timestamp = generateFilenameTimestamp(); + const noteTitle = context.noteTitle || `${workflow.name} - ${timestamp}`; + + // Determine folder path: use workflow-specific folder if set, otherwise fall back to Obsidian's default + let folderPath: string | undefined; + if (workflow.outputFolder && workflow.outputFolder.trim() !== '') { + folderPath = workflow.outputFolder.trim().replace(/\/$/, ''); + } else { + // Get the default new note location from Obsidian's vault config + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vault = app.vault as any; + const newFileFolderPath = vault.getConfig?.('newFileFolderPath') as string | undefined; + if (newFileFolderPath && newFileFolderPath.trim() !== '') { + folderPath = newFileFolderPath.replace(/\/$/, ''); + } + } + + // Build the file path + let filePath: string; + if (folderPath) { + // Ensure folder exists + const folder = app.vault.getAbstractFileByPath(folderPath); + if (!folder) { + await app.vault.createFolder(folderPath); + } + filePath = `${folderPath}/${noteTitle}.md`; + } else { + filePath = `${noteTitle}.md`; + } + + // Handle filename conflicts by appending a number + let finalPath = filePath; + let counter = 1; + while (app.vault.getAbstractFileByPath(finalPath)) { + const basePath = filePath.replace('.md', ''); + finalPath = `${basePath} (${counter}).md`; + counter++; + } + + // Create the note with raw response text + const file = await app.vault.create(finalPath, responseText); + + // Open the newly created note + const leaf = app.workspace.getLeaf(false); + await leaf.openFile(file as TFile); + + new Notice(`Created note: ${file.name}`); + } +} + diff --git a/src/handlers/output/popup-output-handler.ts b/src/handlers/output/popup-output-handler.ts new file mode 100644 index 0000000..79e2bdd --- /dev/null +++ b/src/handlers/output/popup-output-handler.ts @@ -0,0 +1,59 @@ +import { App, Modal, Notice } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; + +/** + * Modal to display the AI response from a workflow execution. + */ +export class WorkflowResultModal extends Modal { + private workflowName: string; + private response: string; + + constructor(app: App, workflowName: string, response: string) { + super(app); + this.workflowName = workflowName; + this.response = response; + } + + onOpen(): void { + const { contentEl } = this; + + contentEl.createEl('h2', { text: this.workflowName }); + + const responseContainer = contentEl.createDiv('workflow-response-container'); + responseContainer.createEl('pre', { + text: this.response, + cls: 'workflow-response-content' + }); + + // Add copy button + const buttonContainer = contentEl.createDiv('workflow-response-buttons'); + const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); + copyButton.addEventListener('click', async () => { + await navigator.clipboard.writeText(this.response); + new Notice('Response copied to clipboard'); + }); + + const closeButton = buttonContainer.createEl('button', { text: 'Close' }); + closeButton.addEventListener('click', () => this.close()); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} + +/** + * Output handler that displays the AI response in a modal popup. + */ +export class PopupOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const modal = new WorkflowResultModal( + context.app, + context.workflow.name, + responseText + ); + modal.open(); + } +} + diff --git a/src/handlers/output/types.ts b/src/handlers/output/types.ts new file mode 100644 index 0000000..ff94d4c --- /dev/null +++ b/src/handlers/output/types.ts @@ -0,0 +1,34 @@ +import { App } from 'obsidian'; +import { WorkflowConfig } from '../../settings'; + +/** + * Context provided to output handlers for handling workflow results. + */ +export interface OutputContext { + /** The Obsidian App instance */ + app: App; + /** The workflow configuration that was executed */ + workflow: WorkflowConfig; + /** The prompt text that was sent to the AI (for chat workflows) */ + promptText?: string; + /** Custom note title (for new-note output handler) */ + noteTitle?: string; +} + +/** + * Common interface for output handlers. + * + * Each output handler handles a specific way of presenting or storing + * the AI model's response (e.g., popup modal, new note, cursor insertion). + */ +export interface OutputHandler { + /** + * Handle the AI model's response output. + * + * @param responseText - The text response from the AI model + * @param context - Context information about the workflow execution + * @returns Promise that resolves when output handling is complete + */ + handleOutput(responseText: string, context: OutputContext): Promise; +} + diff --git a/src/main.ts b/src/main.ts index 135cb99..7b50829 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,5 @@ import { Notice, Plugin } from 'obsidian'; import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; -import { createTranscriptionProvider } from "./providers"; -import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; import { WorkflowSuggesterModal } from "./components/workflow-suggester"; import { executeWorkflow } from "./processing/workflow-executor"; @@ -11,15 +9,10 @@ export default class AIToolboxPlugin extends Plugin { async onload() { await this.loadSettings(); - // This creates an icon in the left ribbon for video transcription. - this.addRibbonIcon('captions', 'Transcribe video from clipboard', () => { - void this.transcribeFromClipboard(); - }); - // Add command to execute custom workflows this.addCommand({ id: 'execute-workflow', - name: 'Run AI Workflow', + name: 'Run Workflow', callback: () => this.showWorkflowSuggester() }); @@ -44,27 +37,6 @@ export default class AIToolboxPlugin extends Plugin { modal.open(); } - /** - * Complete workflow: Extract audio from clipboard URL, transcribe it, and create a note. - */ - async transcribeFromClipboard(): Promise { - // Create the transcription provider from settings - const provider = createTranscriptionProvider(this.settings); - if (!provider) { - new Notice('No transcription provider configured. Please configure a provider in settings.'); - return; - } - - // Build workflow options from settings - const options = { - includeTimestamps: this.settings.includeTimestamps, - language: this.settings.transcriptionLanguage || undefined, - outputFolder: this.settings.outputFolder, - }; - - await transcribeFromClipboard(this.app, provider, this.settings, options); - } - async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } diff --git a/src/processing/index.ts b/src/processing/index.ts index b575225..aa1d7d7 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -23,6 +23,7 @@ export type { export { getOutputDirectory, runYtDlp, + extractAudioFromUrl, extractAudioFromClipboard, } from './video-processor'; diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 6d8a77e..7e3e6f7 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -211,29 +211,28 @@ function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorC } /** - * Downloads audio from a video URL in the clipboard for transcription. + * Downloads audio from a video URL for transcription. * Supports TikTok, YouTube, and other platforms supported by yt-dlp. * Uses yt-dlp directly via child_process for audio extraction. * Requires yt-dlp and ffmpeg to be installed and available in PATH. */ -export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { +export async function extractAudioFromUrl(url: string, settings: AIToolboxSettings): Promise { try { - const clipboardText = await navigator.clipboard.readText(); - - if (!clipboardText) { - new Notice('Clipboard is empty'); + if (!url || !url.trim()) { + new Notice('URL is empty'); return null; } - if (!videoPlatformRegistry.isValidVideoUrl(clipboardText)) { - new Notice('Clipboard does not contain a valid video URL'); + const trimmedUrl = url.trim(); + + if (!videoPlatformRegistry.isValidVideoUrl(trimmedUrl)) { + new Notice('The provided text is not a valid video URL'); return null; } - const url = clipboardText.trim(); new Notice('Preparing video for transcription...'); - const handler = videoPlatformRegistry.findHandlerForUrl(url); + const handler = videoPlatformRegistry.findHandlerForUrl(trimmedUrl); const filenameTemplate = handler ? handler.getYtDlpArgs().outputConfig.filenameTemplate : '%(title)s_%(id)s'; @@ -242,13 +241,13 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr const outputDir = getOutputDirectory(config); const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); - const ytdlpResult = await runYtDlp(url, outputTemplate, config); + const ytdlpResult = await runYtDlp(trimmedUrl, outputTemplate, config); new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); return { audioFilePath: ytdlpResult.audioFilePath, - sourceUrl: url, + sourceUrl: trimmedUrl, metadata: { title: ytdlpResult.title, uploader: ytdlpResult.uploader, @@ -264,3 +263,28 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr return null; } } + +/** + * Downloads audio from a video URL in the clipboard for transcription. + * Supports TikTok, YouTube, and other platforms supported by yt-dlp. + * Uses yt-dlp directly via child_process for audio extraction. + * Requires yt-dlp and ffmpeg to be installed and available in PATH. + */ +export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { + try { + const clipboardText = await navigator.clipboard.readText(); + + if (!clipboardText) { + new Notice('Clipboard is empty'); + return null; + } + + return extractAudioFromUrl(clipboardText, settings); + + } catch (error) { + console.error('Clipboard read error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to read clipboard: ${errorMessage}`); + return null; + } +} diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index 054ed74..24b7ee7 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,150 +1,110 @@ -import { App, MarkdownView, Modal, Notice, TFile, FuzzySuggestModal } from 'obsidian'; -import { WorkflowConfig, AIToolboxSettings } from '../settings'; -import { createWorkflowProvider, ChatMessage, TranscriptionOptions } from '../providers'; +import { App, Notice, TFile } from 'obsidian'; +import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; +import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; +import { videoPlatformRegistry } from './video-platforms'; import { generateFilenameTimestamp } from '../utils/date-utils'; -import { createTranscriptionNote } from '../transcriptions/transcription-note'; -import * as path from 'path'; +import { + OutputHandler, + OutputContext, + NewNoteOutputHandler, + AtCursorOutputHandler, + PopupOutputHandler, + InputHandler, + InputContext, + InputResult, + VaultFileInputHandler, + ClipboardUrlInputHandler, + SelectionUrlInputHandler +} from '../handlers'; /** - * Modal to display the AI response from a workflow execution + * Create an output handler based on the workflow's output type. */ -export class WorkflowResultModal extends Modal { - private workflowName: string; - private response: string; - - constructor(app: App, workflowName: string, response: string) { - super(app); - this.workflowName = workflowName; - this.response = response; +function createOutputHandler(outputType: string): OutputHandler { + switch (outputType) { + case 'new-note': + return new NewNoteOutputHandler(); + case 'at-cursor': + return new AtCursorOutputHandler(); + case 'popup': + default: + return new PopupOutputHandler(); } +} - onOpen(): void { - const { contentEl } = this; - - contentEl.createEl('h2', { text: this.workflowName }); - - const responseContainer = contentEl.createDiv('workflow-response-container'); - responseContainer.createEl('pre', { - text: this.response, - cls: 'workflow-response-content' - }); - - // Add copy button - const buttonContainer = contentEl.createDiv('workflow-response-buttons'); - const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); - copyButton.addEventListener('click', async () => { - await navigator.clipboard.writeText(this.response); - new Notice('Response copied to clipboard'); - }); - - const closeButton = buttonContainer.createEl('button', { text: 'Close' }); - closeButton.addEventListener('click', () => this.close()); +/** + * Create an input handler based on the transcription source type. + */ +function createInputHandler(sourceType: TranscriptionSourceType): InputHandler { + switch (sourceType) { + case 'select-file-from-vault': + return new VaultFileInputHandler(); + case 'url-from-clipboard': + return new ClipboardUrlInputHandler(); + case 'url-from-selection': + return new SelectionUrlInputHandler(); + default: + return new VaultFileInputHandler(); } +} + +/** + * Format a timestamp in seconds to MM:SS or HH:MM:SS format. + */ +function formatTimestamp(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); - onClose(): void { - const { contentEl } = this; - contentEl.empty(); + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } + return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } /** - * Insert text at the current cursor position in the active editor. + * Format a transcription result, optionally including timestamps. + * When timestamps are included and chunks are available, formats each segment + * with its start time prefix. */ -function insertAtCursor(app: App, text: string): void { - const activeView = app.workspace.getActiveViewOfType(MarkdownView); - if (!activeView) { - new Notice('No active editor. Please open a note first.'); - return; +function formatTranscriptionResult(result: TranscriptionResult, includeTimestamps: boolean): string { + if (!includeTimestamps || !result.chunks || result.chunks.length === 0) { + return result.text; } - const editor = activeView.editor; - const cursor = editor.getCursor(); - editor.replaceRange(text, cursor); - - // Move cursor to end of inserted text - const lines = text.split('\n'); - const lastLine = lines[lines.length - 1] ?? ''; - const newLine = cursor.line + lines.length - 1; - const newCh = lines.length === 1 ? cursor.ch + lastLine.length : lastLine.length; - editor.setCursor({ line: newLine, ch: newCh }); - - new Notice('Response inserted at cursor'); + return result.chunks + .map(chunk => `[${formatTimestamp(chunk.timestamp[0])}] ${chunk.text}`) + .join('\n'); } /** - * Create a new note with the workflow result. - * Honors Obsidian's default new note location setting. + * Generate a note title for transcription output based on input source. + * - TikTok: "TikTok by - " + * - YouTube: "