From a38c68c4dd79b331b52438dbd22221e90c646132 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Fri, 9 Jan 2026 17:28:07 -0600 Subject: [PATCH 1/7] stash --- src/processing/audio-processor.ts | 46 ++++++++++++++++------ src/processing/index.ts | 2 + src/processing/workflow-chaining.ts | 51 +++++++++++++++++++++--- src/processing/workflow-executor.ts | 45 +++++---------------- src/providers/base-provider.ts | 39 ++++++++++++------ src/providers/types.ts | 22 +++++++---- src/settings/additional-settings.ts | 33 ++++++---------- src/settings/index.ts | 32 +++++++++++++-- src/settings/types.ts | 12 +++++- src/settings/workflows.ts | 61 +++++++++++++++++++++-------- src/tokens/types.ts | 54 +++++++++++++++++++------ styles.css | 25 +++++++----- 12 files changed, 283 insertions(+), 139 deletions(-) diff --git a/src/processing/audio-processor.ts b/src/processing/audio-processor.ts index 15d7d13..7bc0dd7 100644 --- a/src/processing/audio-processor.ts +++ b/src/processing/audio-processor.ts @@ -1,12 +1,32 @@ import { Buffer } from 'buffer'; import * as fs from 'fs'; +import { TimestampGranularity } from '../settings'; /** - * Interface for transcription API response + * Word-level timestamp data from transcription API + */ +export interface TranscriptionWord { + word: string; + start: number; + end: number; +} + +/** + * Segment-level timestamp data from transcription API + */ +export interface TranscriptionSegment { + text: string; + start: number; + end: number; +} + +/** + * Interface for transcription API response (verbose_json format) */ export interface TranscriptionApiResponse { text: string; - segments?: Array<{ text: string; start: number; end: number }>; + segments?: TranscriptionSegment[]; + words?: TranscriptionWord[]; } /** @@ -41,7 +61,7 @@ export interface MultipartFormDataOptions { boundary: string; audioBuffer: Buffer; fileName: string; - includeTimestamps: boolean; + timestampGranularity: TimestampGranularity; language?: string; additionalFields?: FormField[]; } @@ -51,7 +71,7 @@ export interface MultipartFormDataOptions { */ export interface PrepareAudioFormDataOptions { audioFilePath: string; - includeTimestamps: boolean; + timestampGranularity: TimestampGranularity; language?: string; additionalFields?: FormField[]; } @@ -93,12 +113,14 @@ function combinePartsToArrayBuffer(parts: (string | Buffer)[]): ArrayBuffer { * 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. + * - Uses 'json' format when timestamps are disabled (reduces token usage) + * - Uses 'verbose_json' format when timestamps are enabled (segment or word) * * @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 { boundary, audioBuffer, fileName, timestampGranularity, language, additionalFields = [] } = options; const parts: (string | Buffer)[] = []; // File field @@ -115,8 +137,8 @@ export function buildMultipartFormData(options: MultipartFormDataOptions): Array parts.push(`${field.value}\r\n`); } - // Response format - const responseFormat = includeTimestamps ? 'verbose_json' : 'json'; + // Use 'json' when timestamps disabled, 'verbose_json' when enabled + const responseFormat = timestampGranularity === 'disabled' ? 'json' : 'verbose_json'; parts.push(`--${boundary}\r\n`); parts.push(`Content-Disposition: form-data; name="response_format"\r\n\r\n`); parts.push(`${responseFormat}\r\n`); @@ -128,11 +150,11 @@ export function buildMultipartFormData(options: MultipartFormDataOptions): Array parts.push(`${language}\r\n`); } - // Timestamp granularities (for verbose_json) - if (includeTimestamps) { + // Only add timestamp_granularities[] when timestamps are enabled + if (timestampGranularity !== 'disabled') { 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(`${timestampGranularity}\r\n`); } parts.push(`--${boundary}--\r\n`); @@ -178,7 +200,7 @@ export function extractFileName(filePath: string, defaultName = 'audio.mp3'): st * @throws Error if the audio file cannot be read */ export function prepareAudioFormData(options: PrepareAudioFormDataOptions): PreparedAudioFormData { - const { audioFilePath, includeTimestamps, language, additionalFields } = options; + const { audioFilePath, timestampGranularity, language, additionalFields } = options; validateAudioFile(audioFilePath); @@ -190,7 +212,7 @@ export function prepareAudioFormData(options: PrepareAudioFormDataOptions): Prep boundary, audioBuffer, fileName, - includeTimestamps, + timestampGranularity, language, additionalFields, }); diff --git a/src/processing/index.ts b/src/processing/index.ts index aa1d7d7..b488143 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -10,6 +10,8 @@ export { export type { TranscriptionApiResponse, + TranscriptionSegment, + TranscriptionWord, TranscriptionChunk, TranscriptionResult, FormField, diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts index f972648..c660f9b 100644 --- a/src/processing/workflow-chaining.ts +++ b/src/processing/workflow-chaining.ts @@ -1,5 +1,6 @@ import { App, MarkdownView } from 'obsidian'; -import { WorkflowConfig, AIToolboxSettings } from '../settings'; +import { WorkflowConfig, AIToolboxSettings, TimestampGranularity } from '../settings'; +import { TranscriptionResult, TranscriptionChunk } from '../providers'; /** * Result from executing a workflow, used for chaining @@ -121,26 +122,64 @@ export function createChatWorkflowTokens( } /** - * Create transcription workflow tokens from execution data + * Format a timestamp in seconds to MM:SS or HH:MM:SS format. + */ +export function formatTimestamp(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + 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')}`; +} + +/** + * Format transcription chunks with timestamps. + * Each segment is prefixed with its start time in [MM:SS] format. + */ +export function formatTranscriptionWithTimestamps(chunks: TranscriptionChunk[]): string { + if (!chunks || chunks.length === 0) { + return ''; + } + + return chunks + .map(chunk => `[${formatTimestamp(chunk.timestamp[0])}] ${chunk.text}`) + .join('\n'); +} + +/** + * Create transcription workflow tokens from execution data. + * Generates plain text transcription and optionally timestamped version. + * The transcriptionWithTimestamps token is only included when granularity is not 'disabled'. */ export function createTranscriptionWorkflowTokens( - transcriptionText: string, + transcriptionResult: TranscriptionResult, metadata?: { title?: string; uploader?: string; sourceUrl?: string; description?: string; tags?: string[]; - } + }, + timestampGranularity?: TimestampGranularity ): Record { - return { - transcription: transcriptionText, + const tokens: Record = { + transcription: transcriptionResult.text, title: metadata?.title ?? '', author: metadata?.uploader ?? '', sourceUrl: metadata?.sourceUrl ?? '', description: metadata?.description ?? '', tags: metadata?.tags?.join(', ') ?? '' }; + + // Only include timestamped transcription when timestamps are enabled + if (timestampGranularity !== 'disabled') { + tokens.transcriptionWithTimestamps = formatTranscriptionWithTimestamps(transcriptionResult.chunks); + } + + return tokens; } /** diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index ff37384..7c03290 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -27,7 +27,8 @@ import { getDependencyWorkflowIds, gatherContextValues, replaceContextTokens, - hasContextTokens + hasContextTokens, + formatTranscriptionWithTimestamps } from './workflow-chaining'; /** @@ -61,35 +62,6 @@ function createInputHandler(sourceType: TranscriptionSourceType): InputHandler { } } -/** - * 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); - - 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')}`; -} - -/** - * Format a transcription result, optionally including timestamps. - * When timestamps are included and chunks are available, formats each segment - * with its start time prefix. - */ -function formatTranscriptionResult(result: TranscriptionResult, includeTimestamps: boolean): string { - if (!includeTimestamps || !result.chunks || result.chunks.length === 0) { - return result.text; - } - - return result.chunks - .map(chunk => `[${formatTimestamp(chunk.timestamp[0])}] ${chunk.text}`) - .join('\n'); -} - /** * Generate a note title for transcription output based on input source. * - TikTok: "TikTok by - " @@ -466,12 +438,15 @@ async function executeTranscriptionWorkflow( new Notice(`Transcribing audio...`); const transcriptionOptions: TranscriptionOptions = { - includeTimestamps: workflow.includeTimestamps ?? true, + timestampGranularity: workflow.timestampGranularity ?? 'disabled', language: workflow.language || undefined }; const transcriptionResult = await provider.transcribeAudio(inputResult.audioFilePath, transcriptionOptions); - const formattedText = formatTranscriptionResult(transcriptionResult, workflow.includeTimestamps ?? true); + // Use timestamped version for display output if available, otherwise plain text + const formattedText = transcriptionResult.chunks.length > 0 + ? formatTranscriptionWithTimestamps(transcriptionResult.chunks) + : transcriptionResult.text; const noteTitle = generateTranscriptionNoteTitle(inputResult, workflow.name); const outputType = workflow.outputType || 'new-note'; @@ -526,18 +501,18 @@ async function executeTranscriptionWorkflowInternal( return { ...baseResult, error: 'No input provided or cancelled' }; } + const timestampGranularity = workflow.timestampGranularity ?? 'disabled'; const transcriptionOptions: TranscriptionOptions = { - includeTimestamps: workflow.includeTimestamps ?? true, + timestampGranularity, language: workflow.language || undefined }; const transcriptionResult = await provider.transcribeAudio(inputResult.audioFilePath, transcriptionOptions); - const formattedText = formatTranscriptionResult(transcriptionResult, workflow.includeTimestamps ?? true); return { ...baseResult, success: true, - tokens: createTranscriptionWorkflowTokens(formattedText, inputResult.metadata) + tokens: createTranscriptionWorkflowTokens(transcriptionResult, inputResult.metadata, timestampGranularity) }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/providers/base-provider.ts b/src/providers/base-provider.ts index 0d5acf6..35aa9bf 100644 --- a/src/providers/base-provider.ts +++ b/src/providers/base-provider.ts @@ -1,6 +1,6 @@ import { Notice, requestUrl } from 'obsidian'; -import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult, ChatMessage, ChatOptions, ChatResult, TestAudioData } from './types'; -import { AIProviderType } from '../settings'; +import { ModelProvider, ModelProviderConfig, TranscriptionOptions, TranscriptionResult, ChatMessage, ChatOptions, ChatResult, TestAudioData, TranscriptionWord } from './types'; +import { AIProviderType, TimestampGranularity } from '../settings'; import { prepareAudioFormData, TranscriptionApiResponse, FormField, buildMultipartFormData, generateFormBoundary } from '../processing/audio-processor'; /** @@ -98,9 +98,11 @@ export abstract class BaseProvider implements ModelProvider { try { new Notice(`Transcribing audio with ${this.getProviderDisplayName()}`); + const timestampGranularity = options.timestampGranularity ?? 'segment'; + const { boundary, formData } = prepareAudioFormData({ audioFilePath, - includeTimestamps: options.includeTimestamps || false, + timestampGranularity, language: options.language, additionalFields: this.getAdditionalFormFields(), }); @@ -108,7 +110,7 @@ export abstract class BaseProvider implements ModelProvider { const result = await this.sendTranscriptionRequest(boundary, formData); new Notice('Transcription complete!'); - return this.parseTranscriptionResponse(result, audioFilePath, options.includeTimestamps || false); + return this.parseTranscriptionResponse(result, audioFilePath, timestampGranularity); } catch (error) { console.error('Transcription error:', error); const errorMessage = error instanceof Error ? error.message : String(error); @@ -125,7 +127,7 @@ export abstract class BaseProvider implements ModelProvider { boundary, audioBuffer: testAudio.audioBuffer, fileName: testAudio.fileName, - includeTimestamps: false, + timestampGranularity: 'segment', additionalFields: this.getAdditionalFormFields(), }); @@ -220,21 +222,36 @@ export abstract class BaseProvider implements ModelProvider { return result; } + /** + * Parse transcription API response into a TranscriptionResult. + * - When granularity is 'disabled': returns empty chunks (API uses 'json' format with no timestamps) + * - When granularity is 'segment': extracts segment-level timestamps + * - When granularity is 'word': extracts both segment and word-level timestamps + */ protected parseTranscriptionResponse( response: TranscriptionApiResponse, audioFilePath: string, - includeTimestamps: boolean + granularity: TimestampGranularity ): TranscriptionResult { + // Extract segment-level chunks (empty array when granularity is 'disabled') + const chunks = (response.segments ?? []).map(segment => ({ + text: segment.text.trim(), + timestamp: [segment.start, segment.end] as [number, number], + })); + const result: TranscriptionResult = { text: response.text, + chunks, audioFilePath, }; - if (includeTimestamps && response.segments) { - result.chunks = response.segments.map(segment => ({ - text: segment.text.trim(), - timestamp: [segment.start, segment.end] as [number, number], - })); + // Include word-level timestamps if granularity is 'word' and words are available + if (granularity === 'word' && response.words) { + result.words = response.words.map(word => ({ + word: word.word, + start: word.start, + end: word.end, + } as TranscriptionWord)); } return result; diff --git a/src/providers/types.ts b/src/providers/types.ts index ec23f59..a1b66e6 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,19 +1,19 @@ -import { AIProviderType } from '../settings'; -import { TestAudioData } from '../processing/audio-processor'; +import { AIProviderType, TimestampGranularity } from '../settings'; +import { TestAudioData, TranscriptionWord, TranscriptionSegment } from '../processing/audio-processor'; -// Re-export TestAudioData for consumers of this module -export type { TestAudioData }; +// Re-export types for consumers of this module +export type { TestAudioData, TranscriptionWord, TranscriptionSegment }; /** * Options for audio transcription */ export interface TranscriptionOptions { - includeTimestamps?: boolean; + timestampGranularity?: TimestampGranularity; language?: string; } /** - * Individual chunk with timestamps from transcription + * Individual chunk with timestamps from transcription (segment-level) */ export interface TranscriptionChunk { text: string; @@ -21,11 +21,17 @@ export interface TranscriptionChunk { } /** - * Result from audio transcription + * Result from audio transcription. + * Always includes both plain text and timestamped chunks. */ export interface TranscriptionResult { + /** The complete transcription text */ text: string; - chunks?: TranscriptionChunk[]; + /** Timestamped segments/chunks from the transcription */ + chunks: TranscriptionChunk[]; + /** Word-level timestamps (only populated when granularity is 'word') */ + words?: TranscriptionWord[]; + /** Path to the source audio file */ audioFilePath: string; } diff --git a/src/settings/additional-settings.ts b/src/settings/additional-settings.ts index bf53c52..9e413f8 100644 --- a/src/settings/additional-settings.ts +++ b/src/settings/additional-settings.ts @@ -6,6 +6,7 @@ import AIToolboxPlugin from "../main"; */ export interface AdditionalSettingsCallbacks { refresh: () => void; + isAdvancedVisible: () => boolean; } /** @@ -14,27 +15,9 @@ export interface AdditionalSettingsCallbacks { export function displayAdditionalSettings( containerEl: HTMLElement, plugin: AIToolboxPlugin, - _callbacks: AdditionalSettingsCallbacks + callbacks: AdditionalSettingsCallbacks ): void { - let showAdvanced = false; - - const advancedToggleSetting = new Setting(containerEl) - .addToggle(toggle => toggle - .setValue(showAdvanced) - .onChange((value) => { - showAdvanced = value; - ytdlpPathSetting.settingEl.toggleClass('additional-settings-advanced-hidden', !value); - ffmpegPathSetting.settingEl.toggleClass('additional-settings-advanced-hidden', !value); - advancedLabel.toggleClass('additional-settings-advanced-label-active', value); - ytdlpPathSetting.nameEl.addClass('additional-settings-advanced-name'); - ffmpegPathSetting.nameEl.addClass('additional-settings-advanced-name'); - })); - advancedToggleSetting.settingEl.addClass('additional-settings-toggle'); - const advancedLabel = advancedToggleSetting.controlEl.createSpan({ - text: 'Show advanced settings', - cls: 'additional-settings-toggle-label' - }); - advancedToggleSetting.controlEl.prepend(advancedLabel); + const showAdvanced = callbacks.isAdvancedVisible(); // yt-dlp section header const ytdlpHeading = new Setting(containerEl) @@ -67,7 +50,10 @@ export function displayAdditionalSettings( plugin.settings.ytdlpLocation = value; await plugin.saveSettings(); })); - ytdlpPathSetting.settingEl.addClass('additional-settings-advanced-hidden'); + ytdlpPathSetting.settingEl.toggleClass('settings-advanced-hidden', !showAdvanced); + if (showAdvanced) { + ytdlpPathSetting.nameEl.addClass('settings-advanced-name'); + } const ffmpegPathSetting = new Setting(containerEl) .setName('FFmpeg path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun @@ -78,6 +64,9 @@ export function displayAdditionalSettings( plugin.settings.ffmpegLocation = value; await plugin.saveSettings(); })); - ffmpegPathSetting.settingEl.addClass('additional-settings-advanced-hidden'); + ffmpegPathSetting.settingEl.toggleClass('settings-advanced-hidden', !showAdvanced); + if (showAdvanced) { + ffmpegPathSetting.nameEl.addClass('settings-advanced-name'); + } } diff --git a/src/settings/index.ts b/src/settings/index.ts index 10c8085..77e48a2 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab } from "obsidian"; +import { App, PluginSettingTab, Setting } from "obsidian"; import AIToolboxPlugin from "../main"; import { SettingsTabType, ExpandOnNextRenderState } from "./types"; import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; @@ -19,6 +19,7 @@ export type { TranscriptionMediaType, TranscriptionSourceType, TranscriptionContextConfig, + TimestampGranularity, ChatContextType, ChatContextConfig, AIToolboxSettings, @@ -31,6 +32,8 @@ export class AIToolboxSettingTab extends PluginSettingTab { private activeTab: SettingsTabType = 'providers'; // Track IDs that should start expanded on next render (cleared after use) private expandOnNextRender: ExpandOnNextRenderState = {}; + // Global advanced settings visibility state + private showAdvancedSettings = false; constructor(app: App, plugin: AIToolboxPlugin) { super(app, plugin); @@ -43,6 +46,27 @@ export class AIToolboxSettingTab extends PluginSettingTab { containerEl.empty(); const tabContainer = containerEl.createDiv('settings-tab-container'); + + // Global advanced settings toggle - positioned above tab header + const advancedToggleSetting = new Setting(tabContainer) + .addToggle(toggle => toggle + .setValue(this.showAdvancedSettings) + .onChange((value) => { + this.showAdvancedSettings = value; + advancedLabel.toggleClass('settings-advanced-toggle-label-active', value); + this.display(); + })); + advancedToggleSetting.settingEl.addClass('settings-advanced-toggle'); + const advancedLabel = advancedToggleSetting.controlEl.createSpan({ + text: 'Show advanced settings', + cls: 'settings-advanced-toggle-label' + }); + advancedToggleSetting.controlEl.prepend(advancedLabel); + // Apply active state on initial render if already enabled + if (this.showAdvancedSettings) { + advancedLabel.addClass('settings-advanced-toggle-label-active'); + } + const tabHeader = tabContainer.createDiv('settings-tab-header'); const tabContent = tabContainer.createDiv('settings-tab-content'); @@ -100,14 +124,16 @@ export class AIToolboxSettingTab extends PluginSettingTab { const callbacks: WorkflowSettingsCallbacks = { getExpandState: () => this.expandOnNextRender, setExpandState: (state) => { this.expandOnNextRender = state; }, - refresh: () => this.display() + refresh: () => this.display(), + isAdvancedVisible: () => this.showAdvancedSettings }; displayWorkflowsSettings(containerEl, this.plugin, callbacks); } private displaySettingsTab(containerEl: HTMLElement): void { const callbacks: AdditionalSettingsCallbacks = { - refresh: () => this.display() + refresh: () => this.display(), + isAdvancedVisible: () => this.showAdvancedSettings }; displayAdditionalSettings(containerEl, this.plugin, callbacks); } diff --git a/src/settings/types.ts b/src/settings/types.ts index e881379..77fcdc1 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -65,6 +65,14 @@ export type TranscriptionMediaType = 'video' | 'audio'; */ export type TranscriptionSourceType = 'select-file-from-vault' | 'url-from-clipboard' | 'url-from-selection'; +/** + * Timestamp granularity for transcription output. + * - 'disabled': No timestamps (uses simpler JSON format, reduces token usage) + * - 'segment': Timestamps at sentence/phrase level + * - 'word': Timestamps at individual word level + */ +export type TimestampGranularity = 'disabled' | 'segment' | 'word'; + /** * Context configuration for transcription workflows */ @@ -119,7 +127,7 @@ export interface WorkflowConfig { workflowContexts?: WorkflowContextConfig[]; // Transcription-specific settings (optional for backward compatibility) language?: string; - includeTimestamps?: boolean; + timestampGranularity?: TimestampGranularity; transcriptionContext?: TranscriptionContextConfig; } @@ -141,7 +149,7 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { contexts: [], workflowContexts: [], language: '', - includeTimestamps: true + timestampGranularity: 'disabled' }; export interface AIToolboxSettings { diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index 8e60ca0..1903cd7 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -7,6 +7,7 @@ import { PromptSourceType, TranscriptionMediaType, TranscriptionSourceType, + TimestampGranularity, ChatContextType, ExpandOnNextRenderState, generateId, @@ -30,6 +31,7 @@ export interface WorkflowSettingsCallbacks { getExpandState: () => ExpandOnNextRenderState; setExpandState: (state: ExpandOnNextRenderState) => void; refresh: () => void; + isAdvancedVisible: () => boolean; } /** @@ -99,6 +101,8 @@ function displayWorkflowSettings( const workflowType = workflow.type || 'chat'; const icon = workflowType === 'chat' ? 'message-circle' : 'audio-lines'; + const showAdvanced = callbacks.isAdvancedVisible(); + const { contentContainer, updateTitle, isExpanded } = createCollapsibleSection({ containerEl, title: workflow.name || 'Unnamed workflow', @@ -108,7 +112,7 @@ function displayWorkflowSettings( startExpanded: shouldExpand, isHeading: true, icon, - secondaryText: workflow.id, + secondaryText: showAdvanced ? workflow.id : undefined, onDelete: async () => { const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); if (index !== -1) { @@ -435,8 +439,9 @@ function displayTranscriptionWorkflowSettings( await plugin.saveSettings(); })); - // Language setting - new Setting(contentContainer) + // Language setting (advanced) + const showAdvanced = callbacks.isAdvancedVisible(); + const languageSetting = new Setting(contentContainer) .setName('Language') .setDesc('Optional language code for transcription (e.g., "en", "es", "fr"). Leave empty for auto-detection.') .addText(text => text @@ -446,6 +451,33 @@ function displayTranscriptionWorkflowSettings( workflow.language = value; await plugin.saveSettings(); })); + languageSetting.settingEl.toggleClass('settings-advanced-hidden', !showAdvanced); + if (showAdvanced) { + languageSetting.nameEl.addClass('settings-advanced-name'); + } + + // Timestamp granularity dropdown (advanced) + const granularityOptions: Record = { + 'disabled': 'Disabled (no timestamps)', + 'segment': 'Segment (sentence/phrase level)', + 'word': 'Word (individual word level)' + }; + const granularitySetting = new Setting(contentContainer) + .setName('Timestamp granularity') + .setDesc('Level of detail for timestamps. Disabling reduces token usage.') + .addDropdown(dropdown => dropdown + .addOptions(granularityOptions) + .setValue(workflow.timestampGranularity ?? 'disabled') + .onChange(async (value) => { + workflow.timestampGranularity = value as TimestampGranularity; + await plugin.saveSettings(); + callbacks.setExpandState({ workflowId: workflow.id }); + callbacks.refresh(); + })); + granularitySetting.settingEl.toggleClass('settings-advanced-hidden', !showAdvanced); + if (showAdvanced) { + granularitySetting.nameEl.addClass('settings-advanced-name'); + } // Make available as input to other workflows toggle new Setting(contentContainer) @@ -497,17 +529,6 @@ function displayTranscriptionWorkflowSettings( callbacks.refresh(); })); - // 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(); - })); - // Output folder (only show if output type is new-note) if (workflow.outputType === 'new-note') { createPathPicker({ @@ -719,6 +740,8 @@ interface TokenGroup { workflowId?: string; /** For workflow groups: the source workflow type */ workflowType?: WorkflowType; + /** For transcription workflows: the timestamp granularity setting */ + timestampGranularity?: TimestampGranularity; } /** @@ -789,16 +812,19 @@ function displayAvailableTokensSection( if (!sourceWorkflow) continue; const workflowType = sourceWorkflow.type || 'chat'; + const timestampGranularity = sourceWorkflow.timestampGranularity; const workflowTokens = getWorkflowContextTokens( sourceWorkflow.id, - workflowType + workflowType, + { timestampGranularity } ); if (workflowTokens.length > 0) { tokenGroups.push({ name: sourceWorkflow.name || 'Unnamed workflow', tokens: workflowTokens, workflowId: sourceWorkflow.id, - workflowType: workflowType + workflowType: workflowType, + timestampGranularity: timestampGranularity }); totalTokenCount += workflowTokens.length; } @@ -843,9 +869,10 @@ function displayAvailableTokensSection( groupHeader.setAttribute('title', 'Click to copy all tokens as template'); const workflowId = group.workflowId; const workflowType = group.workflowType; + const timestampGranularity = group.timestampGranularity; groupHeader.addEventListener('click', (e) => { e.stopPropagation(); - const template = generateWorkflowTokenTemplate(workflowId, workflowType); + const template = generateWorkflowTokenTemplate(workflowId, workflowType, { timestampGranularity }); void navigator.clipboard.writeText(template).then(() => { new Notice('Copied token template to clipboard'); }); diff --git a/src/tokens/types.ts b/src/tokens/types.ts index c302971..f2c2947 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -1,4 +1,4 @@ -import { WorkflowType } from "../settings/types"; +import { WorkflowType, TimestampGranularity } from "../settings/types"; /** * Token definition for display in the settings UI @@ -36,8 +36,10 @@ export interface TranscriptionWorkflowTokens { author: string; /** The title of the video */ title: string; - /** The full transcription text */ + /** The full transcription text (plain text without timestamps) */ transcription: string; + /** The transcription with timestamps (formatted with [MM:SS] prefixes) */ + transcriptionWithTimestamps: string; /** The original video URL */ sourceUrl: string; /** The video description */ @@ -63,7 +65,8 @@ export const TRANSCRIPTION_WORKFLOW_TOKENS: TokenDefinition[] = [ { name: 'sourceUrl', description: 'The original video URL' }, { name: 'description', description: 'The video description' }, { name: 'tags', description: 'The video tags (comma-separated)' }, - { name: 'transcription', description: 'The full transcription text' } + { name: 'transcription', description: 'The plain transcription text (no timestamps)' }, + { name: 'transcriptionWithTimestamps', description: 'The transcription with [MM:SS] timestamps' } ]; /** @@ -77,16 +80,37 @@ const TOKEN_LABELS: Record = { sourceUrl: 'Source URL', description: 'Description', tags: 'Tags', - transcription: 'Transcription' + transcription: 'Transcription', + transcriptionWithTimestamps: 'Transcription (with timestamps)' }; /** - * Get token definitions for a workflow type + * Options for getting token definitions */ -export function getTokenDefinitionsForType(type: WorkflowType): TokenDefinition[] { - return type === 'transcription' - ? TRANSCRIPTION_WORKFLOW_TOKENS - : CHAT_WORKFLOW_TOKENS; +export interface TokenDefinitionOptions { + /** For transcription workflows, the timestamp granularity setting */ + timestampGranularity?: TimestampGranularity; +} + +/** + * Get token definitions for a workflow type. + * For transcription workflows, the transcriptionWithTimestamps token is excluded + * when timestampGranularity is 'disabled'. + */ +export function getTokenDefinitionsForType( + type: WorkflowType, + options?: TokenDefinitionOptions +): TokenDefinition[] { + if (type === 'transcription') { + // Filter out transcriptionWithTimestamps when granularity is disabled + if (options?.timestampGranularity === 'disabled') { + return TRANSCRIPTION_WORKFLOW_TOKENS.filter( + token => token.name !== 'transcriptionWithTimestamps' + ); + } + return TRANSCRIPTION_WORKFLOW_TOKENS; + } + return CHAT_WORKFLOW_TOKENS; } /** @@ -95,12 +119,14 @@ export function getTokenDefinitionsForType(type: WorkflowType): TokenDefinition[ * * @param workflowId - The ID of the workflow * @param workflowType - The type of the workflow (chat or transcription) + * @param options - Optional settings like timestampGranularity */ export function getWorkflowContextTokens( workflowId: string, - workflowType: WorkflowType + workflowType: WorkflowType, + options?: TokenDefinitionOptions ): TokenDefinition[] { - const baseTokens = getTokenDefinitionsForType(workflowType); + const baseTokens = getTokenDefinitionsForType(workflowType, options); return baseTokens.map(token => ({ name: `${workflowId}.${token.name}`, @@ -114,12 +140,14 @@ export function getWorkflowContextTokens( * * @param workflowId - The ID of the workflow * @param workflowType - The type of the workflow (chat or transcription) + * @param options - Optional settings like timestampGranularity */ export function generateWorkflowTokenTemplate( workflowId: string, - workflowType: WorkflowType + workflowType: WorkflowType, + options?: TokenDefinitionOptions ): string { - const tokens = getTokenDefinitionsForType(workflowType); + const tokens = getTokenDefinitionsForType(workflowType, options); return tokens .map(token => { diff --git a/styles.css b/styles.css index d981e3a..8912930 100644 --- a/styles.css +++ b/styles.css @@ -601,32 +601,37 @@ button.mod-success:hover { color: var(--text-on-accent); } -/* Additional settings styles */ -.additional-settings-toggle { +/* Global advanced settings toggle (above tab header) */ +.settings-advanced-toggle { justify-content: flex-end; + margin-bottom: 8px; + border-bottom: none; } -.additional-settings-toggle .setting-item-name { +.settings-advanced-toggle .setting-item-name { display: none; } -.additional-settings-toggle-label { +.settings-advanced-toggle-label { font-size: 0.85em; margin-right: 8px; } -.additional-settings-heading .setting-item-name { - font-size: 1.17em; +.settings-advanced-toggle-label-active { + color: var(--text-warning); } -.additional-settings-advanced-hidden { +/* Settings hidden when advanced toggle is off */ +.settings-advanced-hidden { display: none; } -.additional-settings-advanced-label-active { +/* Advanced setting name styling when visible */ +.settings-advanced-name { color: var(--text-warning); } -.additional-settings-advanced-name { - color: var(--text-warning); +/* Additional settings tab styles */ +.additional-settings-heading .setting-item-name { + font-size: 1.17em; } From dcdaf122cc3b9b28def15848b44188e20b462f0f Mon Sep 17 00:00:00 2001 From: dalinicus Date: Fri, 9 Jan 2026 21:02:55 -0600 Subject: [PATCH 2/7] change name to llm studio --- README.md | 12 ++++++------ manifest.json | 8 ++++---- src/handlers/input/types.ts | 4 ++-- src/main.ts | 10 +++++----- src/processing/video-processor.ts | 10 +++++----- src/processing/workflow-chaining.ts | 6 +++--- src/processing/workflow-executor.ts | 24 +++++++++++++++--------- src/providers/provider-factory.ts | 6 +++--- src/settings/additional-settings.ts | 4 ++-- src/settings/index.ts | 10 +++++----- src/settings/providers.ts | 8 ++++---- src/settings/types.ts | 4 ++-- src/settings/workflows.ts | 20 ++++++++++---------- src/tokens/types.ts | 7 ++++--- 14 files changed, 70 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 91b9b0d..b3fe3ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# AI Toolbox +# LLM Toolbox -A personal collection of AI tools to enhance the Obsidian.md workflow. +A personal collection of LLM tools to enhance the Obsidian.md workflow. ## Features @@ -37,7 +37,7 @@ pip install yt-dlp[curl_cffi] ## How to Use 1. **Configure the plugin**: - - Go to **Settings → AI Toolbox** + - Go to **Settings → LLM Toolbox** - Enter your Azure OpenAI endpoint, API key, and deployment name - Configure optional settings (timestamps, language, output folder) @@ -53,14 +53,14 @@ pip install yt-dlp[curl_cffi] 1. Install the [BRAT plugin](https://github.com/TfTHacker/obsidian42-brat) if you haven't already 2. Open **Settings → BRAT → Add Beta plugin** -3. Enter this repository ID: `dalinicus/obsidian-ai-toolbox` +3. Enter this repository ID: `dalinicus/obsidian-llm-toolbox` 4. Click **Add Plugin** 5. Enable the plugin in **Settings → Community plugins** ### Manual Installation -1. Download the latest release from the [Releases page](https://github.com/dalinicus/obsidian-ai-toolbox/releases) -2. Extract the files to your vault's plugins folder: `VaultFolder/.obsidian/plugins/ai-toolbox/` +1. Download the latest release from the [Releases page](https://github.com/dalinicus/obsidian-llm-toolbox/releases) +2. Extract the files to your vault's plugins folder: `VaultFolder/.obsidian/plugins/llm-toolbox/` 3. Reload Obsidian 4. Enable the plugin in **Settings → Community plugins** diff --git a/manifest.json b/manifest.json index de98265..ba922aa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "id": "ai-toolbox", - "name": "AI Toolbox", - "version": "1.0.3", + "id": "llm-toolbox", + "name": "LLM Toolbox", + "version": "1.0.5", "minAppVersion": "0.15.0", - "description": "Personal collection of AI tools to enhance the Obsidian.md workflow.", + "description": "Personal collection of LLM tools to enhance the Obsidian.md workflow.", "author": "dalinicus", "authorUrl": "https://github.com/dalinicus", "fundingUrl": "https://buymeacoffee.com/dalinicus", diff --git a/src/handlers/input/types.ts b/src/handlers/input/types.ts index 6e0d6e7..deb19a0 100644 --- a/src/handlers/input/types.ts +++ b/src/handlers/input/types.ts @@ -1,5 +1,5 @@ import { App } from 'obsidian'; -import { AIToolboxSettings, WorkflowConfig } from '../../settings'; +import { LLMToolboxSettings, WorkflowConfig } from '../../settings'; import { VideoMetadata } from '../../processing'; /** @@ -22,7 +22,7 @@ export interface InputContext { /** The Obsidian App instance */ app: App; /** Plugin settings */ - settings: AIToolboxSettings; + settings: LLMToolboxSettings; /** The workflow configuration being executed */ workflow: WorkflowConfig; } diff --git a/src/main.ts b/src/main.ts index 4e3b00c..c32deaf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,10 @@ import { Notice, Plugin } from 'obsidian'; -import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; +import { DEFAULT_SETTINGS, LLMToolboxSettings, LLMToolboxSettingTab } from "./settings/index"; import { WorkflowSuggesterModal } from "./components/workflow-suggester"; import { executeWorkflow } from "./processing/workflow-executor"; -export default class AIToolboxPlugin extends Plugin { - settings: AIToolboxSettings; +export default class LLMToolboxPlugin extends Plugin { + settings: LLMToolboxSettings; async onload() { await this.loadSettings(); @@ -17,7 +17,7 @@ export default class AIToolboxPlugin extends Plugin { }); // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new AIToolboxSettingTab(this.app, this)); + this.addSettingTab(new LLMToolboxSettingTab(this.app, this)); } /** @@ -38,7 +38,7 @@ export default class AIToolboxPlugin extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } async saveSettings() { diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 7e3e6f7..585c3eb 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { Buffer } from 'buffer'; import { Notice } from 'obsidian'; -import { AIToolboxSettings } from '../settings'; +import { LLMToolboxSettings } from '../settings'; import { videoPlatformRegistry, VideoMetadata } from './video-platforms'; /** @@ -198,9 +198,9 @@ export interface ExtractAudioResult { } /** - * Converts AIToolboxSettings to VideoProcessorConfig. + * Converts LLMToolboxSettings to VideoProcessorConfig. */ -function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorConfig { +function settingsToProcessorConfig(settings: LLMToolboxSettings): VideoProcessorConfig { return { ytdlpLocation: settings.ytdlpLocation, ffmpegLocation: settings.ffmpegLocation, @@ -216,7 +216,7 @@ function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorC * 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 extractAudioFromUrl(url: string, settings: AIToolboxSettings): Promise { +export async function extractAudioFromUrl(url: string, settings: LLMToolboxSettings): Promise { try { if (!url || !url.trim()) { new Notice('URL is empty'); @@ -270,7 +270,7 @@ export async function extractAudioFromUrl(url: string, settings: AIToolboxSettin * 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 extractAudioFromClipboard(settings: LLMToolboxSettings): Promise { try { const clipboardText = await navigator.clipboard.readText(); diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts index c660f9b..3d9189d 100644 --- a/src/processing/workflow-chaining.ts +++ b/src/processing/workflow-chaining.ts @@ -1,5 +1,5 @@ import { App, MarkdownView } from 'obsidian'; -import { WorkflowConfig, AIToolboxSettings, TimestampGranularity } from '../settings'; +import { WorkflowConfig, LLMToolboxSettings, TimestampGranularity } from '../settings'; import { TranscriptionResult, TranscriptionChunk } from '../providers'; /** @@ -30,7 +30,7 @@ export interface DependencyExecutionContext { /** The Obsidian App instance */ app: App; /** Plugin settings */ - settings: AIToolboxSettings; + settings: LLMToolboxSettings; /** Results from executed dependencies */ results: WorkflowResultsMap; /** Workflow IDs currently in the execution stack (for circular detection) */ @@ -43,7 +43,7 @@ export interface DependencyExecutionContext { */ export function detectCircularDependency( workflow: WorkflowConfig, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, visited: Set = new Set(), path: string[] = [] ): string[] { diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index 7c03290..e11e175 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,5 +1,5 @@ import { App, Notice, TFile } from 'obsidian'; -import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; +import { WorkflowConfig, LLMToolboxSettings, TranscriptionSourceType } from '../settings'; import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; import { videoPlatformRegistry } from './video-platforms'; import { generateFilenameTimestamp } from '../utils/date-utils'; @@ -130,7 +130,7 @@ async function getPromptText(app: App, workflow: WorkflowConfig): Promise @@ -194,7 +194,7 @@ async function executeDependencies( */ export async function executeWorkflow( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig ): Promise { // Check for circular dependencies before starting @@ -242,7 +242,7 @@ export async function executeWorkflow( */ async function executeWorkflowInternal( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig, dependencyResults: WorkflowResultsMap ): Promise { @@ -260,7 +260,7 @@ async function executeWorkflowInternal( */ async function executeChatWorkflow( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig, dependencyResults?: WorkflowResultsMap ): Promise { @@ -337,7 +337,7 @@ async function executeChatWorkflow( */ async function executeChatWorkflowInternal( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig, dependencyResults: WorkflowResultsMap ): Promise { @@ -405,7 +405,7 @@ async function executeChatWorkflowInternal( */ async function executeTranscriptionWorkflow( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig, _dependencyResults?: WorkflowResultsMap ): Promise { @@ -467,7 +467,7 @@ async function executeTranscriptionWorkflow( */ async function executeTranscriptionWorkflowInternal( app: App, - settings: AIToolboxSettings, + settings: LLMToolboxSettings, workflow: WorkflowConfig, _dependencyResults: WorkflowResultsMap ): Promise { @@ -509,10 +509,16 @@ async function executeTranscriptionWorkflowInternal( const transcriptionResult = await provider.transcribeAudio(inputResult.audioFilePath, transcriptionOptions); + // Combine metadata with sourceUrl for token creation + const tokenMetadata = { + ...inputResult.metadata, + sourceUrl: inputResult.sourceUrl + }; + return { ...baseResult, success: true, - tokens: createTranscriptionWorkflowTokens(transcriptionResult, inputResult.metadata, timestampGranularity) + tokens: createTranscriptionWorkflowTokens(transcriptionResult, tokenMetadata, timestampGranularity) }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/providers/provider-factory.ts b/src/providers/provider-factory.ts index cbef66b..e0c0277 100644 --- a/src/providers/provider-factory.ts +++ b/src/providers/provider-factory.ts @@ -1,4 +1,4 @@ -import { AIToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT, WorkflowConfig, ProviderModelSelection } from '../settings/index'; +import { LLMToolboxSettings, 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'; @@ -62,7 +62,7 @@ export function createModelProvider(config: ModelProviderConfig): ModelProvider * @returns A configured ModelProvider, or null if not found * @throws ProviderCreationError if the provider cannot be created */ -function createProviderFromSelection(settings: AIToolboxSettings, selection: ProviderModelSelection): ModelProvider | null { +function createProviderFromSelection(settings: LLMToolboxSettings, selection: ProviderModelSelection): ModelProvider | null { const provider = settings.providers.find(p => p.id === selection.providerId); if (!provider) { return null; @@ -85,7 +85,7 @@ function createProviderFromSelection(settings: AIToolboxSettings, selection: Pro * @returns A configured ModelProvider for the workflow, or null if not configured * @throws ProviderCreationError if the provider cannot be created */ -export function createWorkflowProvider(settings: AIToolboxSettings, workflow: WorkflowConfig): ModelProvider | null { +export function createWorkflowProvider(settings: LLMToolboxSettings, workflow: WorkflowConfig): ModelProvider | null { if (!workflow.provider) { return null; } diff --git a/src/settings/additional-settings.ts b/src/settings/additional-settings.ts index 9e413f8..cc57536 100644 --- a/src/settings/additional-settings.ts +++ b/src/settings/additional-settings.ts @@ -1,5 +1,5 @@ import { Setting } from "obsidian"; -import AIToolboxPlugin from "../main"; +import LLMToolboxPlugin from "../main"; /** * Callbacks for the additional settings tab to communicate with the main settings tab @@ -14,7 +14,7 @@ export interface AdditionalSettingsCallbacks { */ export function displayAdditionalSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, callbacks: AdditionalSettingsCallbacks ): void { const showAdvanced = callbacks.isAdvancedVisible(); diff --git a/src/settings/index.ts b/src/settings/index.ts index 77e48a2..6ec9093 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -1,5 +1,5 @@ import { App, PluginSettingTab, Setting } from "obsidian"; -import AIToolboxPlugin from "../main"; +import LLMToolboxPlugin from "../main"; import { SettingsTabType, ExpandOnNextRenderState } from "./types"; import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; import { displayWorkflowsSettings, WorkflowSettingsCallbacks } from "./workflows"; @@ -22,20 +22,20 @@ export type { TimestampGranularity, ChatContextType, ChatContextConfig, - AIToolboxSettings, + LLMToolboxSettings, SettingsTabType, ExpandOnNextRenderState } from "./types"; -export class AIToolboxSettingTab extends PluginSettingTab { - plugin: AIToolboxPlugin; +export class LLMToolboxSettingTab extends PluginSettingTab { + plugin: LLMToolboxPlugin; private activeTab: SettingsTabType = 'providers'; // Track IDs that should start expanded on next render (cleared after use) private expandOnNextRender: ExpandOnNextRenderState = {}; // Global advanced settings visibility state private showAdvancedSettings = false; - constructor(app: App, plugin: AIToolboxPlugin) { + constructor(app: App, plugin: LLMToolboxPlugin) { super(app, plugin); this.plugin = plugin; } diff --git a/src/settings/providers.ts b/src/settings/providers.ts index 76b2233..30c9b3c 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -1,5 +1,5 @@ import { Setting, setIcon, ButtonComponent, Notice } from "obsidian"; -import AIToolboxPlugin from "../main"; +import LLMToolboxPlugin from "../main"; import { AIProviderConfig, AIProviderType, @@ -156,7 +156,7 @@ async function testModel(provider: AIProviderConfig, model: AIModelConfig): Prom */ export function displayProvidersSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, callbacks: ProviderSettingsCallbacks ): void { // Add provider button @@ -188,7 +188,7 @@ export function displayProvidersSettings( function displayProviderSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, provider: AIProviderConfig, callbacks: ProviderSettingsCallbacks ): void { @@ -306,7 +306,7 @@ function displayProviderSettings( function displayModelSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, provider: AIProviderConfig, model: AIModelConfig, callbacks: ProviderSettingsCallbacks diff --git a/src/settings/types.ts b/src/settings/types.ts index 77fcdc1..5c9c758 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -152,7 +152,7 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { timestampGranularity: 'disabled' }; -export interface AIToolboxSettings { +export interface LLMToolboxSettings { impersonateBrowser: string; ytdlpLocation: string; ffmpegLocation: string; @@ -171,7 +171,7 @@ export function generateId(): string { return Math.random().toString(36).substring(2, 11); } -export const DEFAULT_SETTINGS: AIToolboxSettings = { +export const DEFAULT_SETTINGS: LLMToolboxSettings = { impersonateBrowser: 'chrome', ytdlpLocation: '', ffmpegLocation: '', diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index 1903cd7..ffe82c7 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -1,5 +1,5 @@ import { Notice, setIcon, Setting } from "obsidian"; -import AIToolboxPlugin from "../main"; +import LLMToolboxPlugin from "../main"; import { WorkflowConfig, WorkflowOutputType, @@ -56,7 +56,7 @@ const PROMPT_SOURCE_OPTIONS: Record = { */ export function displayWorkflowsSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, callbacks: WorkflowSettingsCallbacks ): void { // Add workflow button @@ -90,7 +90,7 @@ export function displayWorkflowsSettings( function displayWorkflowSettings( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks ): void { @@ -154,7 +154,7 @@ function displayWorkflowSettings( */ function displayChatWorkflowSettings( contentContainer: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -295,7 +295,7 @@ function displayChatWorkflowSettings( function displayWorkflowProviderSelection( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig ): void { const providers = plugin.settings.providers; @@ -344,7 +344,7 @@ function displayWorkflowProviderSelection( */ function displayTranscriptionProviderSelection( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig ): void { const providers = plugin.settings.providers; @@ -392,7 +392,7 @@ function displayTranscriptionProviderSelection( */ function displayTranscriptionWorkflowSettings( contentContainer: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -553,7 +553,7 @@ function displayTranscriptionWorkflowSettings( */ function displayContextSection( contentContainer: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -638,7 +638,7 @@ function displayContextSection( */ function displayWorkflowContextSection( containerEl: HTMLElement, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -774,7 +774,7 @@ function renderTokenReference( function displayAvailableTokensSection( containerEl: HTMLElement, workflow: WorkflowConfig, - plugin: AIToolboxPlugin, + plugin: LLMToolboxPlugin, callbacks: WorkflowSettingsCallbacks ): void { const contexts = workflow.contexts ?? []; diff --git a/src/tokens/types.ts b/src/tokens/types.ts index f2c2947..a18b6a8 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -95,15 +95,16 @@ export interface TokenDefinitionOptions { /** * Get token definitions for a workflow type. * For transcription workflows, the transcriptionWithTimestamps token is excluded - * when timestampGranularity is 'disabled'. + * when timestampGranularity is 'disabled' or undefined (default). */ export function getTokenDefinitionsForType( type: WorkflowType, options?: TokenDefinitionOptions ): TokenDefinition[] { if (type === 'transcription') { - // Filter out transcriptionWithTimestamps when granularity is disabled - if (options?.timestampGranularity === 'disabled') { + // Filter out transcriptionWithTimestamps when granularity is disabled or not set + const granularity = options?.timestampGranularity ?? 'disabled'; + if (granularity === 'disabled') { return TRANSCRIPTION_WORKFLOW_TOKENS.filter( token => token.name !== 'transcriptionWithTimestamps' ); From 569f97746372d977c7e35a8f7acf8f586af6a900 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Fri, 9 Jan 2026 21:04:42 -0600 Subject: [PATCH 3/7] eh changed my mind --- README.md | 12 ++++++------ manifest.json | 8 ++++---- src/handlers/input/types.ts | 4 ++-- src/main.ts | 10 +++++----- src/processing/video-processor.ts | 10 +++++----- src/processing/workflow-chaining.ts | 6 +++--- src/processing/workflow-executor.ts | 16 ++++++++-------- src/providers/provider-factory.ts | 6 +++--- src/settings/additional-settings.ts | 4 ++-- src/settings/index.ts | 10 +++++----- src/settings/providers.ts | 8 ++++---- src/settings/types.ts | 4 ++-- src/settings/workflows.ts | 20 ++++++++++---------- 13 files changed, 59 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b3fe3ae..91b9b0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# LLM Toolbox +# AI Toolbox -A personal collection of LLM tools to enhance the Obsidian.md workflow. +A personal collection of AI tools to enhance the Obsidian.md workflow. ## Features @@ -37,7 +37,7 @@ pip install yt-dlp[curl_cffi] ## How to Use 1. **Configure the plugin**: - - Go to **Settings → LLM Toolbox** + - Go to **Settings → AI Toolbox** - Enter your Azure OpenAI endpoint, API key, and deployment name - Configure optional settings (timestamps, language, output folder) @@ -53,14 +53,14 @@ pip install yt-dlp[curl_cffi] 1. Install the [BRAT plugin](https://github.com/TfTHacker/obsidian42-brat) if you haven't already 2. Open **Settings → BRAT → Add Beta plugin** -3. Enter this repository ID: `dalinicus/obsidian-llm-toolbox` +3. Enter this repository ID: `dalinicus/obsidian-ai-toolbox` 4. Click **Add Plugin** 5. Enable the plugin in **Settings → Community plugins** ### Manual Installation -1. Download the latest release from the [Releases page](https://github.com/dalinicus/obsidian-llm-toolbox/releases) -2. Extract the files to your vault's plugins folder: `VaultFolder/.obsidian/plugins/llm-toolbox/` +1. Download the latest release from the [Releases page](https://github.com/dalinicus/obsidian-ai-toolbox/releases) +2. Extract the files to your vault's plugins folder: `VaultFolder/.obsidian/plugins/ai-toolbox/` 3. Reload Obsidian 4. Enable the plugin in **Settings → Community plugins** diff --git a/manifest.json b/manifest.json index ba922aa..de98265 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "id": "llm-toolbox", - "name": "LLM Toolbox", - "version": "1.0.5", + "id": "ai-toolbox", + "name": "AI Toolbox", + "version": "1.0.3", "minAppVersion": "0.15.0", - "description": "Personal collection of LLM tools to enhance the Obsidian.md workflow.", + "description": "Personal collection of AI tools to enhance the Obsidian.md workflow.", "author": "dalinicus", "authorUrl": "https://github.com/dalinicus", "fundingUrl": "https://buymeacoffee.com/dalinicus", diff --git a/src/handlers/input/types.ts b/src/handlers/input/types.ts index deb19a0..6e0d6e7 100644 --- a/src/handlers/input/types.ts +++ b/src/handlers/input/types.ts @@ -1,5 +1,5 @@ import { App } from 'obsidian'; -import { LLMToolboxSettings, WorkflowConfig } from '../../settings'; +import { AIToolboxSettings, WorkflowConfig } from '../../settings'; import { VideoMetadata } from '../../processing'; /** @@ -22,7 +22,7 @@ export interface InputContext { /** The Obsidian App instance */ app: App; /** Plugin settings */ - settings: LLMToolboxSettings; + settings: AIToolboxSettings; /** The workflow configuration being executed */ workflow: WorkflowConfig; } diff --git a/src/main.ts b/src/main.ts index c32deaf..4e3b00c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,10 @@ import { Notice, Plugin } from 'obsidian'; -import { DEFAULT_SETTINGS, LLMToolboxSettings, LLMToolboxSettingTab } from "./settings/index"; +import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { WorkflowSuggesterModal } from "./components/workflow-suggester"; import { executeWorkflow } from "./processing/workflow-executor"; -export default class LLMToolboxPlugin extends Plugin { - settings: LLMToolboxSettings; +export default class AIToolboxPlugin extends Plugin { + settings: AIToolboxSettings; async onload() { await this.loadSettings(); @@ -17,7 +17,7 @@ export default class LLMToolboxPlugin extends Plugin { }); // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new LLMToolboxSettingTab(this.app, this)); + this.addSettingTab(new AIToolboxSettingTab(this.app, this)); } /** @@ -38,7 +38,7 @@ export default class LLMToolboxPlugin extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } async saveSettings() { diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 585c3eb..7e3e6f7 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { Buffer } from 'buffer'; import { Notice } from 'obsidian'; -import { LLMToolboxSettings } from '../settings'; +import { AIToolboxSettings } from '../settings'; import { videoPlatformRegistry, VideoMetadata } from './video-platforms'; /** @@ -198,9 +198,9 @@ export interface ExtractAudioResult { } /** - * Converts LLMToolboxSettings to VideoProcessorConfig. + * Converts AIToolboxSettings to VideoProcessorConfig. */ -function settingsToProcessorConfig(settings: LLMToolboxSettings): VideoProcessorConfig { +function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorConfig { return { ytdlpLocation: settings.ytdlpLocation, ffmpegLocation: settings.ffmpegLocation, @@ -216,7 +216,7 @@ function settingsToProcessorConfig(settings: LLMToolboxSettings): VideoProcessor * 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 extractAudioFromUrl(url: string, settings: LLMToolboxSettings): Promise { +export async function extractAudioFromUrl(url: string, settings: AIToolboxSettings): Promise { try { if (!url || !url.trim()) { new Notice('URL is empty'); @@ -270,7 +270,7 @@ export async function extractAudioFromUrl(url: string, settings: LLMToolboxSetti * 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: LLMToolboxSettings): Promise { +export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { try { const clipboardText = await navigator.clipboard.readText(); diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts index 3d9189d..c660f9b 100644 --- a/src/processing/workflow-chaining.ts +++ b/src/processing/workflow-chaining.ts @@ -1,5 +1,5 @@ import { App, MarkdownView } from 'obsidian'; -import { WorkflowConfig, LLMToolboxSettings, TimestampGranularity } from '../settings'; +import { WorkflowConfig, AIToolboxSettings, TimestampGranularity } from '../settings'; import { TranscriptionResult, TranscriptionChunk } from '../providers'; /** @@ -30,7 +30,7 @@ export interface DependencyExecutionContext { /** The Obsidian App instance */ app: App; /** Plugin settings */ - settings: LLMToolboxSettings; + settings: AIToolboxSettings; /** Results from executed dependencies */ results: WorkflowResultsMap; /** Workflow IDs currently in the execution stack (for circular detection) */ @@ -43,7 +43,7 @@ export interface DependencyExecutionContext { */ export function detectCircularDependency( workflow: WorkflowConfig, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, visited: Set = new Set(), path: string[] = [] ): string[] { diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index e11e175..af5c6f8 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,5 +1,5 @@ import { App, Notice, TFile } from 'obsidian'; -import { WorkflowConfig, LLMToolboxSettings, TranscriptionSourceType } from '../settings'; +import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; import { videoPlatformRegistry } from './video-platforms'; import { generateFilenameTimestamp } from '../utils/date-utils'; @@ -130,7 +130,7 @@ async function getPromptText(app: App, workflow: WorkflowConfig): Promise @@ -194,7 +194,7 @@ async function executeDependencies( */ export async function executeWorkflow( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig ): Promise { // Check for circular dependencies before starting @@ -242,7 +242,7 @@ export async function executeWorkflow( */ async function executeWorkflowInternal( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig, dependencyResults: WorkflowResultsMap ): Promise { @@ -260,7 +260,7 @@ async function executeWorkflowInternal( */ async function executeChatWorkflow( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig, dependencyResults?: WorkflowResultsMap ): Promise { @@ -337,7 +337,7 @@ async function executeChatWorkflow( */ async function executeChatWorkflowInternal( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig, dependencyResults: WorkflowResultsMap ): Promise { @@ -405,7 +405,7 @@ async function executeChatWorkflowInternal( */ async function executeTranscriptionWorkflow( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig, _dependencyResults?: WorkflowResultsMap ): Promise { @@ -467,7 +467,7 @@ async function executeTranscriptionWorkflow( */ async function executeTranscriptionWorkflowInternal( app: App, - settings: LLMToolboxSettings, + settings: AIToolboxSettings, workflow: WorkflowConfig, _dependencyResults: WorkflowResultsMap ): Promise { diff --git a/src/providers/provider-factory.ts b/src/providers/provider-factory.ts index e0c0277..cbef66b 100644 --- a/src/providers/provider-factory.ts +++ b/src/providers/provider-factory.ts @@ -1,4 +1,4 @@ -import { LLMToolboxSettings, AIProviderConfig, AIModelConfig, DEFAULT_OPENAI_ENDPOINT, WorkflowConfig, 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'; @@ -62,7 +62,7 @@ export function createModelProvider(config: ModelProviderConfig): ModelProvider * @returns A configured ModelProvider, or null if not found * @throws ProviderCreationError if the provider cannot be created */ -function createProviderFromSelection(settings: LLMToolboxSettings, selection: ProviderModelSelection): ModelProvider | null { +function createProviderFromSelection(settings: AIToolboxSettings, selection: ProviderModelSelection): ModelProvider | null { const provider = settings.providers.find(p => p.id === selection.providerId); if (!provider) { return null; @@ -85,7 +85,7 @@ function createProviderFromSelection(settings: LLMToolboxSettings, selection: Pr * @returns A configured ModelProvider for the workflow, or null if not configured * @throws ProviderCreationError if the provider cannot be created */ -export function createWorkflowProvider(settings: LLMToolboxSettings, workflow: WorkflowConfig): ModelProvider | null { +export function createWorkflowProvider(settings: AIToolboxSettings, workflow: WorkflowConfig): ModelProvider | null { if (!workflow.provider) { return null; } diff --git a/src/settings/additional-settings.ts b/src/settings/additional-settings.ts index cc57536..9e413f8 100644 --- a/src/settings/additional-settings.ts +++ b/src/settings/additional-settings.ts @@ -1,5 +1,5 @@ import { Setting } from "obsidian"; -import LLMToolboxPlugin from "../main"; +import AIToolboxPlugin from "../main"; /** * Callbacks for the additional settings tab to communicate with the main settings tab @@ -14,7 +14,7 @@ export interface AdditionalSettingsCallbacks { */ export function displayAdditionalSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, callbacks: AdditionalSettingsCallbacks ): void { const showAdvanced = callbacks.isAdvancedVisible(); diff --git a/src/settings/index.ts b/src/settings/index.ts index 6ec9093..77e48a2 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -1,5 +1,5 @@ import { App, PluginSettingTab, Setting } from "obsidian"; -import LLMToolboxPlugin from "../main"; +import AIToolboxPlugin from "../main"; import { SettingsTabType, ExpandOnNextRenderState } from "./types"; import { displayProvidersSettings, ProviderSettingsCallbacks } from "./providers"; import { displayWorkflowsSettings, WorkflowSettingsCallbacks } from "./workflows"; @@ -22,20 +22,20 @@ export type { TimestampGranularity, ChatContextType, ChatContextConfig, - LLMToolboxSettings, + AIToolboxSettings, SettingsTabType, ExpandOnNextRenderState } from "./types"; -export class LLMToolboxSettingTab extends PluginSettingTab { - plugin: LLMToolboxPlugin; +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 = {}; // Global advanced settings visibility state private showAdvancedSettings = false; - constructor(app: App, plugin: LLMToolboxPlugin) { + constructor(app: App, plugin: AIToolboxPlugin) { super(app, plugin); this.plugin = plugin; } diff --git a/src/settings/providers.ts b/src/settings/providers.ts index 30c9b3c..76b2233 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -1,5 +1,5 @@ import { Setting, setIcon, ButtonComponent, Notice } from "obsidian"; -import LLMToolboxPlugin from "../main"; +import AIToolboxPlugin from "../main"; import { AIProviderConfig, AIProviderType, @@ -156,7 +156,7 @@ async function testModel(provider: AIProviderConfig, model: AIModelConfig): Prom */ export function displayProvidersSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, callbacks: ProviderSettingsCallbacks ): void { // Add provider button @@ -188,7 +188,7 @@ export function displayProvidersSettings( function displayProviderSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, provider: AIProviderConfig, callbacks: ProviderSettingsCallbacks ): void { @@ -306,7 +306,7 @@ function displayProviderSettings( function displayModelSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, provider: AIProviderConfig, model: AIModelConfig, callbacks: ProviderSettingsCallbacks diff --git a/src/settings/types.ts b/src/settings/types.ts index 5c9c758..77fcdc1 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -152,7 +152,7 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { timestampGranularity: 'disabled' }; -export interface LLMToolboxSettings { +export interface AIToolboxSettings { impersonateBrowser: string; ytdlpLocation: string; ffmpegLocation: string; @@ -171,7 +171,7 @@ export function generateId(): string { return Math.random().toString(36).substring(2, 11); } -export const DEFAULT_SETTINGS: LLMToolboxSettings = { +export const DEFAULT_SETTINGS: AIToolboxSettings = { impersonateBrowser: 'chrome', ytdlpLocation: '', ffmpegLocation: '', diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index ffe82c7..1903cd7 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -1,5 +1,5 @@ import { Notice, setIcon, Setting } from "obsidian"; -import LLMToolboxPlugin from "../main"; +import AIToolboxPlugin from "../main"; import { WorkflowConfig, WorkflowOutputType, @@ -56,7 +56,7 @@ const PROMPT_SOURCE_OPTIONS: Record = { */ export function displayWorkflowsSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, callbacks: WorkflowSettingsCallbacks ): void { // Add workflow button @@ -90,7 +90,7 @@ export function displayWorkflowsSettings( function displayWorkflowSettings( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks ): void { @@ -154,7 +154,7 @@ function displayWorkflowSettings( */ function displayChatWorkflowSettings( contentContainer: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -295,7 +295,7 @@ function displayChatWorkflowSettings( function displayWorkflowProviderSelection( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig ): void { const providers = plugin.settings.providers; @@ -344,7 +344,7 @@ function displayWorkflowProviderSelection( */ function displayTranscriptionProviderSelection( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig ): void { const providers = plugin.settings.providers; @@ -392,7 +392,7 @@ function displayTranscriptionProviderSelection( */ function displayTranscriptionWorkflowSettings( contentContainer: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -553,7 +553,7 @@ function displayTranscriptionWorkflowSettings( */ function displayContextSection( contentContainer: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -638,7 +638,7 @@ function displayContextSection( */ function displayWorkflowContextSection( containerEl: HTMLElement, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, workflow: WorkflowConfig, callbacks: WorkflowSettingsCallbacks, isExpanded: () => boolean @@ -774,7 +774,7 @@ function renderTokenReference( function displayAvailableTokensSection( containerEl: HTMLElement, workflow: WorkflowConfig, - plugin: LLMToolboxPlugin, + plugin: AIToolboxPlugin, callbacks: WorkflowSettingsCallbacks ): void { const contexts = workflow.contexts ?? []; From 511ed78690cfc9c778625702057e46069c2f6202 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Sun, 11 Jan 2026 19:04:22 -0600 Subject: [PATCH 4/7] clean up picker --- AGENTS.md | 31 +++ src/components/path-picker.ts | 373 +++++++++++----------------------- src/settings/types.ts | 2 - src/settings/workflows.ts | 36 ++-- 4 files changed, 162 insertions(+), 280 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b72af5f..6212a38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,37 @@ npm run build - Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets. - After the initial release, follow the process to add/update your plugin in the community catalog as required. +### Release preparation checklist + +Before creating a GitHub release, follow these steps to update version files: + +#### 1. Update manifest.json +- Bump the `version` field in `manifest.json` following Semantic Versioning (x.y.z format) +- Ensure the `minAppVersion` is accurate for any new Obsidian APIs used + +#### 2. Update versions.json +The `versions.json` file maps plugin versions to minimum Obsidian app versions. Update it based on the release type: + +**For patch releases (x.y.Z):** +- Add the new version entry: `"x.y.z": "minimum-app-version"` +- Keep all existing entries unchanged + +**For minor releases (x.Y.0):** +- Add the new version entry: `"x.y.0": "minimum-app-version"` +- For the previous minor version (x.Y-1.*), keep only the latest patch version +- Remove all other patch versions for that minor version +- Example: If releasing 1.2.0, keep only 1.1.3 (remove 1.1.0, 1.1.1, 1.1.2) + +**For major releases (X.0.0):** +- Add the new version entry: `"x.0.0": "minimum-app-version"` +- For the previous major version (X-1.*.*), keep the latest two minor versions, each with their latest patch version +- Remove all other minor and patch versions for that major version +- Example: If releasing 2.0.0, keep only 1.2.3 and 1.3.2 (remove 1.0.0, 1.1.0, 1.2.1, 1.2.2, 1.3.0, 1.3.1, etc.) + +#### 3. Run linting +- Run `eslint ./src/` to check for code quality issues +- Fix any errors or warnings before creating the release + ## Security, privacy, and compliance Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular: diff --git a/src/components/path-picker.ts b/src/components/path-picker.ts index 715adc4..d63bc9e 100644 --- a/src/components/path-picker.ts +++ b/src/components/path-picker.ts @@ -1,328 +1,189 @@ -import { App, Setting, AbstractInputSuggest, TFolder, TFile } from "obsidian"; +import { App, Setting, AbstractInputSuggest, TFolder, TFile, TAbstractFile } from "obsidian"; /** - * Internal folder suggestion component. + * Type of path selection. */ -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) - ); - } +export type PathSelectionType = "folder" | "file"; - 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; - } +/** + * Represents a path item (folder or file) in the unified picker. + */ +interface PathItem { + type: PathSelectionType; + path: string; + displayPath: string; + item: TAbstractFile; } /** - * Internal file suggestion component that filters by folder. + * Unified path suggestion component that shows both folders and files. */ -class FileSuggestInternal extends AbstractInputSuggest { +class UnifiedPathSuggest extends AbstractInputSuggest { private textInputEl: HTMLInputElement; - private folderPath: string = ""; - - constructor(app: App, inputEl: HTMLInputElement) { + private includeFiles: boolean; + private onPathSelected: (path: string, type: PathSelectionType) => void; + + constructor( + app: App, + inputEl: HTMLInputElement, + includeFiles: boolean, + onPathSelected: (path: string, type: PathSelectionType) => void + ) { super(app, inputEl); this.textInputEl = inputEl; + this.includeFiles = includeFiles; + this.onPathSelected = onPathSelected; } - setFolderPath(folderPath: string): void { - this.folderPath = folderPath; - } - - getSuggestions(inputStr: string): TFile[] { + getSuggestions(inputStr: string): PathItem[] { const inputLower = inputStr.toLowerCase().trim(); - const files = this.getFilesInFolder(); + const items = this.getAllItems(); if (inputLower === "") { - return files; + return items; } - return files.filter(file => { - const displayPath = this.getDisplayPath(file); - return displayPath.toLowerCase().includes(inputLower); - }); + return items.filter(item => + item.path.toLowerCase().includes(inputLower) + ); } - renderSuggestion(file: TFile, el: HTMLElement): void { - const displayPath = this.getDisplayPath(file); - el.createEl("div", { text: displayPath, cls: "file-suggest-item" }); + renderSuggestion(item: PathItem, el: HTMLElement): void { + el.addClass("path-picker-suggestion"); + el.createEl("span", { + text: item.displayPath, + cls: `path-picker-${item.type}` + }); } - 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 } - })); + selectSuggestion(item: PathItem): void { + this.textInputEl.value = item.path; + this.textInputEl.dataset.selectionType = item.type; + this.onPathSelected(item.path, item.type); this.close(); } - private getDisplayPath(file: TFile): string { - if (this.folderPath && file.path.startsWith(this.folderPath + "/")) { - return file.path.substring(this.folderPath.length + 1); + private getAllItems(): PathItem[] { + const items: PathItem[] = []; + + // Add folders + const rootFolder = this.app.vault.getRoot(); + if (rootFolder) { + items.push({ + type: "folder", + path: "", + displayPath: "/ (root)", + item: rootFolder + }); } - 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)); + for (const folder of this.app.vault.getAllFolders()) { + items.push({ + type: "folder", + path: folder.path, + displayPath: folder.path, + item: folder + }); } - const normalizedFolder = this.folderPath.replace(/\/$/, ""); - const folder = this.app.vault.getAbstractFileByPath(normalizedFolder); - - if (!(folder instanceof TFolder)) { - return []; + // Add markdown files if enabled + if (this.includeFiles) { + for (const file of this.app.vault.getMarkdownFiles()) { + // Display path without .md extension + const displayPath = file.path.replace(/\.md$/, ""); + items.push({ + type: "file", + path: file.path, + displayPath: displayPath, + item: file + }); + } } - const filesInFolder = allFiles.filter(file => { - if (normalizedFolder === "") { - return true; + // Sort: folders first (root at top), then files, alphabetically within each group + items.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1; } - return file.path.startsWith(normalizedFolder + "/"); + // Root folder always first + if (a.path === "") return -1; + if (b.path === "") return 1; + return a.path.localeCompare(b.path); }); - filesInFolder.sort((a, b) => a.path.localeCompare(b.path)); - return filesInFolder; + return items; } } /** - * Mode for the path picker component. - */ -export type PathPickerMode = "folder-only" | "folder-file"; - -/** - * Base options for the path picker. + * Options for the unified path picker. */ -interface PathPickerBaseOptions { +export interface PathPickerOptions { containerEl: HTMLElement; app: App; name: string; description: string; - folderPlaceholder?: string; - initialFolderPath?: string; - onFolderChange?: (folderPath: string) => void; + placeholder?: string; + initialPath?: string; + /** If true, shows both folders and files; if false, only folders */ + allowFiles?: boolean; + /** Called when the user selects a path */ + onChange?: (path: string, type: PathSelectionType) => void; } /** - * Options for folder-only mode. + * Result from creating a path picker. */ -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 { +export interface PathPickerResult { setting: Setting; - folderInputEl: HTMLInputElement; - fileInputEl: HTMLInputElement; - fileSuggest: FileSuggestInternal; + inputEl: HTMLInputElement; } -export type PathPickerResult = - T extends FolderOnlyPickerOptions ? FolderOnlyPickerResult : FolderFilePickerResult; - /** - * Create a unified path picker component. + * Create a unified path picker component with a single search input. * - * Supports two modes: - * - "folder-only": Shows only a folder selection input - * - "folder-file": Shows folder and file selection inputs side-by-side + * Shows folders and optionally files in one searchable list. + * Automatically determines selection type based on what the user picks. */ -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 { +export function createPathPicker(options: PathPickerOptions): PathPickerResult { const { containerEl, app, name, description, - folderPlaceholder = "Select folder...", - initialFolderPath = "", - onFolderChange + placeholder = "Search...", + initialPath = "", + allowFiles = false, + onChange } = options; - let folderInputEl: HTMLInputElement; + let inputEl: 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"); + .addSearch(search => { + inputEl = search.inputEl; + search + .setPlaceholder(placeholder) + .setValue(initialPath); + + new UnifiedPathSuggest( + app, + search.inputEl, + allowFiles, + (path, type) => { + onChange?.(path, type); + } + ); + + search.inputEl.addClass("path-picker-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<{ path: string }>) => { - 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! + inputEl: inputEl! }; } - diff --git a/src/settings/types.ts b/src/settings/types.ts index 77fcdc1..51670bd 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -114,7 +114,6 @@ export interface WorkflowConfig { type: WorkflowType; promptText: string; promptSourceType: PromptSourceType; - promptFolderPath: string; promptFilePath: string; provider: ProviderModelSelection | null; outputType: WorkflowOutputType; @@ -139,7 +138,6 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { type: 'chat', promptText: '', promptSourceType: 'inline', - promptFolderPath: '', promptFilePath: '', provider: null, outputType: 'popup', diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index 1903cd7..6fc8b94 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -204,21 +204,15 @@ function displayChatWorkflowSettings( // 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; + description: 'Search for a file to use as the prompt template', + placeholder: 'Search for file...', + initialPath: workflow.promptFilePath ?? '', + allowFiles: true, + onChange: async (path: string) => { + workflow.promptFilePath = path; await plugin.saveSettings(); } }); @@ -277,15 +271,14 @@ function displayChatWorkflowSettings( // 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 notes will be created (leave empty to use default)', - folderPlaceholder: 'Default folder', - initialFolderPath: workflow.outputFolder || '', - onFolderChange: async (folderPath: string) => { - workflow.outputFolder = folderPath; + placeholder: 'Search for folder...', + initialPath: workflow.outputFolder || '', + onChange: async (path: string) => { + workflow.outputFolder = path; await plugin.saveSettings(); } }); @@ -532,15 +525,14 @@ function displayTranscriptionWorkflowSettings( // 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; + placeholder: 'Search for folder...', + initialPath: workflow.outputFolder || '', + onChange: async (path: string) => { + workflow.outputFolder = path; await plugin.saveSettings(); } }); From 07f900771cd2edf06760e87be91c4a6efb54f329 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Sun, 11 Jan 2026 20:34:12 -0600 Subject: [PATCH 5/7] add log pain --- .../output/at-cursor-output-handler.ts | 9 +- .../output/new-note-output-handler.ts | 5 +- src/handlers/output/popup-output-handler.ts | 5 +- src/logging/index.ts | 10 ++ src/logging/log-manager.ts | 124 ++++++++++++++++++ src/logging/log-pane-view.ts | 118 +++++++++++++++++ src/logging/types.ts | 52 ++++++++ src/main.ts | 49 ++++++- src/processing/video-processor.ts | 20 ++- src/processing/workflow-executor.ts | 53 ++++---- styles.css | 96 ++++++++++++++ 11 files changed, 494 insertions(+), 47 deletions(-) create mode 100644 src/logging/index.ts create mode 100644 src/logging/log-manager.ts create mode 100644 src/logging/log-pane-view.ts create mode 100644 src/logging/types.ts diff --git a/src/handlers/output/at-cursor-output-handler.ts b/src/handlers/output/at-cursor-output-handler.ts index 6d6347f..fbc8b88 100644 --- a/src/handlers/output/at-cursor-output-handler.ts +++ b/src/handlers/output/at-cursor-output-handler.ts @@ -1,5 +1,6 @@ -import { MarkdownView, Notice } from 'obsidian'; +import { MarkdownView } from 'obsidian'; import { OutputHandler, OutputContext } from './types'; +import { logNotice, LogCategory } from '../../logging'; /** * Output handler that inserts the AI response at the current cursor position @@ -9,7 +10,7 @@ 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.'); + logNotice(LogCategory.WORKFLOW, 'No active editor. Please open a note first.'); return; } @@ -20,12 +21,12 @@ export class AtCursorOutputHandler implements OutputHandler { const from = editor.getCursor('from'); editor.replaceSelection(responseText); this.moveCursorToEndOfText(editor, from, responseText); - new Notice('Response replaced selection'); + logNotice(LogCategory.WORKFLOW, 'Response replaced selection'); } else { const cursor = editor.getCursor(); editor.replaceRange(responseText, cursor); this.moveCursorToEndOfText(editor, cursor, responseText); - new Notice('Response inserted at cursor'); + logNotice(LogCategory.WORKFLOW, 'Response inserted at cursor'); } } diff --git a/src/handlers/output/new-note-output-handler.ts b/src/handlers/output/new-note-output-handler.ts index 6eb33a5..26dde0d 100644 --- a/src/handlers/output/new-note-output-handler.ts +++ b/src/handlers/output/new-note-output-handler.ts @@ -1,6 +1,7 @@ -import { Notice, TFile } from 'obsidian'; +import { TFile } from 'obsidian'; import { OutputHandler, OutputContext } from './types'; import { generateFilenameTimestamp } from '../../utils/date-utils'; +import { logNotice, LogCategory } from '../../logging'; /** * Output handler that creates a new note containing the AI response. @@ -57,7 +58,7 @@ export class NewNoteOutputHandler implements OutputHandler { await leaf.openFile(file); } - new Notice(`Created note: ${file.name}`); + logNotice(LogCategory.WORKFLOW, `Created note: ${file.name}`); } } diff --git a/src/handlers/output/popup-output-handler.ts b/src/handlers/output/popup-output-handler.ts index 6dfeb3b..ca69a89 100644 --- a/src/handlers/output/popup-output-handler.ts +++ b/src/handlers/output/popup-output-handler.ts @@ -1,5 +1,6 @@ -import { App, Modal, Notice } from 'obsidian'; +import { App, Modal } from 'obsidian'; import { OutputHandler, OutputContext } from './types'; +import { logNotice, LogCategory } from '../../logging'; /** * Modal to display the AI response from a workflow execution. @@ -30,7 +31,7 @@ export class WorkflowResultModal extends Modal { const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); copyButton.addEventListener('click', () => { void navigator.clipboard.writeText(this.response).then(() => { - new Notice('Response copied to clipboard'); + logNotice(LogCategory.WORKFLOW, 'Response copied to clipboard'); }); }); diff --git a/src/logging/index.ts b/src/logging/index.ts new file mode 100644 index 0000000..d9eac2b --- /dev/null +++ b/src/logging/index.ts @@ -0,0 +1,10 @@ +// Re-export types +export { LogLevel, LogCategory } from './types'; +export type { LogEntry, LogListener, LogUnsubscribe, LogCategoryType } from './types'; + +// Re-export log manager and convenience functions +export { logManager, logDebug, logInfo, logWarn, logError, logNotice } from './log-manager'; + +// Re-export log pane view +export { VIEW_TYPE_LOG, LogPaneView } from './log-pane-view'; + diff --git a/src/logging/log-manager.ts b/src/logging/log-manager.ts new file mode 100644 index 0000000..eeb96dc --- /dev/null +++ b/src/logging/log-manager.ts @@ -0,0 +1,124 @@ +import { Notice } from 'obsidian'; +import { LogEntry, LogLevel, LogListener, LogUnsubscribe, LogCategoryType } from './types'; + +const MAX_LOGS = 500; + +/** + * Centralized log manager with subscription system for real-time log updates. + */ +class LogManager { + private logs: LogEntry[] = []; + private listeners: LogListener[] = []; + + /** + * Add a log entry with the specified level, category, and message. + */ + log(level: LogLevel, category: string, message: string, details?: unknown): void { + const entry: LogEntry = { + timestamp: new Date(), + level, + category, + message, + details + }; + + this.logs.push(entry); + if (this.logs.length > MAX_LOGS) { + this.logs = this.logs.slice(-MAX_LOGS); + } + + // Console output based on level + const formattedMsg = `[AI Toolbox] [${category}] ${message}`; + switch (level) { + case LogLevel.ERROR: + console.error(formattedMsg, details !== undefined ? details : ''); + break; + case LogLevel.WARN: + console.warn(formattedMsg, details !== undefined ? details : ''); + break; + case LogLevel.DEBUG: + console.debug(formattedMsg, details !== undefined ? details : ''); + break; + default: + console.log(formattedMsg, details !== undefined ? details : ''); + } + + this.notifyListeners(); + } + + /** + * Subscribe to log updates. Returns an unsubscribe function. + */ + subscribe(callback: LogListener): LogUnsubscribe { + this.listeners.push(callback); + callback([...this.logs]); // Initial call with current logs + return () => { + this.listeners = this.listeners.filter(l => l !== callback); + }; + } + + private notifyListeners(): void { + const logsCopy = [...this.logs]; + this.listeners.forEach(cb => cb(logsCopy)); + } + + /** + * Get a copy of all current logs. + */ + getLogs(): LogEntry[] { + return [...this.logs]; + } + + /** + * Clear all logs. + */ + clear(): void { + this.logs = []; + this.notifyListeners(); + } + + /** + * Get the count of logs at each level. + */ + getLogCounts(): Record { + const counts = { + [LogLevel.DEBUG]: 0, + [LogLevel.INFO]: 0, + [LogLevel.WARN]: 0, + [LogLevel.ERROR]: 0 + }; + for (const log of this.logs) { + counts[log.level]++; + } + return counts; + } +} + +/** Singleton log manager instance */ +export const logManager = new LogManager(); + +// Convenience functions for logging at specific levels +export function logDebug(category: LogCategoryType | string, message: string, details?: unknown): void { + logManager.log(LogLevel.DEBUG, category, message, details); +} + +export function logInfo(category: LogCategoryType | string, message: string, details?: unknown): void { + logManager.log(LogLevel.INFO, category, message, details); +} + +export function logWarn(category: LogCategoryType | string, message: string, details?: unknown): void { + logManager.log(LogLevel.WARN, category, message, details); +} + +export function logError(category: LogCategoryType | string, message: string, details?: unknown): void { + logManager.log(LogLevel.ERROR, category, message, details); +} + +/** + * Log at INFO level and also display an Obsidian Notice to the user. + */ +export function logNotice(category: LogCategoryType | string, message: string, details?: unknown): void { + logManager.log(LogLevel.INFO, category, message, details); + new Notice(message); +} + diff --git a/src/logging/log-pane-view.ts b/src/logging/log-pane-view.ts new file mode 100644 index 0000000..ae612f0 --- /dev/null +++ b/src/logging/log-pane-view.ts @@ -0,0 +1,118 @@ +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { logManager } from './log-manager'; +import { LogEntry, LogLevel, LogUnsubscribe } from './types'; +import type AIToolboxPlugin from '../main'; + +export const VIEW_TYPE_LOG = 'ai-toolbox-log'; + +/** + * Log pane view for displaying AI Toolbox plugin logs in a dedicated Obsidian panel. + */ +export class LogPaneView extends ItemView { + plugin: AIToolboxPlugin; + private logContainer: HTMLElement; + private unsubscribe: LogUnsubscribe | null = null; + private autoScroll = true; + private filterLevel: LogLevel = LogLevel.DEBUG; + + constructor(leaf: WorkspaceLeaf, plugin: AIToolboxPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_LOG; + } + + getDisplayText(): string { + return 'AI Toolbox Log'; + } + + getIcon(): string { + return 'scroll-text'; + } + + async onOpen(): Promise { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass('ai-toolbox-log-view'); + + this.buildControls(container); + this.logContainer = container.createDiv({ cls: 'log-container' }); + + this.unsubscribe = logManager.subscribe(logs => this.renderLogs(logs)); + } + + private buildControls(container: HTMLElement): void { + const controls = container.createDiv({ cls: 'log-controls' }); + + // Filter dropdown + const filterLabel = controls.createEl('label', { cls: 'log-control-item' }); + filterLabel.createSpan({ text: 'Level: ' }); + const select = filterLabel.createEl('select', { cls: 'dropdown' }); + const levels = ['DEBUG', 'INFO', 'WARN', 'ERROR']; + levels.forEach((level, i) => { + const option = select.createEl('option', { value: String(i), text: level }); + if (i === this.filterLevel) option.selected = true; + }); + select.addEventListener('change', () => { + this.filterLevel = parseInt(select.value); + this.renderLogs(logManager.getLogs()); + }); + + // Auto-scroll toggle + const autoScrollLabel = controls.createEl('label', { cls: 'log-control-item' }); + const checkbox = autoScrollLabel.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.autoScroll; + checkbox.addEventListener('change', () => { + this.autoScroll = checkbox.checked; + }); + autoScrollLabel.appendText(' Auto-scroll'); + + // Spacer + controls.createDiv({ cls: 'log-controls-spacer' }); + + // Clear button + const clearBtn = controls.createEl('button', { text: 'Clear', cls: 'mod-warning' }); + clearBtn.addEventListener('click', () => logManager.clear()); + + // Copy button + const copyBtn = controls.createEl('button', { text: 'Copy All' }); + copyBtn.addEventListener('click', () => this.copyLogs()); + } + + private renderLogs(logs: LogEntry[]): void { + this.logContainer.empty(); + const filtered = logs.filter(l => l.level >= this.filterLevel); + + for (const entry of filtered) { + const levelName = LogLevel[entry.level].toLowerCase(); + const line = this.logContainer.createDiv({ cls: `log-entry log-${levelName}` }); + + const time = entry.timestamp.toLocaleTimeString(); + line.createSpan({ cls: 'log-time', text: `[${time}]` }); + line.createSpan({ cls: 'log-level', text: ` [${LogLevel[entry.level]}]` }); + line.createSpan({ cls: 'log-category', text: ` [${entry.category}]` }); + line.createSpan({ cls: 'log-message', text: ` ${entry.message}` }); + } + + if (this.autoScroll) { + this.logContainer.scrollTop = this.logContainer.scrollHeight; + } + } + + private copyLogs(): void { + const text = logManager.getLogs() + .map(e => `[${e.timestamp.toISOString()}] [${LogLevel[e.level]}] [${e.category}] ${e.message}`) + .join('\n'); + navigator.clipboard.writeText(text); + } + + async onClose(): Promise { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + } +} + diff --git a/src/logging/types.ts b/src/logging/types.ts new file mode 100644 index 0000000..776105a --- /dev/null +++ b/src/logging/types.ts @@ -0,0 +1,52 @@ +/** + * Log level enumeration for categorizing log messages. + */ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3 +} + +/** + * Represents a single log entry with timestamp, level, category, and message. + */ +export interface LogEntry { + /** When the log entry was created */ + timestamp: Date; + /** Severity level of the log */ + level: LogLevel; + /** Category/module that generated the log (e.g., "workflow", "provider", "transcription") */ + category: string; + /** The log message */ + message: string; + /** Optional additional details (objects, errors, etc.) */ + details?: unknown; +} + +/** + * Callback type for log subscription listeners. + */ +export type LogListener = (logs: LogEntry[]) => void; + +/** + * Unsubscribe function returned when subscribing to log updates. + */ +export type LogUnsubscribe = () => void; + +/** + * Log category constants for consistent categorization. + */ +export const LogCategory = { + PLUGIN: 'plugin', + WORKFLOW: 'workflow', + PROVIDER: 'provider', + TRANSCRIPTION: 'transcription', + INPUT: 'input', + OUTPUT: 'output', + VIDEO: 'video', + AUDIO: 'audio' +} as const; + +export type LogCategoryType = typeof LogCategory[keyof typeof LogCategory]; + diff --git a/src/main.ts b/src/main.ts index 4e3b00c..b9097cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ -import { Notice, Plugin } from 'obsidian'; +import { Plugin } from 'obsidian'; import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; import { WorkflowSuggesterModal } from "./components/workflow-suggester"; import { executeWorkflow } from "./processing/workflow-executor"; +import { VIEW_TYPE_LOG, LogPaneView, logInfo, logNotice, LogCategory } from "./logging"; export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; @@ -9,6 +10,14 @@ export default class AIToolboxPlugin extends Plugin { async onload() { await this.loadSettings(); + // Register log view + this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this)); + + // Add ribbon icon for log view + this.addRibbonIcon('scroll-text', 'Show AI Toolbox Log', () => { + void this.activateLogView(); + }); + // Add command to execute custom workflows this.addCommand({ id: 'execute-workflow', @@ -16,8 +25,44 @@ export default class AIToolboxPlugin extends Plugin { callback: () => this.showWorkflowSuggester() }); + // Add command to show log view + this.addCommand({ + id: 'show-log', + name: 'Show log', + callback: () => { + void this.activateLogView(); + } + }); + // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new AIToolboxSettingTab(this.app, this)); + + logInfo(LogCategory.PLUGIN, 'AI Toolbox plugin loaded'); + } + + onunload() { + // Clean up log view leaves + this.app.workspace.detachLeavesOfType(VIEW_TYPE_LOG); + } + + /** + * Activate and reveal the log view in the right sidebar. + */ + private async activateLogView(): Promise { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(VIEW_TYPE_LOG)[0]; + + if (!leaf) { + const rightLeaf = workspace.getRightLeaf(false); + if (rightLeaf) { + leaf = rightLeaf; + await leaf.setViewState({ type: VIEW_TYPE_LOG, active: true }); + } + } + + if (leaf) { + workspace.revealLeaf(leaf); + } } /** @@ -27,7 +72,7 @@ export default class AIToolboxPlugin extends Plugin { const availableWorkflows = this.settings.workflows.filter(w => w.showInCommand); if (availableWorkflows.length === 0) { - new Notice('No workflows available. You can configure workflows from the workflows tab in settings.'); + logNotice(LogCategory.WORKFLOW, 'No workflows available. You can configure workflows from the workflows tab in settings.'); return; } diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 7e3e6f7..cd9ed24 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -3,9 +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'; +import { logNotice, LogCategory } from '../logging'; /** * Configuration for video processing operations. @@ -219,18 +219,18 @@ function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorC export async function extractAudioFromUrl(url: string, settings: AIToolboxSettings): Promise { try { if (!url || !url.trim()) { - new Notice('URL is empty'); + logNotice(LogCategory.TRANSCRIPTION, 'URL is empty'); return null; } const trimmedUrl = url.trim(); if (!videoPlatformRegistry.isValidVideoUrl(trimmedUrl)) { - new Notice('The provided text is not a valid video URL'); + logNotice(LogCategory.TRANSCRIPTION, 'The provided text is not a valid video URL'); return null; } - new Notice('Preparing video for transcription...'); + logNotice(LogCategory.TRANSCRIPTION, 'Preparing video for transcription...'); const handler = videoPlatformRegistry.findHandlerForUrl(trimmedUrl); const filenameTemplate = handler @@ -243,7 +243,7 @@ export async function extractAudioFromUrl(url: string, settings: AIToolboxSettin const ytdlpResult = await runYtDlp(trimmedUrl, outputTemplate, config); - new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); + logNotice(LogCategory.TRANSCRIPTION, `Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); return { audioFilePath: ytdlpResult.audioFilePath, @@ -257,9 +257,7 @@ export async function extractAudioFromUrl(url: string, settings: AIToolboxSettin }; } 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}`); + logNotice(LogCategory.TRANSCRIPTION, `Failed to extract audio for transcription: ${error instanceof Error ? error.message : String(error)}`, error); return null; } } @@ -275,16 +273,14 @@ export async function extractAudioFromClipboard(settings: AIToolboxSettings): Pr const clipboardText = await navigator.clipboard.readText(); if (!clipboardText) { - new Notice('Clipboard is empty'); + logNotice(LogCategory.TRANSCRIPTION, '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}`); + logNotice(LogCategory.TRANSCRIPTION, `Failed to read clipboard: ${error instanceof Error ? error.message : String(error)}`, error); return null; } } diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index af5c6f8..0fae04f 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,4 +1,4 @@ -import { App, Notice, TFile } from 'obsidian'; +import { App, TFile } from 'obsidian'; import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; import { videoPlatformRegistry } from './video-platforms'; @@ -30,6 +30,7 @@ import { hasContextTokens, formatTranscriptionWithTimestamps } from './workflow-chaining'; +import { logInfo, logDebug, logWarn, logNotice, LogCategory } from '../logging'; /** * Create an output handler based on the workflow's output type. @@ -101,21 +102,20 @@ async function getPromptText(app: App, workflow: WorkflowConfig): Promise w.id === depId); - new Notice(`Circular dependency detected: "${depWorkflow?.name ?? depId}" is already being executed.`); + logNotice(LogCategory.WORKFLOW, `Circular dependency detected: "${depWorkflow?.name ?? depId}" is already being executed.`); return false; } const depWorkflow = settings.workflows.find(w => w.id === depId); if (!depWorkflow) { - new Notice(`Dependency workflow not found: ${depId}`); + logNotice(LogCategory.WORKFLOW, `Dependency workflow not found: ${depId}`); return false; } @@ -168,11 +168,11 @@ async function executeDependencies( } // Execute the dependency workflow - new Notice(`Executing dependency: ${depWorkflow.name}...`); + logNotice(LogCategory.WORKFLOW, `Executing dependency: ${depWorkflow.name}...`); const result = await executeWorkflowInternal(app, settings, depWorkflow, results); if (!result.success) { - new Notice(`Dependency workflow "${depWorkflow.name}" failed: ${result.error}`); + logNotice(LogCategory.WORKFLOW, `Dependency workflow "${depWorkflow.name}" failed: ${result.error}`); return false; } @@ -197,11 +197,13 @@ export async function executeWorkflow( settings: AIToolboxSettings, workflow: WorkflowConfig ): Promise { + logInfo(LogCategory.WORKFLOW, `Starting workflow: ${workflow.name}`, { type: workflow.type, id: workflow.id }); + // Check for circular dependencies before starting if (hasWorkflowDependencies(workflow)) { const cycle = detectCircularDependency(workflow, settings); if (cycle.length > 0) { - new Notice(`Circular dependency detected: ${cycle.join(' → ')}`); + logNotice(LogCategory.WORKFLOW, `Circular dependency detected: ${cycle.join(' → ')}`); return; } @@ -266,7 +268,7 @@ async function executeChatWorkflow( ): 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.`); + logNotice(LogCategory.WORKFLOW, `Workflow "${workflow.name}" has no provider configured. Please configure a provider in settings.`); return; } @@ -289,31 +291,33 @@ async function executeChatWorkflow( // Validate workflow has text if (!promptText.trim()) { - new Notice(`Workflow "${workflow.name}" has no prompt text. Please add prompt text in settings.`); + logNotice(LogCategory.WORKFLOW, `Workflow "${workflow.name}" has no prompt text. Please add prompt text 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.`); + logNotice(LogCategory.WORKFLOW, `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 workflow "${workflow.name}" does not support chat. Please select a chat-capable model.`); + logNotice(LogCategory.WORKFLOW, `The provider for workflow "${workflow.name}" does not support chat. Please select a chat-capable model.`); return; } try { - new Notice(`Executing workflow: ${workflow.name}...`); + logNotice(LogCategory.WORKFLOW, `Executing workflow: ${workflow.name}...`); + logDebug(LogCategory.WORKFLOW, `Sending chat request to provider`); const messages: ChatMessage[] = [ { role: 'user', content: promptText } ]; const result = await provider.sendChat(messages); + logInfo(LogCategory.WORKFLOW, `Chat workflow completed: ${workflow.name}`); const outputType = workflow.outputType || 'popup'; const handler = createOutputHandler(outputType); @@ -326,9 +330,7 @@ async function executeChatWorkflow( await handler.handleOutput(result.content, context); } catch (error) { - console.error('Workflow execution error:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - new Notice(`Failed to execute workflow: ${errorMessage}`); + logNotice(LogCategory.WORKFLOW, `Workflow execution error: ${workflow.name}`, error); } } @@ -410,18 +412,18 @@ async function executeTranscriptionWorkflow( _dependencyResults?: WorkflowResultsMap ): Promise { if (!workflow.provider) { - new Notice(`Workflow "${workflow.name}" has no provider configured. Please configure a provider in settings.`); + logNotice(LogCategory.TRANSCRIPTION, `Workflow "${workflow.name}" has no provider configured. Please configure a provider in settings.`); return; } const provider = createWorkflowProvider(settings, workflow); if (!provider) { - new Notice(`Could not find the configured provider for workflow "${workflow.name}". Please check your settings.`); + logNotice(LogCategory.TRANSCRIPTION, `Could not find the configured provider for workflow "${workflow.name}". Please check your settings.`); return; } if (!provider.supportsTranscription()) { - new Notice(`The provider for workflow "${workflow.name}" does not support transcription. Please select a transcription-capable model.`); + logNotice(LogCategory.TRANSCRIPTION, `The provider for workflow "${workflow.name}" does not support transcription. Please select a transcription-capable model.`); return; } @@ -435,7 +437,8 @@ async function executeTranscriptionWorkflow( return; } - new Notice(`Transcribing audio...`); + logNotice(LogCategory.TRANSCRIPTION, `Transcribing audio...`); + logDebug(LogCategory.TRANSCRIPTION, `Transcribing audio file: ${inputResult.audioFilePath}`); const transcriptionOptions: TranscriptionOptions = { timestampGranularity: workflow.timestampGranularity ?? 'disabled', @@ -443,6 +446,8 @@ async function executeTranscriptionWorkflow( }; const transcriptionResult = await provider.transcribeAudio(inputResult.audioFilePath, transcriptionOptions); + logInfo(LogCategory.TRANSCRIPTION, `Transcription completed: ${workflow.name}`); + // Use timestamped version for display output if available, otherwise plain text const formattedText = transcriptionResult.chunks.length > 0 ? formatTranscriptionWithTimestamps(transcriptionResult.chunks) @@ -456,9 +461,7 @@ async function executeTranscriptionWorkflow( await handler.handleOutput(formattedText, context); } 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}`); + logNotice(LogCategory.TRANSCRIPTION, `Transcription workflow error: ${workflow.name}`, error); } } diff --git a/styles.css b/styles.css index 8912930..d1d0cc8 100644 --- a/styles.css +++ b/styles.css @@ -635,3 +635,99 @@ button.mod-success:hover { .additional-settings-heading .setting-item-name { font-size: 1.17em; } + +/* ==================== Log View Styles ==================== */ + +.ai-toolbox-log-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.log-controls { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + flex-wrap: wrap; + flex-shrink: 0; +} + +.log-control-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; +} + +.log-controls-spacer { + flex: 1; +} + +.log-container { + flex: 1; + overflow-y: auto; + font-family: var(--font-monospace); + font-size: 12px; + padding: 8px 12px; + user-select: text; + -webkit-user-select: text; +} + +.log-entry { + padding: 3px 0; + border-bottom: 1px solid var(--background-modifier-border-hover); + line-height: 1.4; + word-break: break-word; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-time { + color: var(--text-muted); +} + +.log-level { + font-weight: 600; +} + +.log-category { + color: var(--text-accent); +} + +.log-message { + color: var(--text-normal); +} + +/* Log level specific styles */ +.log-debug { + opacity: 0.7; +} + +.log-debug .log-level { + color: var(--text-muted); +} + +.log-info .log-level { + color: var(--text-accent); +} + +.log-warn { + background-color: rgba(255, 200, 0, 0.08); +} + +.log-warn .log-level { + color: var(--text-warning); +} + +.log-error { + background-color: rgba(255, 100, 100, 0.08); +} + +.log-error .log-level { + color: var(--text-error); +} From 229e1cec4adc09a9dc3a57b85289cbdac7628f48 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Sun, 11 Jan 2026 21:21:31 -0600 Subject: [PATCH 6/7] actions --- src/main.ts | 2 +- src/processing/action-executor.ts | 299 ++++++++++ src/processing/workflow-chaining.ts | 61 +- src/processing/workflow-executor.ts | 724 ++++++++--------------- src/providers/index.ts | 1 + src/providers/provider-factory.ts | 23 +- src/settings/index.ts | 16 +- src/settings/types.ts | 107 +++- src/settings/workflows.ts | 865 +++++++++------------------- src/tokens/types.ts | 48 +- styles.css | 27 + 11 files changed, 1021 insertions(+), 1152 deletions(-) create mode 100644 src/processing/action-executor.ts diff --git a/src/main.ts b/src/main.ts index b9097cb..b0b7883 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,7 +69,7 @@ export default class AIToolboxPlugin extends Plugin { * Show the workflow suggester modal and execute the selected workflow. */ private showWorkflowSuggester(): void { - const availableWorkflows = this.settings.workflows.filter(w => w.showInCommand); + const availableWorkflows = this.settings.workflows; if (availableWorkflows.length === 0) { logNotice(LogCategory.WORKFLOW, 'No workflows available. You can configure workflows from the workflows tab in settings.'); diff --git a/src/processing/action-executor.ts b/src/processing/action-executor.ts new file mode 100644 index 0000000..b212751 --- /dev/null +++ b/src/processing/action-executor.ts @@ -0,0 +1,299 @@ +import { App, TFile } from 'obsidian'; +import { AIToolboxSettings, ChatAction, TranscriptionAction, WorkflowAction, TranscriptionSourceType } from '../settings'; +import { createActionProvider, ChatMessage, TranscriptionOptions } from '../providers'; +import { + InputHandler, + InputContext, + InputResult, + VaultFileInputHandler, + ClipboardUrlInputHandler, + SelectionUrlInputHandler +} from '../handlers'; +import { + createChatWorkflowTokens, + createTranscriptionWorkflowTokens, + gatherContextValues, + replaceContextTokens, + hasContextTokens +} from './workflow-chaining'; +import { logInfo, logDebug, logNotice, LogCategory } from '../logging'; + +/** + * Result from executing a single action + */ +export interface ActionExecutionResult { + /** The action that was executed */ + actionId: string; + /** The action type */ + actionType: 'chat' | 'transcription'; + /** Whether execution succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Token values produced by this action */ + tokens: Record; + /** Additional metadata (e.g., for transcription note title) */ + metadata?: { + noteTitle?: string; + inputResult?: InputResult; + }; +} + +/** + * Map of action ID to execution results + */ +export type ActionResultsMap = Map; + +/** + * Context for action execution + */ +export interface ActionExecutionContext { + app: App; + settings: AIToolboxSettings; + /** Results from previously executed actions in this workflow */ + previousResults: ActionResultsMap; + /** Results from dependency workflows */ + dependencyResults: Map; + /** The workflow name (for logging) */ + workflowName: string; +} + +/** + * Replace action tokens in text with values from previous action results. + * Tokens are in the format {{actionId.tokenName}} + */ +export function replaceActionTokens( + text: string, + results: ActionResultsMap +): string { + const tokenPattern = /\{\{([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_]+)\}\}/g; + + return text.replace(tokenPattern, (match, actionId: string, tokenName: string) => { + const result = results.get(actionId); + if (!result) { + return match; + } + + const tokenValue = result.tokens[tokenName]; + if (tokenValue === undefined) { + return match; + } + + return tokenValue; + }); +} + +/** + * 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(); + } +} + +/** + * Get the prompt text for a chat action, loading from file if needed. + */ +async function getPromptText(app: App, action: ChatAction): Promise { + const sourceType = action.promptSourceType ?? 'inline'; + + if (sourceType === 'from-file') { + const filePath = action.promptFilePath; + if (!filePath || !filePath.trim()) { + logNotice(LogCategory.WORKFLOW, `Action "${action.name}" has no prompt file configured.`); + return null; + } + + const file = app.vault.getAbstractFileByPath(filePath); + if (!file || !(file instanceof TFile)) { + logNotice(LogCategory.WORKFLOW, `Prompt file "${filePath}" not found for action "${action.name}".`); + return null; + } + + try { + return await app.vault.read(file); + } catch (error) { + logNotice(LogCategory.WORKFLOW, `Failed to read prompt file: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + return action.promptText; +} + +/** + * Execute a chat action and return the result. + */ +export async function executeChatAction( + action: ChatAction, + context: ActionExecutionContext +): Promise { + const baseResult: ActionExecutionResult = { + actionId: action.id, + actionType: 'chat', + success: false, + tokens: {} + }; + + if (!action.provider) { + return { ...baseResult, error: 'No provider configured' }; + } + + const provider = createActionProvider(context.settings, action); + if (!provider) { + return { ...baseResult, error: 'Provider not found' }; + } + + if (!provider.supportsChat()) { + return { ...baseResult, error: 'Provider does not support chat' }; + } + + let promptText = await getPromptText(context.app, action); + if (promptText === null) { + return { ...baseResult, error: 'Failed to load prompt text' }; + } + + // Replace tokens from previous actions + if (context.previousResults.size > 0) { + promptText = replaceActionTokens(promptText, context.previousResults); + } + + // Replace tokens from dependency workflows + if (context.dependencyResults.size > 0) { + promptText = replaceActionTokens(promptText, context.dependencyResults); + } + + // Replace context tokens ({{selection}}, {{clipboard}}, etc.) + if (hasContextTokens(promptText)) { + const contextValues = await gatherContextValues(context.app); + promptText = replaceContextTokens(promptText, contextValues); + } + + if (!promptText.trim()) { + return { ...baseResult, error: 'Empty prompt text' }; + } + + try { + logDebug(LogCategory.WORKFLOW, `Executing chat action: ${action.name}`); + + const messages: ChatMessage[] = [ + { role: 'user', content: promptText } + ]; + + const result = await provider.sendChat(messages); + logInfo(LogCategory.WORKFLOW, `Chat action completed: ${action.name}`); + + return { + ...baseResult, + success: true, + tokens: createChatWorkflowTokens(promptText, result.content) + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { ...baseResult, error: errorMessage }; + } +} + +/** + * Execute a transcription action and return the result. + */ +export async function executeTranscriptionAction( + action: TranscriptionAction, + context: ActionExecutionContext +): Promise { + const baseResult: ActionExecutionResult = { + actionId: action.id, + actionType: 'transcription', + success: false, + tokens: {} + }; + + if (!action.provider) { + return { ...baseResult, error: 'No provider configured' }; + } + + const provider = createActionProvider(context.settings, action); + if (!provider) { + return { ...baseResult, error: 'Provider not found' }; + } + + if (!provider.supportsTranscription()) { + return { ...baseResult, error: 'Provider does not support transcription' }; + } + + const sourceType: TranscriptionSourceType = action.transcriptionContext?.sourceType ?? 'url-from-clipboard'; + const inputHandler = createInputHandler(sourceType); + + // Create a minimal workflow-like object for InputContext compatibility + const inputContext: InputContext = { + app: context.app, + settings: context.settings, + workflow: { + id: action.id, + name: action.name, + actions: [], + outputType: 'popup', + outputFolder: '' + } + }; + + try { + logDebug(LogCategory.TRANSCRIPTION, `Executing transcription action: ${action.name}`); + + const inputResult = await inputHandler.getInput(inputContext); + if (!inputResult) { + return { ...baseResult, error: 'No input provided or cancelled' }; + } + + logNotice(LogCategory.TRANSCRIPTION, `Transcribing audio...`); + + const timestampGranularity = action.timestampGranularity ?? 'disabled'; + const transcriptionOptions: TranscriptionOptions = { + timestampGranularity, + language: action.language || undefined + }; + + const transcriptionResult = await provider.transcribeAudio(inputResult.audioFilePath, transcriptionOptions); + logInfo(LogCategory.TRANSCRIPTION, `Transcription action completed: ${action.name}`); + + const tokenMetadata = { + ...inputResult.metadata, + sourceUrl: inputResult.sourceUrl + }; + + return { + ...baseResult, + success: true, + tokens: createTranscriptionWorkflowTokens(transcriptionResult, tokenMetadata, timestampGranularity), + metadata: { + inputResult + } + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { ...baseResult, error: errorMessage }; + } +} + +/** + * Execute any action type and return the result. + */ +export async function executeAction( + action: WorkflowAction, + context: ActionExecutionContext +): Promise { + if (action.type === 'chat') { + return executeChatAction(action, context); + } else { + return executeTranscriptionAction(action, context); + } +} + diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts index c660f9b..a3de469 100644 --- a/src/processing/workflow-chaining.ts +++ b/src/processing/workflow-chaining.ts @@ -23,59 +23,27 @@ export interface WorkflowExecutionResult { */ export type WorkflowResultsMap = Map; -/** - * Context for tracking workflow dependency execution - */ -export interface DependencyExecutionContext { - /** The Obsidian App instance */ - app: App; - /** Plugin settings */ - settings: AIToolboxSettings; - /** Results from executed dependencies */ - results: WorkflowResultsMap; - /** Workflow IDs currently in the execution stack (for circular detection) */ - executionStack: Set; -} - /** * Detect circular dependencies in a workflow's dependency chain. - * Returns an array of workflow names forming the cycle, or empty if no cycle. + * Currently workflows don't have cross-workflow dependencies (actions chain within a workflow). + * This function is kept for potential future use. */ export function detectCircularDependency( - workflow: WorkflowConfig, - settings: AIToolboxSettings, - visited: Set = new Set(), - path: string[] = [] + _workflow: WorkflowConfig, + _settings: AIToolboxSettings, + _visited: Set = new Set(), + _path: string[] = [] ): string[] { - if (visited.has(workflow.id)) { - // Found a cycle - return the path from the first occurrence - const cycleStart = path.indexOf(workflow.name); - return [...path.slice(cycleStart), workflow.name]; - } - - visited.add(workflow.id); - path.push(workflow.name); - - const workflowContexts = workflow.workflowContexts ?? []; - for (const ctx of workflowContexts) { - const depWorkflow = settings.workflows.find(w => w.id === ctx.workflowId); - if (depWorkflow) { - const cycle = detectCircularDependency(depWorkflow, settings, visited, path); - if (cycle.length > 0) { - return cycle; - } - } - } - - path.pop(); + // No cross-workflow dependencies in current design return []; } /** - * Check if a workflow has any workflow dependencies configured + * Check if a workflow has any workflow dependencies configured. + * Currently workflows don't have cross-workflow dependencies. */ -export function hasWorkflowDependencies(workflow: WorkflowConfig): boolean { - return (workflow.workflowContexts?.length ?? 0) > 0; +export function hasWorkflowDependencies(_workflow: WorkflowConfig): boolean { + return false; } /** @@ -183,10 +151,11 @@ export function createTranscriptionWorkflowTokens( } /** - * Get ordered list of dependency workflow IDs for a workflow + * Get ordered list of dependency workflow IDs for a workflow. + * Currently workflows don't have cross-workflow dependencies. */ -export function getDependencyWorkflowIds(workflow: WorkflowConfig): string[] { - return (workflow.workflowContexts ?? []).map(ctx => ctx.workflowId); +export function getDependencyWorkflowIds(_workflow: WorkflowConfig): string[] { + return []; } /** diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index 0fae04f..a5878dd 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,6 +1,5 @@ -import { App, TFile } from 'obsidian'; -import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; -import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; +import { App } from 'obsidian'; +import { WorkflowConfig, AIToolboxSettings } from '../settings'; import { videoPlatformRegistry } from './video-platforms'; import { generateFilenameTimestamp } from '../utils/date-utils'; import { @@ -9,119 +8,81 @@ import { NewNoteOutputHandler, AtCursorOutputHandler, PopupOutputHandler, - InputHandler, - InputContext, - InputResult, - VaultFileInputHandler, - ClipboardUrlInputHandler, - SelectionUrlInputHandler + InputResult } from '../handlers'; import { WorkflowExecutionResult, WorkflowResultsMap, detectCircularDependency, hasWorkflowDependencies, - replaceWorkflowTokens, - createChatWorkflowTokens, - createTranscriptionWorkflowTokens, - getDependencyWorkflowIds, - gatherContextValues, - replaceContextTokens, - hasContextTokens, - formatTranscriptionWithTimestamps + getDependencyWorkflowIds } from './workflow-chaining'; -import { logInfo, logDebug, logWarn, logNotice, LogCategory } from '../logging'; +import { + ActionExecutionResult, + ActionResultsMap, + ActionExecutionContext, + executeAction +} from './action-executor'; +import { logInfo, logNotice, LogCategory } from '../logging'; /** * Create an output handler based on the workflow's output type. */ 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(); - } -} - -/** - * 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(); - } + switch (outputType) { + case 'new-note': + return new NewNoteOutputHandler(); + case 'at-cursor': + return new AtCursorOutputHandler(); + case 'popup': + default: + return new PopupOutputHandler(); + } } /** - * Generate a note title for transcription output based on input source. - * - TikTok: "TikTok by - " - * - YouTube: "