From 2213f6afc5cb556f4a72283cd5c2ba09d57d2011 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 8 Feb 2026 23:07:04 +1300 Subject: [PATCH] refactor: unify llmGenerate execution into shared executor Co-Authored-By: Claude Opus 4.6 --- .../__tests__/llmGenerateExecutor.test.ts | 239 ++++++++++++++++++ src/store/execution/llmGenerateExecutor.ts | 108 ++++++++ 2 files changed, 347 insertions(+) create mode 100644 src/store/execution/__tests__/llmGenerateExecutor.test.ts create mode 100644 src/store/execution/llmGenerateExecutor.ts diff --git a/src/store/execution/__tests__/llmGenerateExecutor.test.ts b/src/store/execution/__tests__/llmGenerateExecutor.test.ts new file mode 100644 index 00000000..81081c30 --- /dev/null +++ b/src/store/execution/__tests__/llmGenerateExecutor.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeLlmGenerate } from "../llmGenerateExecutor"; +import type { NodeExecutionContext } from "../types"; +import type { WorkflowNode } from "@/types"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const defaultProviderSettings = { + providers: { + gemini: { apiKey: "gkey" }, + replicate: { apiKey: "" }, + fal: { apiKey: "" }, + kie: { apiKey: "" }, + wavespeed: { apiKey: "" }, + openai: { apiKey: "okey" }, + }, +} as any; + +function makeNode(data: Record = {}): WorkflowNode { + return { + id: "llm-1", + type: "llmGenerate", + position: { x: 0, y: 0 }, + data: { + outputText: null, + inputImages: [], + inputPrompt: null, + status: null, + error: null, + provider: "google", + model: "gemini-2.5-flash", + temperature: 0.7, + maxTokens: 1024, + ...data, + }, + } as WorkflowNode; +} + +function makeCtx( + node: WorkflowNode, + overrides: Partial = {} +): NodeExecutionContext { + return { + node, + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: [], + audio: [], + text: "test llm prompt", + dynamicInputs: {}, + easeCurve: null, + }), + updateNodeData: vi.fn(), + getFreshNode: vi.fn().mockReturnValue(node), + getEdges: vi.fn().mockReturnValue([]), + getNodes: vi.fn().mockReturnValue([node]), + providerSettings: defaultProviderSettings, + addIncurredCost: vi.fn(), + addToGlobalHistory: vi.fn(), + generationsPath: null, + saveDirectoryPath: null, + get: vi.fn(), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("executeLlmGenerate", () => { + it("should throw when no text input", async () => { + const node = makeNode(); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: [], + audio: [], + text: null, + dynamicInputs: {}, + easeCurve: null, + }), + }); + + await expect(executeLlmGenerate(ctx)).rejects.toThrow("Missing text input"); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("llm-1", expect.objectContaining({ + status: "error", + error: expect.stringContaining("Missing text input"), + })); + }); + + it("should set loading status before API call", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "generated text" }), + }); + + const ctx = makeCtx(node); + await executeLlmGenerate(ctx); + + const calls = (ctx.updateNodeData as ReturnType).mock.calls; + const loadingCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "loading" + ); + expect(loadingCall).toBeDefined(); + }); + + it("should call /api/llm with correct payload", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "result text" }), + }); + + const ctx = makeCtx(node); + await executeLlmGenerate(ctx); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/llm", + expect.objectContaining({ + method: "POST", + }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.prompt).toBe("test llm prompt"); + expect(body.provider).toBe("google"); + expect(body.model).toBe("gemini-2.5-flash"); + expect(body.temperature).toBe(0.7); + expect(body.maxTokens).toBe(1024); + }); + + it("should include images in request when connected", async () => { + const node = makeNode(); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["data:image/png;base64,img1"], + videos: [], + audio: [], + text: "describe this", + dynamicInputs: {}, + easeCurve: null, + }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "description" }), + }); + + await executeLlmGenerate(ctx); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.images).toEqual(["data:image/png;base64,img1"]); + }); + + it("should not include images field when none connected", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "result" }), + }); + + const ctx = makeCtx(node); + await executeLlmGenerate(ctx); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.images).toBeUndefined(); + }); + + it("should update node with result text on success", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "generated output" }), + }); + + const ctx = makeCtx(node); + await executeLlmGenerate(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("llm-1", { + outputText: "generated output", + status: "complete", + error: null, + }); + }); + + it("should throw on HTTP error", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('{"error": "LLM down"}'), + }); + + const ctx = makeCtx(node); + await expect(executeLlmGenerate(ctx)).rejects.toThrow("LLM down"); + }); + + it("should throw on API failure", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, error: "Token limit exceeded" }), + }); + + const ctx = makeCtx(node); + await expect(executeLlmGenerate(ctx)).rejects.toThrow("Token limit exceeded"); + }); + + it("should use stored fallback in regenerate mode", async () => { + const node = makeNode({ + inputImages: ["stored.png"], + inputPrompt: "stored llm prompt", + }); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: [], + audio: [], + text: null, + dynamicInputs: {}, + easeCurve: null, + }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, text: "result" }), + }); + + await executeLlmGenerate(ctx, { useStoredFallback: true }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.prompt).toBe("stored llm prompt"); + expect(body.images).toEqual(["stored.png"]); + }); +}); diff --git a/src/store/execution/llmGenerateExecutor.ts b/src/store/execution/llmGenerateExecutor.ts new file mode 100644 index 00000000..e5d9d792 --- /dev/null +++ b/src/store/execution/llmGenerateExecutor.ts @@ -0,0 +1,108 @@ +/** + * LLM Generate Executor + * + * Unified executor for llmGenerate (text generation) nodes. + * Used by both executeWorkflow and regenerateNode. + */ + +import type { LLMGenerateNodeData } from "@/types"; +import { buildLlmHeaders } from "@/store/utils/buildApiHeaders"; +import type { NodeExecutionContext } from "./types"; + +export interface LlmGenerateOptions { + /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ + useStoredFallback?: boolean; +} + +export async function executeLlmGenerate( + ctx: NodeExecutionContext, + options: LlmGenerateOptions = {} +): Promise { + const { + node, + getConnectedInputs, + updateNodeData, + signal, + providerSettings, + } = ctx; + + const { useStoredFallback = false } = options; + + const inputs = getConnectedInputs(node.id); + const nodeData = node.data as LLMGenerateNodeData; + + // Determine images and text + let images: string[]; + let text: string | null; + + if (useStoredFallback) { + images = inputs.images.length > 0 ? inputs.images : nodeData.inputImages; + text = inputs.text ?? nodeData.inputPrompt; + } else { + images = inputs.images; + text = inputs.text ?? nodeData.inputPrompt; + } + + if (!text) { + updateNodeData(node.id, { + status: "error", + error: "Missing text input - connect a prompt node or set internal prompt", + }); + throw new Error("Missing text input"); + } + + updateNodeData(node.id, { + inputPrompt: text, + inputImages: images, + status: "loading", + error: null, + }); + + const headers = buildLlmHeaders(nodeData.provider, providerSettings); + + const response = await fetch("/api/llm", { + method: "POST", + headers, + body: JSON.stringify({ + prompt: text, + ...(images.length > 0 && { images }), + provider: nodeData.provider, + model: nodeData.model, + temperature: nodeData.temperature, + maxTokens: nodeData.maxTokens, + }), + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + } + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } + + const result = await response.json(); + + if (result.success && result.text) { + updateNodeData(node.id, { + outputText: result.text, + status: "complete", + error: null, + }); + } else { + updateNodeData(node.id, { + status: "error", + error: result.error || "LLM generation failed", + }); + throw new Error(result.error || "LLM generation failed"); + } +}