diff --git a/src/chat-view.ts b/src/chat-view.ts index 78365a1..9fda8d4 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -4,6 +4,7 @@ import { VaultIndexer } from './vault-indexer'; import { VaultVectorStore } from './vault-vector-store'; import { ToolExecutor } from './tool-executor'; import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder'; +import { WorkflowEngine } from './workflow-engine'; import { PluginSettings, OllamaMessage, @@ -58,6 +59,13 @@ export class ChatView extends ItemView { this.toolExecutor = new ToolExecutor(this.app.vault, this.app); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault); this.conversationStateManager = new ConversationStateManager(); + this.workflowEngine = new WorkflowEngine( + this.app.vault, + this.app, + settings.ollamaUrl, + settings.model, + { cacheConfig: settings.cacheConfig } + ); } updateSettings(newSettings: PluginSettings) { @@ -68,6 +76,13 @@ export class ChatView extends ItemView { undefined, newSettings.cacheConfig ); + this.workflowEngine = new WorkflowEngine( + this.app.vault, + this.app, + newSettings.ollamaUrl, + newSettings.model, + { cacheConfig: newSettings.cacheConfig } + ); void this.ollamaClient.initializeCache().catch(() => { new Notice( 'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.' @@ -777,12 +792,97 @@ export class ChatView extends ItemView { this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove()); } + private formatWorkflowResult(result: { + workflowName: string; + success: boolean; + stepResults: { stepName: string; success: boolean; data: unknown; error?: string }[]; + finalOutput: unknown; + error?: string; + }): string { + const lines: string[] = []; + lines.push(`## ${result.workflowName}`); + lines.push(''); + + if (result.stepResults.length > 0) { + lines.push('**Steps:**'); + for (const step of result.stepResults) { + const status = step.success ? '✅' : '❌'; + lines.push(`${status} **${step.stepName}**`); + if (!step.success && step.error) { + lines.push(` Error: ${step.error}`); + } + } + lines.push(''); + } + + if (result.error) { + lines.push(`**Workflow Error:** ${result.error}`); + lines.push(''); + } + + if (result.finalOutput) { + lines.push('**Result:**'); + if (typeof result.finalOutput === 'string') { + lines.push(result.finalOutput); + } else { + lines.push(JSON.stringify(result.finalOutput, null, 2)); + } + } + + return lines.join('\n'); + } + + async handleWorkflowRequest(query: string, assistantMessageId: string): Promise { + try { + this.updateMessageById(assistantMessageId, { + content: '🔄 Generating workflow plan...', + isStreaming: false, + }); + + const result = await this.workflowEngine.executeWorkflowFromQuery(query, this.getTools()); + + const formatted = this.formatWorkflowResult({ + workflowName: result.workflowName, + success: result.success, + stepResults: result.stepResults.map((sr) => ({ + stepName: sr.stepName, + success: sr.success, + data: sr.data, + error: sr.error, + })), + finalOutput: result.finalOutput, + error: result.error, + }); + + this.updateMessageById(assistantMessageId, { + content: formatted, + isStreaming: false, + }); + + this.conversationStateManager.updateShortTermContext({ + role: 'assistant', + content: formatted, + }); + } catch (error) { + ErrorHandler.handleError(error, 'ChatView.handleWorkflowRequest'); + this.updateMessageById(assistantMessageId, { + content: 'An error occurred while executing the workflow.', + isStreaming: false, + }); + } + } + async handleUserInput(inputValue?: string): Promise { const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim(); if (!userMessage) { return; } + const isWorkflowCommand = userMessage.toLowerCase().startsWith('/workflow'); + const actualMessage = isWorkflowCommand + ? userMessage.slice('/workflow'.length).trim() + : userMessage; + const maxContextLength = this.settings.maxContextLength; const tools = this.getTools(); const messageId = crypto.randomUUID(); @@ -792,7 +892,7 @@ export class ChatView extends ItemView { const userChatMessage: ChatMessage = { id: userMessageId, role: 'user', - content: userMessage, + content: actualMessage, timestamp: Date.now(), }; @@ -823,9 +923,15 @@ export class ChatView extends ItemView { this.lastMessageEl = previousStreamingEl; } + if (isWorkflowCommand) { + await this.handleWorkflowRequest(actualMessage, assistantMessageId); + this.cleanupStreamingResources(); + return; + } + try { const entries = await this.vaultIndexer.searchVault( - userMessage, + actualMessage, this.settings.vaultSearchLimit ); const context = entries @@ -841,8 +947,8 @@ export class ChatView extends ItemView { .join('\n\n') .slice(0, maxContextLength); const userMessageWithContext = context - ? `Relevant vault context:\n${context}\n\nUser question:\n${userMessage}` - : userMessage; + ? `Relevant vault context:\n${context}\n\nUser question:\n${actualMessage}` + : actualMessage; // Get the complete messages array for the LLM with all context layers const completeMessages = @@ -932,6 +1038,7 @@ export class ChatView extends ItemView { private vaultIndexer: VaultIndexer; private toolExecutor: ToolExecutor; private actionPreviewBuilder: ActionPreviewBuilder; + private workflowEngine: WorkflowEngine; private conversationStateManager: ConversationStateManager; private vectorStore?: VaultVectorStore; diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 98a2843..646d51a 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -713,6 +713,37 @@ describe('ChatView', () => { errorHandlerSpy.mockRestore(); }); + it('should handle /workflow command', async () => { + view['sendButton'] = document.createElement('button'); + view['inputEl'] = document.createElement('textarea'); + (view['inputEl'] as HTMLTextAreaElement).value = '/workflow find notes and summarize'; + + const workflowSpy = jest + .spyOn(view['workflowEngine'], 'executeWorkflowFromQuery') + .mockResolvedValue({ + workflowId: 'wf-1', + workflowName: 'Test Workflow', + success: true, + stepResults: [ + { + stepId: 's1', + stepName: 'Search', + success: true, + data: ['note1'], + timestamp: Date.now(), + }, + ], + finalOutput: 'Summary result', + }); + + await (view as any).handleUserInput('/workflow find notes and summarize'); + + expect(workflowSpy).toHaveBeenCalledWith('find notes and summarize', expect.any(Array)); + const assistantMsg = (view as any).messages.find((m: any) => m.role === 'assistant'); + expect(assistantMsg.content).toContain('Test Workflow'); + expect(assistantMsg.content).toContain('Summary result'); + }); + it('should limit conversation history to maxMessageHistory', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea');