Add action preview builder for write tool confirmation
Introduces `ActionPreviewBuilder` to generate before/after previews for destructive operations. Write tools are now deferred with apply/cancel UI instead of executing immediately. Includes `ProposedAction` type, CSS for diff views, and state management in `ChatView`.
This commit is contained in:
+198
-10
@@ -3,7 +3,16 @@ import { OllamaClient } from './ollama-client';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { VaultVectorStore } from './vault-vector-store';
|
||||
import { ToolExecutor } from './tool-executor';
|
||||
import { PluginSettings, OllamaMessage, OllamaTool, OllamaToolCall, ChatMessage } from './types';
|
||||
import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder';
|
||||
import {
|
||||
PluginSettings,
|
||||
OllamaMessage,
|
||||
OllamaTool,
|
||||
OllamaToolCall,
|
||||
ChatMessage,
|
||||
ProposedAction,
|
||||
ToolResult,
|
||||
} from './types';
|
||||
import { ConversationStateManager } from './conversation-state';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
|
||||
@@ -47,6 +56,7 @@ export class ChatView extends ItemView {
|
||||
);
|
||||
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
|
||||
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
|
||||
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault);
|
||||
this.conversationStateManager = new ConversationStateManager();
|
||||
}
|
||||
|
||||
@@ -560,9 +570,13 @@ export class ChatView extends ItemView {
|
||||
fullResponse: string,
|
||||
assistantMessageId: string
|
||||
): Promise<void> {
|
||||
const toolResults = (
|
||||
const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? ''));
|
||||
const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? ''));
|
||||
|
||||
// Execute read/search tools immediately
|
||||
const readResults = (
|
||||
await Promise.all(
|
||||
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
||||
readToolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
||||
return { ...toolResult, id: toolCall.id };
|
||||
@@ -574,13 +588,40 @@ export class ChatView extends ItemView {
|
||||
)
|
||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
||||
|
||||
const followUpMessages: OllamaMessage[] = toolResults.map((result) => {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
tool_call_id: result.id ?? '',
|
||||
};
|
||||
});
|
||||
// Build previews for write tools
|
||||
const writePreviews: ProposedAction[] = [];
|
||||
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
||||
try {
|
||||
const preview = await this.actionPreviewBuilder.buildPreview(toolCall);
|
||||
writePreviews.push(preview);
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
}
|
||||
}
|
||||
|
||||
if (writePreviews.length > 0) {
|
||||
// Store pending state for apply/cancel
|
||||
this.pendingActions = writePreviews;
|
||||
this.pendingReadResults = readResults;
|
||||
this.pendingFollowUpContext = { messages, tools, assistantMessageId };
|
||||
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`,
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
this.render();
|
||||
this.renderActionPreviews(assistantMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// No write tools — proceed with follow-up as before
|
||||
const allResults = readResults;
|
||||
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
tool_call_id: result.id ?? '',
|
||||
}));
|
||||
|
||||
const followUp: OllamaMessage = {
|
||||
role: 'assistant',
|
||||
@@ -599,6 +640,143 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
async applyPendingActions(): Promise<void> {
|
||||
if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messages, tools, assistantMessageId } = this.pendingFollowUpContext;
|
||||
|
||||
// Execute write tools
|
||||
const writeResults = (
|
||||
await Promise.all(
|
||||
this.pendingActions.map(async (action) => {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
||||
return { ...toolResult, id: action.toolCall.id };
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'ChatView.applyPendingActions');
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
||||
|
||||
const allResults = [...this.pendingReadResults, ...writeResults];
|
||||
|
||||
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
tool_call_id: result.id ?? '',
|
||||
}));
|
||||
|
||||
const followUp: OllamaMessage = {
|
||||
role: 'assistant',
|
||||
content: 'I have processed your request using the following tools. Here are the results:',
|
||||
tool_calls: this.pendingActions.map((a) => a.toolCall),
|
||||
};
|
||||
|
||||
if (followUpMessages.length > 0) {
|
||||
const finalMessages = [...messages, followUp, ...followUpMessages];
|
||||
const response = await this.ollamaClient.chat(finalMessages, tools);
|
||||
const finalResponse = response.content || 'Actions applied successfully.';
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: finalResponse,
|
||||
isStreaming: false,
|
||||
});
|
||||
} else {
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: 'Actions applied successfully.',
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.clearPendingActions();
|
||||
this.render();
|
||||
}
|
||||
|
||||
cancelPendingActions(): void {
|
||||
if (this.pendingActions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.pendingFollowUpContext;
|
||||
if (context) {
|
||||
this.updateMessageById(context.assistantMessageId, {
|
||||
content: 'Actions cancelled. No changes were made.',
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.clearPendingActions();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private renderActionPreviews(assistantMessageId: string): void {
|
||||
const messageEl = this.chatContainer?.querySelector(
|
||||
`.ollama-message[data-msg-id="${assistantMessageId}"]`
|
||||
);
|
||||
if (!messageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing preview
|
||||
const existing = messageEl.querySelector('.ollama-proposed-actions');
|
||||
existing?.remove();
|
||||
|
||||
const container = messageEl.createEl('div', { cls: 'ollama-proposed-actions' });
|
||||
container.createEl('div', {
|
||||
cls: 'ollama-proposed-actions-header',
|
||||
text: 'Proposed Actions',
|
||||
});
|
||||
|
||||
for (const action of this.pendingActions) {
|
||||
const card = container.createEl('div', { cls: 'ollama-proposed-action' });
|
||||
card.createEl('div', { cls: 'ollama-action-description', text: action.description });
|
||||
|
||||
if (action.preview) {
|
||||
const diffEl = card.createEl('div', { cls: 'ollama-action-diff' });
|
||||
if (action.preview.before !== undefined) {
|
||||
diffEl.createEl('pre', {
|
||||
cls: 'ollama-diff-before',
|
||||
text: `Before:\n${action.preview.before.slice(0, 500)}`,
|
||||
});
|
||||
}
|
||||
if (action.preview.after !== undefined) {
|
||||
diffEl.createEl('pre', {
|
||||
cls: 'ollama-diff-after',
|
||||
text: `After:\n${action.preview.after.slice(0, 500)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buttonContainer = container.createEl('div', { cls: 'ollama-action-buttons' });
|
||||
const applyBtn = buttonContainer.createEl('button', {
|
||||
cls: 'ollama-apply-button',
|
||||
text: 'Apply All',
|
||||
});
|
||||
const cancelBtn = buttonContainer.createEl('button', {
|
||||
cls: 'ollama-cancel-button',
|
||||
text: 'Cancel',
|
||||
});
|
||||
|
||||
applyBtn.addEventListener('click', () => {
|
||||
void this.applyPendingActions();
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.cancelPendingActions();
|
||||
});
|
||||
}
|
||||
|
||||
private clearPendingActions(): void {
|
||||
this.pendingActions = [];
|
||||
this.pendingReadResults = [];
|
||||
this.pendingFollowUpContext = null;
|
||||
this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
async handleUserInput(inputValue?: string): Promise<void> {
|
||||
const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim();
|
||||
if (!userMessage) {
|
||||
@@ -753,8 +931,18 @@ export class ChatView extends ItemView {
|
||||
private ollamaClient: OllamaClient;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
private toolExecutor: ToolExecutor;
|
||||
private actionPreviewBuilder: ActionPreviewBuilder;
|
||||
private conversationStateManager: ConversationStateManager;
|
||||
private vectorStore?: VaultVectorStore;
|
||||
|
||||
// Pending action state
|
||||
private pendingActions: ProposedAction[] = [];
|
||||
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
||||
private pendingFollowUpContext: {
|
||||
messages: OllamaMessage[];
|
||||
tools: OllamaTool[];
|
||||
assistantMessageId: string;
|
||||
} | null = null;
|
||||
}
|
||||
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
|
||||
Reference in New Issue
Block a user