From f5c4830866618457921daf3b92951fa13eee14df Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 23:43:23 +1300 Subject: [PATCH] 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 --- src/components/ChatPanel.tsx | 130 ++++++++++++++++++++++++++---- src/components/WorkflowCanvas.tsx | 34 +++++++- src/store/workflowStore.ts | 18 +++++ 3 files changed, 164 insertions(+), 18 deletions(-) diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index cc1eab8e..1e4658b8 100644 --- a/src/components/ChatPanel.tsx +++ b/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; isBuildingWorkflow?: boolean; + onApplyEdits?: (operations: EditOperation[]) => { applied: number; skipped: string[] }; + workflowState?: { + nodes: { id: string; type: string; data: Record }[]; + 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(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>(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 (
{textContent}

) : ( -

{children}

, - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) =>
    {children}
    , - code: ({ children }) => {children}, - }} - > - {textContent} -
    + <> + {/* 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 ( +
    + {explanation} +
    + ); + } + } + if (toolName === "createWorkflow") { + return ( +
    + Building workflow... +
    + ); + } + return null; + })} + + {/* Display text content */} + {textContent && ( +

    {children}

    , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) =>
    {children}
    , + code: ({ children }) => {children}, + }} + > + {textContent} +
    + )} + )}
    diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index b0ceae08..f06aa490 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/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} /> ); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 08e0c4b6..b18eb03e 100644 --- a/src/store/workflowStore.ts +++ b/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((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 }; + }, }));