Browse Source

feat(34-03): wire applyEditOperations into chat workflow

Add applyEditOperations to workflowStore for AI-driven workflow modifications.
Wire ChatPanel to process editWorkflow tool calls and apply operations via store.
Pass lightweight workflow state to chat API for context-aware assistance.

Changes:
- workflowStore: add applyEditOperations method and EditOperation import
- ChatPanel: add onApplyEdits prop, workflowState prop, custom fetch wrapper
- ChatPanel: process tool-invocation parts for editWorkflow and createWorkflow
- ChatPanel: render tool explanations in chat UI
- WorkflowCanvas: extract chatWorkflowState, create handleApplyEdits callback
- WorkflowCanvas: pass onApplyEdits and workflowState to ChatPanel

AI edits trigger captureSnapshot before applying (revert button support).
AI edits do NOT increment manualChangeCount (only manual edits clear snapshot).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
f5c4830866
  1. 130
      src/components/ChatPanel.tsx
  2. 34
      src/components/WorkflowCanvas.tsx
  3. 18
      src/store/workflowStore.ts

130
src/components/ChatPanel.tsx

@ -1,27 +1,86 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState, useCallback } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import ReactMarkdown from "react-markdown";
import { EditOperation } from "@/lib/chat/editOperations";
interface ChatPanelProps {
isOpen: boolean;
onClose: () => void;
onBuildWorkflow?: (description: string) => Promise<void>;
isBuildingWorkflow?: boolean;
onApplyEdits?: (operations: EditOperation[]) => { applied: number; skipped: string[] };
workflowState?: {
nodes: { id: string; type: string; data: Record<string, unknown> }[];
edges: { id: string; source: string; target: string; sourceHandle?: string; targetHandle?: string }[]
};
}
export function ChatPanel({ isOpen, onClose, onBuildWorkflow, isBuildingWorkflow = false }: ChatPanelProps) {
export function ChatPanel({ isOpen, onClose, onBuildWorkflow, isBuildingWorkflow = false, onApplyEdits, workflowState }: ChatPanelProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
// Create a custom fetch function that includes workflowState
const customFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit) => {
if (!init?.body) {
return fetch(input, init);
}
const body = JSON.parse(init.body as string);
const bodyWithWorkflow = {
...body,
workflowState,
};
return fetch(input, {
...init,
body: JSON.stringify(bodyWithWorkflow),
});
}, [workflowState]);
const { messages, sendMessage, setMessages, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
transport: new DefaultChatTransport({ api: "/api/chat", fetch: customFetch }),
});
const isLoading = status === "streaming" || status === "submitted";
// Track processed tool calls to avoid re-applying
const processedToolCalls = useRef<Set<string>>(new Set());
// Process tool invocations from AI responses
useEffect(() => {
messages.forEach((msg) => {
msg.parts?.forEach((part) => {
if (!part.type.startsWith("tool-")) return;
if (!("state" in part) || !("toolCallId" in part) || !("input" in part)) return;
if (part.state !== "output-available") return;
const callId = part.toolCallId;
if (processedToolCalls.current.has(callId)) return;
processedToolCalls.current.add(callId);
const toolName = part.type.replace("tool-", "");
if (toolName === "createWorkflow" && onBuildWorkflow) {
// Extract description from tool args (input is the args)
const description = (part.input as { description?: string }).description;
if (description) {
onBuildWorkflow(description);
}
}
if (toolName === "editWorkflow" && onApplyEdits) {
// Extract operations from tool args (input is the args)
const operations = (part.input as { operations?: EditOperation[] }).operations;
if (operations) {
onApplyEdits(operations);
}
}
});
});
}, [messages, onBuildWorkflow, onApplyEdits]);
// Extract conversation description from user messages
const getConversationDescription = () => {
const userMessages = messages
@ -107,6 +166,14 @@ export function ChatPanel({ isOpen, onClose, onBuildWorkflow, isBuildingWorkflow
.map((part) => part.text)
.join("") || "";
// Extract tool invocations for display
const toolInvocations = message.parts
?.filter((part) => {
if (!part.type.startsWith("tool-")) return false;
if (!("state" in part)) return false;
return part.state === "output-available";
}) || [];
return (
<div
key={message.id}
@ -122,20 +189,49 @@ export function ChatPanel({ isOpen, onClose, onBuildWorkflow, isBuildingWorkflow
{message.role === "user" ? (
<p className="whitespace-pre-wrap">{textContent}</p>
) : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-neutral-100">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 last:mb-0 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 last:mb-0 space-y-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
blockquote: ({ children }) => <blockquote className="border-l-2 border-neutral-500 pl-2 my-2 text-neutral-300 italic">{children}</blockquote>,
code: ({ children }) => <code className="bg-neutral-600 px-1 rounded text-xs">{children}</code>,
}}
>
{textContent}
</ReactMarkdown>
<>
{/* Display tool invocation explanations */}
{toolInvocations.map((tool, idx) => {
if (!("input" in tool)) return null;
const toolName = tool.type.replace("tool-", "");
if (toolName === "editWorkflow") {
const explanation = (tool.input as { explanation?: string }).explanation;
if (explanation) {
return (
<div key={idx} className="mb-2 text-green-300 text-xs italic">
{explanation}
</div>
);
}
}
if (toolName === "createWorkflow") {
return (
<div key={idx} className="mb-2 text-blue-300 text-xs italic">
Building workflow...
</div>
);
}
return null;
})}
{/* Display text content */}
{textContent && (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-neutral-100">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 last:mb-0 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 last:mb-0 space-y-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
blockquote: ({ children }) => <blockquote className="border-l-2 border-neutral-500 pl-2 my-2 text-neutral-300 italic">{children}</blockquote>,
code: ({ children }) => <code className="bg-neutral-600 px-1 rounded text-xs">{children}</code>,
}}
>
{textContent}
</ReactMarkdown>
)}
</>
)}
</div>
</div>

34
src/components/WorkflowCanvas.tsx

@ -40,6 +40,7 @@ import { detectAndSplitGrid } from "@/utils/gridSplitter";
import { logger } from "@/utils/logger";
import { WelcomeModal } from "./quickstart";
import { ChatPanel } from "./ChatPanel";
import { EditOperation } from "@/lib/chat/editOperations";
const nodeTypes: NodeTypes = {
imageInput: ImageInputNode,
@ -179,7 +180,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};
export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot } =
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
@ -594,6 +595,35 @@ export function WorkflowCanvas() {
}
}, [loadWorkflow, showToast, captureSnapshot]);
// Create lightweight workflow state for chat (strip base64 images)
const chatWorkflowState = useMemo(() => ({
nodes: nodes.map(n => ({
id: n.id,
type: n.type || '',
data: { customTitle: (n.data as { customTitle?: string }).customTitle },
})),
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || undefined,
targetHandle: e.targetHandle || undefined,
})),
}), [nodes, edges]);
// Handle applying edit operations from chat
const handleApplyEdits = useCallback((operations: EditOperation[]) => {
captureSnapshot(); // Snapshot before AI edits
const result = applyEditOperations(operations);
if (result.applied > 0) {
showToast(`Applied ${result.applied} edit(s)`, "success");
}
if (result.skipped.length > 0) {
console.warn('Skipped operations:', result.skipped);
}
return result;
}, [captureSnapshot, applyEditOperations, showToast]);
// Handle node selection from drop menu
const handleMenuSelect = useCallback(
(selection: { type: NodeType | MenuAction; isAction: boolean }) => {
@ -1389,6 +1419,8 @@ export function WorkflowCanvas() {
onClose={() => setIsChatOpen(false)}
onBuildWorkflow={handleBuildWorkflow}
isBuildingWorkflow={isBuildingWorkflow}
onApplyEdits={handleApplyEdits}
workflowState={chatWorkflowState}
/>
</div>
);

18
src/store/workflowStore.ts

@ -32,6 +32,7 @@ import { useToast } from "@/components/Toast";
import { calculateGenerationCost } from "@/utils/costCalculator";
import { logger } from "@/utils/logger";
import { externalizeWorkflowImages, hydrateWorkflowImages } from "@/utils/imageStorage";
import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations";
import {
loadSaveConfigs,
saveSaveConfig,
@ -220,6 +221,7 @@ interface WorkflowStore {
revertToSnapshot: () => void;
clearSnapshot: () => void;
incrementManualChangeCount: () => void;
applyEditOperations: (operations: EditOperation[]) => { applied: number; skipped: string[] };
}
let nodeIdCounter = 0;
@ -3012,4 +3014,20 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ manualChangeCount: newCount });
}
},
applyEditOperations: (operations) => {
const state = get();
const result = executeEditOps(operations, {
nodes: state.nodes,
edges: state.edges,
});
set({
nodes: result.nodes,
edges: result.edges,
hasUnsavedChanges: true,
});
return { applied: result.applied, skipped: result.skipped };
},
}));

Loading…
Cancel
Save