diff --git a/AGENTS.md b/AGENTS.md index b72af5f..9a9f753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,44 @@ 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 and fix issues +- Run `npx eslint ./src/` to check for code quality issues +- **Fix all errors and warnings** before creating the release +- Common issues to watch for: + - Unused imports (remove them) + - Floating promises (add `await` or `void` operator) + - Sentence case violations in UI text (use lowercase after first word) + - Unnecessary type assertions (remove redundant `as` casts) + - Misused promises in callbacks (use `void` for fire-and-forget async calls) + - Console statements (only `console.warn`, `console.error`, and `console.debug` are allowed) + ## Security, privacy, and compliance Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular: @@ -175,6 +213,7 @@ Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particula - Write idempotent code paths so reload/unload doesn't leak listeners or intervals. - Use `this.register*` helpers for everything that needs cleanup. - When refreshing collapsible settings sections, preserve the expand state by setting `callbacks.setExpandState({ workflowId: workflow.id })` before calling `callbacks.refresh()` if the section is currently expanded. +- When editing action settings that trigger a UI refresh, use `preserveActionExpandState()` callback to maintain expanded state. **Don't** - Introduce network calls without an obvious user-facing reason and documentation. @@ -258,13 +297,185 @@ this.registerDomEvent(window, "resize", () => { /* ... */ }); this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); ``` +## Action-based workflow system + +Workflows are containers for sequential actions. Each workflow can contain multiple actions that execute in order, with later actions able to reference outputs from earlier actions using tokens. + +### Workflow structure + +```ts +interface WorkflowConfig { + id: string; // Unique identifier + name: string; // Display name + actions: WorkflowAction[]; // Sequential list of actions + outputType: WorkflowOutputType; // 'popup' | 'new-note' | 'at-cursor' + outputFolder: string; // Folder for new-note output +} +``` + +### Action types + +There are two action types: **chat** and **transcription**. + +#### Chat action + +Sends a prompt to an AI model and receives a response. + +```ts +interface ChatAction extends BaseAction { + type: 'chat'; + promptText: string; // The prompt text (when inline) + promptSourceType: PromptSourceType; // 'inline' | 'from-file' + promptFilePath: string; // Path to prompt file (when from-file) + contexts?: ChatContextConfig[]; // Context sources (selection, clipboard, etc.) +} +``` + +**Configuration options:** +- **Provider**: Select which AI provider and model to use +- **Prompt source**: Inline text or load from a file in the vault +- **Prompt text**: The prompt template with token placeholders + +#### Transcription action + +Transcribes audio/video content using a speech-to-text model. + +```ts +interface TranscriptionAction extends BaseAction { + type: 'transcription'; + transcriptionContext?: { + mediaType: 'video' | 'audio'; + sourceUrlToken?: string; // Token containing the URL (e.g., 'workflow.clipboard') + }; + language?: string; // ISO language code (e.g., 'en', 'es') + timestampGranularity?: 'disabled' | 'segment' | 'word'; +} +``` + +**Configuration options:** +- **Provider**: Select which AI provider and model to use (must support transcription) +- **Media type**: Video or audio +- **Source URL**: Token containing the media URL (default: `workflow.clipboard`) +- **Language**: Optional language hint for better accuracy +- **Timestamp granularity**: Disabled (default), segment-level, or word-level + +### Token system + +Actions can reference values from workflow context and previous action outputs using `{{tokenName}}` syntax. + +#### Workflow context tokens (always available) + +| Token | Description | +|-------|-------------| +| `{{workflow.selection}}` | Currently selected text in the editor | +| `{{workflow.clipboard}}` | Contents of the system clipboard | +| `{{workflow.file.content}}` | Full contents of the active file | +| `{{workflow.file.path}}` | Path of the active file | + +#### Chat action output tokens + +| Token | Description | +|-------|-------------| +| `{{actionId.prompt}}` | The original prompt text | +| `{{actionId.response}}` | The AI response text | + +#### Transcription action output tokens + +| Token | Description | +|-------|-------------| +| `{{actionId.title}}` | Video title | +| `{{actionId.author}}` | Video uploader/author | +| `{{actionId.sourceUrl}}` | Original video URL | +| `{{actionId.description}}` | Video description | +| `{{actionId.tags}}` | Video tags (comma-separated) | +| `{{actionId.transcription}}` | Plain transcription text | +| `{{actionId.transcriptionWithTimestamps}}` | Transcription with `[MM:SS]` prefixes (only when timestamps enabled) | + +### Token replacement example + +A workflow with two actions: +1. **Transcription action** (id: `abc123`) - transcribes a video from clipboard URL +2. **Chat action** - summarizes the transcription + +The chat action's prompt can reference the transcription output: + +``` +Summarize the following video transcript: + +Title: {{abc123.title}} +Author: {{abc123.author}} + +Transcript: +{{abc123.transcription}} +``` + +### Workflow execution flow + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ +│ Gather workflow │ ──▶ │ Execute Action 1│ ──▶ │ Execute Action 2│ ──▶ │ Output Handler │ +│ context │ │ (store tokens) │ │ (use prev tokens)│ │ (final result) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ └────────────────┘ +``` + +### Settings UI expand state preservation + +When editing action settings that trigger a UI refresh (e.g., changing prompt source type), preserve the action's expanded state: + +```ts +// In action-specific settings functions, use preserveActionExpandState callback +.onChange(async (value) => { + action.someProperty = value; + await plugin.saveSettings(); + preserveActionExpandState(); // Sets both workflowId and actionId + callbacks.refresh(); +}); +``` + +The `ExpandOnNextRenderState` interface tracks which items should be expanded: + +```ts +interface ExpandOnNextRenderState { + providerId?: string; + modelId?: string; + workflowId?: string; + actionId?: string; // Added for action expand state + availableTokensExpanded?: boolean; +} +``` + +### Creating new actions (avoiding shared references) + +When creating new actions, always create fresh instances of nested objects and arrays: + +```ts +// Correct: Create new array/object instances +const newWorkflow: WorkflowConfig = { + id: generateId(), + ...DEFAULT_WORKFLOW_CONFIG, + actions: [] // New array, not shared reference +}; + +const newChatAction = { + ...DEFAULT_CHAT_ACTION, + id: generateId(), + contexts: [] // New array +}; + +const newTranscriptionAction = { + ...DEFAULT_TRANSCRIPTION_ACTION, + id: generateId(), + transcriptionContext: { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' } // New object +}; +``` + ## Handler architecture The plugin uses a handler-based architecture to separate concerns for workflow execution: ### Input handlers (`src/handlers/input/`) -Input handlers acquire media for transcription workflows. Each handler implements the `InputHandler` interface: +Input handlers acquire media for transcription actions. Each handler implements the `InputHandler` interface: ```ts interface InputHandler { @@ -276,6 +487,7 @@ interface InputHandler { - `VaultFileInputHandler` - Prompts user to select an audio file from the vault - `ClipboardUrlInputHandler` - Extracts audio from a video URL in the clipboard (uses yt-dlp) - `SelectionUrlInputHandler` - Extracts audio from a video URL in the current text selection +- `TokenUrlInputHandler` - Extracts audio from a URL resolved from a token value **InputResult structure:** ```ts @@ -312,26 +524,15 @@ interface OutputHandler { **To add a new input handler:** 1. Create a new file in `src/handlers/input/` (e.g., `my-custom-input-handler.ts`) 2. Implement the `InputHandler` interface -3. Export from `src/handlers/input/index.ts` -4. Add the new source type to `TranscriptionSourceType` in `src/settings/types.ts` -5. Update `createInputHandler()` in `src/processing/workflow-executor.ts` +3. Export from `src/handlers/input/index.ts` and `src/handlers/index.ts` **To add a new output handler:** 1. Create a new file in `src/handlers/output/` (e.g., `my-custom-output-handler.ts`) 2. Implement the `OutputHandler` interface -3. Export from `src/handlers/output/index.ts` +3. Export from `src/handlers/output/index.ts` and `src/handlers/index.ts` 4. Add the new output type to `WorkflowOutputType` in `src/settings/types.ts` 5. Update `createOutputHandler()` in `src/processing/workflow-executor.ts` -### Workflow execution flow - -``` -┌─────────────────┐ ┌───────────────┐ ┌────────────────┐ ┌────────────────┐ -│ User triggers │ ──▶ │ Input Handler │ ──▶ │ AI Provider │ ──▶ │ Output Handler │ -│ workflow │ │ (get media) │ │ (transcribe) │ │ (show result) │ -└─────────────────┘ └───────────────┘ └────────────────┘ └────────────────┘ -``` - ## Troubleshooting - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `/.obsidian/plugins//`. diff --git a/manifest.json b/manifest.json index de98265..c309a77 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "ai-toolbox", "name": "AI Toolbox", - "version": "1.0.3", + "version": "1.0.4", "minAppVersion": "0.15.0", "description": "Personal collection of AI tools to enhance the Obsidian.md workflow.", "author": "dalinicus", diff --git a/src/components/path-picker.ts b/src/components/path-picker.ts index 715adc4..b6bfef0 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, 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/handlers/index.ts b/src/handlers/index.ts index dac0517..9a764de 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -3,7 +3,8 @@ export type { InputHandler, InputContext, InputResult } from './input'; export { VaultFileInputHandler, ClipboardUrlInputHandler, - SelectionUrlInputHandler + SelectionUrlInputHandler, + TokenUrlInputHandler } from './input'; // Output handlers diff --git a/src/handlers/input/index.ts b/src/handlers/input/index.ts index 9afca07..375a895 100644 --- a/src/handlers/input/index.ts +++ b/src/handlers/input/index.ts @@ -5,4 +5,5 @@ export type { InputHandler, InputContext, InputResult } from './types'; export { VaultFileInputHandler } from './vault-file-input-handler'; export { ClipboardUrlInputHandler } from './clipboard-url-input-handler'; export { SelectionUrlInputHandler } from './selection-url-input-handler'; +export { TokenUrlInputHandler } from './token-url-input-handler'; diff --git a/src/handlers/input/token-url-input-handler.ts b/src/handlers/input/token-url-input-handler.ts new file mode 100644 index 0000000..7d92a53 --- /dev/null +++ b/src/handlers/input/token-url-input-handler.ts @@ -0,0 +1,37 @@ +import { Notice } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import { extractAudioFromUrl } from '../../processing'; + +/** + * Input handler that extracts audio from a URL resolved from a token value. + * Uses yt-dlp to download and convert the video to audio. + */ +export class TokenUrlInputHandler implements InputHandler { + private url: string; + + constructor(url: string) { + this.url = url; + } + + async getInput(context: InputContext): Promise { + if (!this.url || !this.url.trim()) { + new Notice('No URL found in the selected token'); + return null; + } + + new Notice('Processing URL from token...'); + + const result = await extractAudioFromUrl(this.url.trim(), context.settings); + + if (!result) { + return null; + } + + return { + audioFilePath: result.audioFilePath, + sourceUrl: result.sourceUrl, + metadata: result.metadata + }; + } +} + 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..6685aa6 --- /dev/null +++ b/src/logging/log-manager.ts @@ -0,0 +1,122 @@ +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: + default: + console.debug(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, message: string, details?: unknown): void { + logManager.log(LogLevel.DEBUG, category, message, details); +} + +export function logInfo(category: LogCategoryType, message: string, details?: unknown): void { + logManager.log(LogLevel.INFO, category, message, details); +} + +export function logWarn(category: LogCategoryType, message: string, details?: unknown): void { + logManager.log(LogLevel.WARN, category, message, details); +} + +export function logError(category: LogCategoryType, 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, 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..c105809 --- /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 as number)) 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'); + void 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..f1d6132 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,18 +25,53 @@ 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() { + // Log views are cleaned up automatically by Obsidian when the plugin unloads + } + + /** + * 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) { + await workspace.revealLeaf(leaf); + } } /** * 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) { - 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/action-executor.ts b/src/processing/action-executor.ts new file mode 100644 index 0000000..f442a59 --- /dev/null +++ b/src/processing/action-executor.ts @@ -0,0 +1,325 @@ +import { App, TFile } from 'obsidian'; +import { AIToolboxSettings, ChatAction, TranscriptionAction, WorkflowAction } from '../settings'; +import { createActionProvider, ChatMessage, TranscriptionOptions } from '../providers'; +import { + InputContext, + InputResult, + TokenUrlInputHandler +} from '../handlers'; +import { + createChatWorkflowTokens, + createTranscriptionWorkflowTokens, + ContextTokenValues, + replaceWorkflowContextTokens +} 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; + /** Workflow-level context values (clipboard, selection, etc.) gathered at workflow start */ + workflowContext: ContextTokenValues; +} + +/** + * 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; + }); +} + +/** + * Resolve a token value from the execution context. + * Tokens can reference workflow context (e.g., 'workflow.clipboard') or + * previous action results (e.g., 'chat1.response'). + */ +function resolveTokenValue( + tokenName: string, + context: ActionExecutionContext +): string | undefined { + // Check if it's a workflow context token + if (tokenName.startsWith('workflow.')) { + const contextKey = tokenName.substring('workflow.'.length); + switch (contextKey) { + case 'selection': + return context.workflowContext.selection; + case 'clipboard': + return context.workflowContext.clipboard; + case 'file.content': + return context.workflowContext.fileContent; + case 'file.path': + return context.workflowContext.filePath; + default: + return undefined; + } + } + + // Check if it's a previous action token (e.g., 'chat1.response') + const dotIndex = tokenName.indexOf('.'); + if (dotIndex > 0) { + const actionId = tokenName.substring(0, dotIndex); + const tokenKey = tokenName.substring(dotIndex + 1); + const actionResult = context.previousResults.get(actionId); + if (actionResult) { + return actionResult.tokens[tokenKey]; + } + } + + return undefined; +} + +/** + * 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 workflow context tokens ({{workflow.selection}}, {{workflow.clipboard}}, etc.) + promptText = replaceWorkflowContextTokens(promptText, context.workflowContext); + + 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' }; + } + + // Resolve the source URL from the configured token + const sourceUrlToken = action.transcriptionContext?.sourceUrlToken ?? 'workflow.clipboard'; + const sourceUrl = resolveTokenValue(sourceUrlToken, context); + + if (!sourceUrl || !sourceUrl.trim()) { + return { ...baseResult, error: `No URL found in token {{${sourceUrlToken}}}` }; + } + + const inputHandler = new TokenUrlInputHandler(sourceUrl); + + // 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: 'Failed to extract audio from URL' }; + } + + 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/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/video-processor.ts b/src/processing/video-processor.ts index 7e3e6f7..decbe80 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. @@ -14,6 +14,7 @@ export interface VideoProcessorConfig { ytdlpLocation?: string; ffmpegLocation?: string; impersonateBrowser: string; + cookiesFromBrowser?: string; keepVideo: boolean; outputDirectory?: string; } @@ -108,6 +109,10 @@ function spawnYtDlp( args.push('--ffmpeg-location', config.ffmpegLocation); } + if (config.cookiesFromBrowser) { + args.push('--cookies-from-browser', config.cookiesFromBrowser); + } + args.push( '--impersonate', config.impersonateBrowser, '-o', outputTemplate, @@ -205,6 +210,7 @@ function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorC ytdlpLocation: settings.ytdlpLocation, ffmpegLocation: settings.ffmpegLocation, impersonateBrowser: settings.impersonateBrowser, + cookiesFromBrowser: settings.cookiesFromBrowser, keepVideo: settings.keepVideo, outputDirectory: settings.outputDirectory, }; @@ -219,18 +225,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 +249,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 +263,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 +279,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-chaining.ts b/src/processing/workflow-chaining.ts index f972648..5b28d93 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 @@ -22,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; } /** @@ -121,33 +90,72 @@ 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; } /** - * 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 []; } /** @@ -157,16 +165,16 @@ export interface ContextTokenValues { /** The currently selected text in the editor */ selection?: string; /** The full contents of the active file */ - activeTabContent?: string; - /** The filename of the active file */ - activeTabFilename?: string; + fileContent?: string; + /** The path of the active file */ + filePath?: string; /** The clipboard contents */ clipboard?: string; } /** * Gather context values from the current editor/clipboard state. - * This retrieves selection, active tab content, filename, and clipboard for token replacement. + * This retrieves selection, file content, file path, and clipboard for token replacement. */ export async function gatherContextValues(app: App): Promise { const values: ContextTokenValues = {}; @@ -182,9 +190,9 @@ export async function gatherContextValues(app: App): Promise const file = activeView.file; if (file) { - values.activeTabFilename = file.name; + values.filePath = file.path; try { - values.activeTabContent = await app.vault.read(file); + values.fileContent = await app.vault.read(file); } catch { // Ignore read errors } @@ -204,24 +212,27 @@ export async function gatherContextValues(app: App): Promise } /** - * Replace context tokens in a prompt with actual values. - * Handles simple tokens like {{selection}}, {{clipboard}}, {{activeTabContent}}, {{activeTabFilename}}. + * Replace workflow context tokens in a prompt with actual values. + * Handles tokens like {{workflow.selection}}, {{workflow.clipboard}}, + * {{workflow.file.content}}, {{workflow.file.path}}. + * + * These tokens are gathered once at workflow start and shared across all actions. */ -export function replaceContextTokens( +export function replaceWorkflowContextTokens( promptText: string, values: ContextTokenValues ): string { - // Match simple tokens like {{selection}} (no dot, simple alphanumeric name) - const tokenPattern = /\{\{([a-zA-Z]+)\}\}/g; + // Match workflow context tokens like {{workflow.selection}} or {{workflow.file.content}} + const tokenPattern = /\{\{workflow\.([a-zA-Z.]+)\}\}/g; return promptText.replace(tokenPattern, (match, tokenName: string) => { switch (tokenName) { case 'selection': return values.selection ?? match; - case 'activeTabContent': - return values.activeTabContent ?? match; - case 'activeTabFilename': - return values.activeTabFilename ?? match; + case 'file.content': + return values.fileContent ?? match; + case 'file.path': + return values.filePath ?? match; case 'clipboard': return values.clipboard ?? match; default: @@ -231,11 +242,3 @@ export function replaceContextTokens( }); } -/** - * Check if a prompt contains any context tokens that need replacement. - */ -export function hasContextTokens(promptText: string): boolean { - const contextTokens = ['selection', 'activeTabContent', 'activeTabFilename', 'clipboard']; - return contextTokens.some(token => promptText.includes(`{{${token}}}`)); -} - diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index ff37384..40481ab 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -1,6 +1,5 @@ -import { App, Notice, 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 { @@ -8,148 +7,83 @@ import { OutputContext, NewNoteOutputHandler, AtCursorOutputHandler, - PopupOutputHandler, - InputHandler, - InputContext, - InputResult, - VaultFileInputHandler, - ClipboardUrlInputHandler, - SelectionUrlInputHandler + PopupOutputHandler } from '../handlers'; import { WorkflowExecutionResult, WorkflowResultsMap, detectCircularDependency, hasWorkflowDependencies, - replaceWorkflowTokens, - createChatWorkflowTokens, - createTranscriptionWorkflowTokens, getDependencyWorkflowIds, gatherContextValues, - replaceContextTokens, - hasContextTokens + ContextTokenValues } from './workflow-chaining'; +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(); - } -} - -/** - * 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'); + 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: "