Add workflow command support to chat view

Integrate WorkflowEngine into ChatView to handle `/workflow` commands.
When a message starts with `/workflow`, the workflow engine generates
and executes a multi-step plan instead of the standard chat flow.
Results are formatted with step-by-step status and output display.

Includes workflow result formatting, assistant message updates, and
short-term context tracking. Tests verify command parsing and engine
invocation.
This commit is contained in:
2026-05-20 18:58:21 +02:00
parent 158d5f68e6
commit 64ee7763fe
2 changed files with 142 additions and 4 deletions
+111 -4
View File
@@ -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<void> {
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<void> {
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;
+31
View File
@@ -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');