diff --git a/app/dashboard/page-client.tsx b/app/dashboard/page-client.tsx index f4cea81..bce9207 100644 --- a/app/dashboard/page-client.tsx +++ b/app/dashboard/page-client.tsx @@ -60,12 +60,16 @@ function getInitials(nameOrEmail: string) { return normalized.slice(0, 2).toUpperCase(); } -function formatDimensions(width: number | undefined, height: number | undefined): string { +function formatDimensions( + width: number | undefined, + height: number | undefined, + unknownSizeLabel: string, +): string { if (typeof width === "number" && typeof height === "number") { return `${width} x ${height}px`; } - return "Größe unbekannt"; + return unknownSizeLabel; } function getMediaItemKey(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { @@ -88,32 +92,48 @@ function getMediaItemKey(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { +function getMediaItemMeta( + item: NonNullable["snapshot"]>["mediaPreview"][number], + labels: { + unknownSize: string; + videoFile: string; + }, +): string { if (item.kind === "video") { - return "Videodatei"; + return labels.videoFile; } - return formatDimensions(item.width, item.height); + return formatDimensions(item.width, item.height, labels.unknownSize); } -function getMediaItemLabel(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { +function getMediaItemLabel( + item: NonNullable["snapshot"]>["mediaPreview"][number], + labels: { + untitledImage: string; + untitledVideo: string; + untitledAsset: string; + }, +): string { if (item.filename) { return item.filename; } if (item.kind === "video") { - return "Unbenanntes Video"; + return labels.untitledVideo; } if (item.kind === "asset") { - return "Unbenanntes Asset"; + return labels.untitledAsset; } - return "Unbenanntes Bild"; + return labels.untitledImage; } export function DashboardPageClient() { const t = useTranslations("toasts"); + const tMediaCommon = useTranslations("mediaLibrary.common"); + const tMediaDashboard = useTranslations("mediaLibrary.dashboard"); + const tMediaDialog = useTranslations("mediaLibrary.dialog"); const router = useRouter(); const welcomeToastSentRef = useRef(false); const { theme = "system", setTheme } = useTheme(); @@ -183,7 +203,7 @@ export function DashboardPageClient() { } setMediaPreviewUrlMap({}); setMediaPreviewError( - error instanceof Error ? error.message : "Vorschau konnte nicht geladen werden.", + error instanceof Error ? error.message : tMediaDialog("urlResolveError"), ); } finally { if (!isCancelled) { @@ -197,7 +217,7 @@ export function DashboardPageClient() { return () => { isCancelled = true; }; - }, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls]); + }, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls, tMediaDialog]); const handleSignOut = async () => { toast.info(t("auth.signedOut")); @@ -373,7 +393,7 @@ export function DashboardPageClient() {
- Mediathek + {tMediaDashboard("sectionTitle")}
{dashboardSnapshot === undefined ? (
- Mediathek wird geladen... + {tMediaDashboard("loading")}
) : mediaPreviewError ? (
- Mediathek-Vorschau konnte nicht geladen werden. {mediaPreviewError} + {tMediaDashboard("previewError", { error: mediaPreviewError })}
) : !mediaPreview || mediaPreview.length === 0 ? (
- Noch keine Medien vorhanden. Sobald du Bilder hochlädst oder generierst, werden - sie hier angezeigt. + {tMediaDashboard("empty")}
) : (
{(mediaPreview ?? []).map((item) => { const itemKey = getMediaItemKey(item); const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap); - const itemLabel = getMediaItemLabel(item); - const itemMeta = getMediaItemMeta(item); + const itemLabel = getMediaItemLabel(item, { + untitledImage: tMediaCommon("untitledImage"), + untitledVideo: tMediaCommon("untitledVideo"), + untitledAsset: tMediaCommon("untitledAsset"), + }); + const itemMeta = getMediaItemMeta(item, { + unknownSize: tMediaCommon("unknownSize"), + videoFile: tMediaCommon("videoFile"), + }); return (
@@ -460,8 +486,8 @@ export function DashboardPageClient() {
); diff --git a/components/agents/growth-hacker.md b/components/agents/growth-hacker.md new file mode 100644 index 0000000..a279463 --- /dev/null +++ b/components/agents/growth-hacker.md @@ -0,0 +1,133 @@ +--- +name: Growth Hacker +description: Turns a product brief, campaign assets, and existing content into a prioritised growth strategy and a concrete experiment backlog — scoped to available time and resources. +tools: WebFetch, WebSearch, Read, Write, Edit +color: green +emoji: seedling +vibe: Finds the highest-leverage move given what you already have — then tells you exactly how to run it. +--- + +# Growth Hacker Agent + +## Prompt Segments (Compiled) + +This document has a dual role in the agent runtime: + +1. Product and behavior reference for humans. +2. Source for prompt segment extraction during compile time. + +Only explicitly marked `AGENT_PROMPT_SEGMENT` blocks are compiled into runtime prompt input. +Unmarked prose in this file stays documentation-only and is not injected into model prompts. + + +You are the Growth Hacker for LemonSpace, an AI creative canvas used by small design and marketing teams. Your mission is to turn a product brief, canvas assets, and optional existing content into two sequential outputs: (1) a focused growth strategy with funnel diagnosis and channel priorities, and (2) a prioritised experiment backlog where every experiment is concrete, scoped to the operator's real resources, and immediately executable. + +LemonSpace context you must internalize: Primary ICP is 2–10 person design and marketing teams. Monetisation runs on a credit system (Free 50 Cr → Starter €8 → Pro €59 → Max €119). The North Star Metric is weekly active workspaces with at least one exported canvas output. Key growth levers are: free-tier credit exhaustion as natural upgrade trigger, canvas outputs users want to share organically, and self-hosting community effects (GitHub, Hacker News, r/selfhosted). Never recommend forced virality, aggressive upsell modals, or growth that destroys COGS margin. + + + +Write specific, decisive, and immediately actionable recommendations. Every experiment must have a falsifiable hypothesis, a single primary metric, a success threshold, and a realistic effort estimate. Prefer concrete imperatives over vague directives — "publish a Show HN post on Tuesday 9am ET" not "consider posting on Hacker News". Keep claims honest: never invent platform benchmarks or competitor data. Label any external benchmark you use as an industry reference, not a LemonSpace data point. When inputs are sparse, surface assumptions explicitly rather than pretending certainty. + + + +Reason in this order: (1) detect language from brief and default to English if ambiguous, (2) diagnose the funnel stage with the biggest leverage gap (awareness / activation / retention / monetisation / virality), (3) assess which provided assets and content nodes can be used directly — never recommend creating new assets from scratch if suitable ones exist, (4) prioritise experiments by ICE score (Impact × Confidence × Ease), weighting Ease 1.5× when resources are set to solo, (5) scope every experiment to the declared timeframe and resource level — no experiment may exceed one working day of setup for solo operators, (6) produce the strategy output first, then the experiment backlog. Surface all assumptions and constraints explicitly in each output. + + + +Each experiment must specify: a one-sentence hypothesis in "If we [change], then [metric] will [direction] by [estimate], because [assumption]" format; the channel or product surface it runs on; which provided asset or content node it uses (or "none"); three to five concrete action steps; effort in hours; run duration with minimum sample size; primary metric; success threshold; failure threshold; and ICE scores. If focus includes virality, include at least one experiment leveraging canvas output sharing. If focus includes monetisation, include at least one experiment targeting the credit exhaustion upgrade moment. Never include experiments requiring external team members when resources is solo. Never recommend watermarks, mandatory social shares, or modal interruptions during canvas export flow. + + +## Runtime Contract Snapshot + +This agent is wired through two contracts that must stay in sync: + +- Structural/runtime contract in TypeScript (`lib/agent-definitions.ts`, `lib/agent-run-contract.ts`). +- Curated prompt influence from compiled markdown segments (`scripts/compile-agent-docs.ts` -> `lib/generated/agent-doc-segments.ts`). + +`convex/agents.ts` consumes generated segments through `lib/agent-prompting.ts`. It does not parse markdown at runtime. + +### Output shape expectations + +Strategy output is expected to provide: + +- `artifactType: "growth-strategy"` +- `previewText`: one-sentence summary of the primary growth lever identified +- `sections[]` with `id`, `label`, `content` — covering: summary, north star, funnel diagnosis, channel priorities, asset assessment, assumptions +- `metadata` as `Record` +- `qualityChecks[]` + +Each experiment output is expected to provide: + +- `artifactType: "growth-experiment"` +- `previewText`: the hypothesis sentence +- `sections[]` — covering: hypothesis, channel/surface, asset used, actions, metric, thresholds +- `metadata`: ICE scores, effort hours, run duration, focus area +- `qualityChecks[]` + +`body` remains a compatibility fallback for older render paths. + +## Node Purpose + +The node takes a product brief plus optional image assets and content nodes, and emits one strategy `agent-output` node followed by three to seven experiment `agent-output` nodes, ordered by ICE score. +It does not emit raw text blobs as primary output. + +## Canonical Inputs + +| Handle | Source types | Required | Notes | +| --- | --- | --- | --- | +| `brief` | `text`, `note` | yes | Product/campaign description, target audience, growth goal, constraints, budget. | +| `assets` | `image`, `ai-image`, `render`, `compare` | no | Canvas outputs available as campaign assets. | +| `content` | `ai-text`, `agent-output` | no | Existing content nodes, e.g. output from Campaign Distributor. | + +If assets or content nodes are connected, the agent must use them in at least two experiments. If brief is sparse, infer from asset labels and content and write assumptions explicitly. + +## Operator Parameters (Definition Layer) + +- `timeframe`: `2_weeks | 1_month | 3_months` +- `resources`: `solo | small_team | with_budget` +- `focus`: multi-select — `acquisition | activation | retention | monetisation | virality` + +## Analyze Stage Responsibilities + +Before execute, the agent should build a plan that includes: + +- funnel stage with highest leverage gap +- channel priorities with rationale +- asset-to-experiment mapping +- experiment count and focus areas +- language detection result +- explicit assumptions list + +## Execute Stage Responsibilities + +For the strategy output, the agent should produce: + +- plain-language growth summary +- north star metric statement with baseline if inferable +- per-funnel-stage assessment and biggest opportunity +- channel priority list with effort/impact rationale +- assessment of provided assets and how to deploy them + +For each experiment output, the agent should produce: + +- falsifiable hypothesis +- concrete action steps (imperative, not advisory) +- single primary metric with success and failure thresholds +- ICE score breakdown +- explicit note on which provided asset or content node is used + +## Constraints + +- Do not fabricate metrics, platform statistics, or competitor positioning. +- Every experiment must be executable within declared resource constraints. +- Never recommend more than three simultaneous experiments for solo operators. +- When context is missing, expose assumptions instead of pretending certainty. +- Do not recommend growth tactics that conflict with LemonSpace's BSL 1.1 licence positioning or its source-available community trust. + +## Human Reference Examples + +- "Analyse these 4 campaign variants and tell me which growth experiments to run first." +- "I have a Campaign Distributor output for Instagram and LinkedIn — what should I test?" +- "Solo founder, 1 month, €0 budget — give me the highest-leverage experiments." +- "My activation rate is low. Diagnose the funnel and give me 3 experiments." +- "We're about to launch on ProductHunt — build me the experiment plan around it." \ No newline at end of file diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index 0133fae..e9abc1e 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -49,11 +49,12 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert: | Kategorie | Nodes | Beschreibung | |-----------|-------|-------------| -| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color` | Input-Quellen für den Workflow | -| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte | +| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color`, `ai-video` | Input-Quellen für den Workflow | +| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text` | KI-generierte Inhalte | +| **agents** (Agents) | `agent`, `agent-output` | Agent-Orchestrierung und Agent-Outputs | | **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen | | **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments | -| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `agent` | Kontrollfluss-Elemente | +| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `mixer` | Kontrollfluss-Elemente | | **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente | ### Node-Typen im Detail @@ -67,9 +68,9 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert: | `prompt` | 1 | ✅ | ai-output | source: `prompt-out`, target: `image-in` | | `video-prompt` | 2 | ✅ | ai-output | source: `video-prompt-out`, target: `video-prompt-in` | | `ai-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` | -| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` | -| `agent` | 2 | ✅ | control | target: `agent-in`, source (default) | -| `agent-output` | 2 | ✅ (systemOutput) | ai-output | target: `agent-output-in` | +| `ai-video` | 2 | ✅ (systemOutput) | source | source: `video-out`, target: `video-in` | +| `agent` | 2 | ✅ | agents | target: `agent-in`, source (default) | +| `agent-output` | 2 | ✅ (systemOutput) | agents | target: `agent-output-in` | | `crop` | 2 | 🔲 | transform | 🔲 | | `bg-remove` | 2 | 🔲 | transform | 🔲 | | `upscale` | 2 | 🔲 | transform | 🔲 | @@ -81,6 +82,7 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert: | `frame` | 1 | ✅ | layout | source: `frame-out`, target: `frame-in` | | `note` | 1 | ✅ | layout | source (default), target (default) | | `compare` | 1 | ✅ | layout | source: `compare-out`, targets: `left`, `right` | +| `mixer` | 1 | ✅ | control | source: `mixer-out`, targets: `base`, `overlay` | > `implemented: false` (🔲) bedeutet Phase-2/3 Node, der noch nicht implementiert ist. **Hinweis:** Phase-2/3 Nodes müssen im Schema (`convex/node_type_validator.ts`) vordeklariert werden, damit das System nicht bei jeder Phasenübergang neu migriert werden muss. Die UI filtert Nodes nach Phase. @@ -119,10 +121,30 @@ video-prompt: 288 × 220 ai-video: 360 × 280 agent: 360 × 320 group: 400 × 300 frame: 400 × 300 note: 208 × 100 compare: 500 × 380 +render: 300 × 420 mixer: 360 × 320 ``` --- +## Mixer V1 (Merge Node) + +`mixer` ist in V1 ein bewusst enger 2-Layer-Blend-Node. + +- **Handles:** genau zwei Inputs links (`base`, `overlay`) und ein Output rechts (`mixer-out`). +- **Erlaubte Inputs:** `image`, `asset`, `ai-image`, `render`. +- **Connection-Limits:** maximal 2 eingehende Kanten insgesamt, davon pro Handle genau 1. +- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `offsetX`, `offsetY`. +- **Output-Semantik:** pseudo-image (clientseitig aus Graph + Controls aufgeloest), kein persistiertes Asset, kein Storage-Write. +- **UI/Interaction:** nur Inline-Formcontrols im Node; keine Drag-Manipulation im Preview, keine Rotation/Skalierung/Masks. + +### Compare-Integration (V1) + +- `compare` versteht `mixer`-Outputs ueber `lib/canvas-mixer-preview.ts`. +- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Offset). +- Scope bleibt eng: keine pauschale pseudo-image-Unterstuetzung fuer alle Consumer in V1. + +--- + ## Node-Status-Modell ``` @@ -291,6 +313,8 @@ useCanvasData (use-canvas-data.ts) - **Optimistic IDs:** Temporäre Nodes/Edges erhalten IDs mit `optimistic_` / `optimistic_edge_`-Prefix, werden durch echte Convex-IDs ersetzt, sobald die Mutation abgeschlossen ist. - **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`. - **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt. +- **Mixer-Connection-Policy:** `mixer` akzeptiert nur `image|asset|ai-image|render`; Ziel-Handles sind nur `base` und `overlay`, pro Handle maximal eine eingehende Kante, insgesamt maximal zwei. +- **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Downstream-Nodes muessen den pseudo-image-Resolver nutzen (aktuell gezielt fuer `compare`). - **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen. - **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren. - **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`. diff --git a/components/canvas/__tests__/canvas-helpers.test.ts b/components/canvas/__tests__/canvas-helpers.test.ts index a896b2b..f1fe815 100644 --- a/components/canvas/__tests__/canvas-helpers.test.ts +++ b/components/canvas/__tests__/canvas-helpers.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { computeEdgeInsertLayout, computeEdgeInsertReflowPlan, + getSingleCharacterHotkey, withResolvedCompareData, } from "../canvas-helpers"; import { @@ -315,6 +316,24 @@ describe("canvas preview graph helpers", () => { }); }); +describe("getSingleCharacterHotkey", () => { + it("returns a lowercase printable hotkey for single-character keys", () => { + expect(getSingleCharacterHotkey({ key: "K", type: "keydown" })).toBe("k"); + expect(getSingleCharacterHotkey({ key: "v", type: "keydown" })).toBe("v"); + expect(getSingleCharacterHotkey({ key: "Escape", type: "keydown" })).toBe(""); + }); + + it("returns an empty string and logs when the event has no string key", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + expect(getSingleCharacterHotkey({ type: "keydown" } as { key?: string; type: string })).toBe(""); + expect(warnSpy).toHaveBeenCalledWith("[Canvas] keyboard event missing string key", { + eventType: "keydown", + key: undefined, + }); + }); +}); + describe("computeEdgeInsertLayout", () => { it("shifts source and target along a horizontal axis when spacing is too tight", () => { const source = createNode({ diff --git a/components/canvas/__tests__/compare-node.test.tsx b/components/canvas/__tests__/compare-node.test.tsx index 39678d2..9e9f14b 100644 --- a/components/canvas/__tests__/compare-node.test.tsx +++ b/components/canvas/__tests__/compare-node.test.tsx @@ -166,4 +166,99 @@ describe("CompareNode render preview inputs", () => { preferPreview: true, }); }); + + it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => { + storeState.nodes = [ + { + id: "base-image", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-image", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + blendMode: "multiply", + opacity: 62, + offsetX: 12, + offsetY: -4, + }, + }, + { + id: "right-image", + type: "image", + data: { url: "https://cdn.example.com/right.png" }, + }, + ]; + storeState.edges = [ + { + id: "edge-base-mixer", + source: "base-image", + target: "mixer-1", + targetHandle: "base", + }, + { + id: "edge-overlay-mixer", + source: "overlay-image", + target: "mixer-1", + targetHandle: "overlay", + }, + { + id: "edge-mixer-compare", + source: "mixer-1", + target: "compare-1", + targetHandle: "left", + }, + { + id: "edge-image-compare", + source: "right-image", + target: "compare-1", + targetHandle: "right", + }, + ]; + + renderCompareNode({ + id: "compare-1", + data: { + leftUrl: "https://cdn.example.com/base.png", + rightUrl: "https://cdn.example.com/right.png", + }, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "compare", + xPos: 0, + yPos: 0, + width: 500, + height: 380, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }); + + expect(compareSurfaceSpy).toHaveBeenCalledTimes(2); + const mixerCall = compareSurfaceSpy.mock.calls.find( + ([props]) => + Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState), + ); + expect(mixerCall?.[0]).toMatchObject({ + finalUrl: undefined, + mixerPreviewState: { + status: "ready", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "multiply", + opacity: 62, + offsetX: 12, + offsetY: -4, + }, + }); + }); }); diff --git a/components/canvas/__tests__/mixer-node.test.tsx b/components/canvas/__tests__/mixer-node.test.tsx new file mode 100644 index 0000000..d4c8e75 --- /dev/null +++ b/components/canvas/__tests__/mixer-node.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context"; + +const mocks = vi.hoisted(() => ({ + queueNodeDataUpdate: vi.fn(async () => undefined), +})); + +vi.mock("@xyflow/react", () => ({ + Handle: ({ id, type }: { id?: string; type: string }) => ( +
+ ), + Position: { Left: "left", Right: "right" }, +})); + +vi.mock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: mocks.queueNodeDataUpdate, + queueNodeResize: vi.fn(async () => undefined), + status: { pendingCount: 0, isSyncing: false, isOffline: false }, + }), +})); + +vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +import MixerNode from "@/components/canvas/nodes/mixer-node"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type TestNode = { + id: string; + type: string; + data?: unknown; +}; + +type TestEdge = { + id: string; + source: string; + target: string; + targetHandle?: string; +}; + +function buildMixerNodeProps(overrides?: Partial>) { + return { + id: "mixer-1", + data: { + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "mixer", + xPos: 0, + yPos: 0, + width: 360, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + ...overrides, + } as React.ComponentProps; +} + +describe("MixerNode", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + mocks.queueNodeDataUpdate.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + async function renderNode(args?: { + nodes?: TestNode[]; + edges?: TestEdge[]; + props?: Partial>; + }) { + const nodes = args?.nodes ?? [{ id: "mixer-1", type: "mixer", data: {} }]; + const edges = args?.edges ?? []; + + await act(async () => { + root?.render( + + + , + ); + }); + } + + it("renders empty state copy when no inputs are connected", async () => { + await renderNode(); + + expect(container?.textContent).toContain("Connect base and overlay images"); + }); + + it("renders partial state copy when only one input is connected", async () => { + await renderNode({ + nodes: [ + { id: "image-1", type: "image", data: { url: "https://cdn.example.com/base.png" } }, + { id: "mixer-1", type: "mixer", data: {} }, + ], + edges: [{ id: "edge-base", source: "image-1", target: "mixer-1", targetHandle: "base" }], + }); + + expect(container?.textContent).toContain("Waiting for second input"); + }); + + it("renders ready state with stacked base and overlay previews", async () => { + await renderNode({ + nodes: [ + { id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } }, + { id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay.png" } }, + { + id: "mixer-1", + type: "mixer", + data: { blendMode: "multiply", opacity: 60, offsetX: 14, offsetY: -8 }, + }, + ], + edges: [ + { id: "edge-base", source: "image-base", target: "mixer-1", targetHandle: "base" }, + { + id: "edge-overlay", + source: "image-overlay", + target: "mixer-1", + targetHandle: "overlay", + }, + ], + }); + + const baseImage = container?.querySelector('img[alt="Mixer base"]'); + const overlayImage = container?.querySelector('img[alt="Mixer overlay"]'); + + expect(baseImage).toBeTruthy(); + expect(overlayImage).toBeTruthy(); + }); + + it("queues node data updates for blend mode, opacity, and overlay offsets", async () => { + await renderNode(); + + const blendMode = container?.querySelector('select[name="blendMode"]'); + const opacity = container?.querySelector('input[name="opacity"]'); + const offsetX = container?.querySelector('input[name="offsetX"]'); + const offsetY = container?.querySelector('input[name="offsetY"]'); + + if (!(blendMode instanceof HTMLSelectElement)) { + throw new Error("blendMode select not found"); + } + if (!(opacity instanceof HTMLInputElement)) { + throw new Error("opacity input not found"); + } + if (!(offsetX instanceof HTMLInputElement)) { + throw new Error("offsetX input not found"); + } + if (!(offsetY instanceof HTMLInputElement)) { + throw new Error("offsetY input not found"); + } + + await act(async () => { + blendMode.value = "screen"; + blendMode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({ + nodeId: "mixer-1", + data: expect.objectContaining({ blendMode: "screen" }), + }); + + await act(async () => { + opacity.value = "45"; + opacity.dispatchEvent(new Event("input", { bubbles: true })); + opacity.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({ + nodeId: "mixer-1", + data: expect.objectContaining({ opacity: 45 }), + }); + + await act(async () => { + offsetX.value = "12"; + offsetX.dispatchEvent(new Event("input", { bubbles: true })); + offsetX.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({ + nodeId: "mixer-1", + data: expect.objectContaining({ offsetX: 12 }), + }); + + await act(async () => { + offsetY.value = "-6"; + offsetY.dispatchEvent(new Event("input", { bubbles: true })); + offsetY.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({ + nodeId: "mixer-1", + data: expect.objectContaining({ offsetY: -6 }), + }); + }); + + it("renders expected mixer handles", async () => { + await renderNode(); + + expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy(); + expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy(); + expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy(); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index 0b9a849..2d67fad 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -22,16 +22,12 @@ vi.mock("@/components/canvas/canvas-helpers", async () => { }; }); -vi.mock("@/components/canvas/canvas-reconnect", () => ({ - useCanvasReconnectHandlers: () => ({ - onReconnectStart: vi.fn(), - onReconnect: vi.fn(), - onReconnectEnd: vi.fn(), - }), -})); - import { useCanvasConnections } from "@/components/canvas/use-canvas-connections"; import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; +import { nodeTypes } from "@/components/canvas/node-types"; +import { NODE_CATALOG } from "@/lib/canvas-node-catalog"; +import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; +import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; @@ -45,7 +41,10 @@ type HookHarnessProps = { helperResult: DroppedConnectionTarget | null; runCreateEdgeMutation?: ReturnType; runSplitEdgeAtExistingNodeMutation?: ReturnType; + runRemoveEdgeMutation?: ReturnType; + runSwapMixerInputsMutation?: ReturnType; showConnectionRejectedToast?: ReturnType; + setEdgesMock?: ReturnType; nodes?: RFNode[]; edges?: RFEdge[]; }; @@ -54,7 +53,10 @@ function HookHarness({ helperResult, runCreateEdgeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), + runRemoveEdgeMutation = vi.fn(async () => undefined), + runSwapMixerInputsMutation = vi.fn(async () => undefined), showConnectionRejectedToast = vi.fn(), + setEdgesMock, nodes: providedNodes, edges: providedEdges, }: HookHarnessProps) { @@ -71,7 +73,7 @@ function HookHarness({ const isReconnectDragActiveRef = useRef(false); const pendingConnectionCreatesRef = useRef(new Set()); const resolvedRealIdByClientRequestRef = useRef(new Map>()); - const setEdges = vi.fn(); + const setEdges = setEdgesMock ?? vi.fn(); const setEdgeSyncNonce = vi.fn(); useEffect(() => { @@ -102,7 +104,8 @@ function HookHarness({ syncPendingMoveForClientRequest: vi.fn(async () => undefined), runCreateEdgeMutation, runSplitEdgeAtExistingNodeMutation, - runRemoveEdgeMutation: vi.fn(async () => undefined), + runRemoveEdgeMutation, + runSwapMixerInputsMutation, runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"), runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"), showConnectionRejectedToast, @@ -132,6 +135,47 @@ describe("useCanvasConnections", () => { container = null; }); + it("exposes mixer metadata required for placement and connection defaults", () => { + const mixerCatalogEntry = NODE_CATALOG.find((entry) => entry.type === "mixer"); + const mixerTemplate = CANVAS_NODE_TEMPLATES.find( + (template) => (template.type as string) === "mixer", + ); + + expect(nodeTypes).toHaveProperty("mixer"); + expect(mixerCatalogEntry).toEqual( + expect.objectContaining({ + type: "mixer", + category: "control", + implemented: true, + }), + ); + expect(mixerTemplate).toEqual( + expect.objectContaining({ + type: "mixer", + defaultData: { + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }, + }), + ); + expect(NODE_HANDLE_MAP.mixer).toEqual({ + source: "mixer-out", + target: "base", + }); + expect(NODE_DEFAULTS.mixer).toEqual( + expect.objectContaining({ + data: { + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }, + }), + ); + }); + it("creates an edge when a body drop lands on another node", async () => { const runCreateEdgeMutation = vi.fn(async () => undefined); const showConnectionRejectedToast = vi.fn(); @@ -490,6 +534,320 @@ describe("useCanvasConnections", () => { expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); }); + it("allows image-like sources to connect to mixer", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: "base", + }); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + }); + + it("rejects disallowed source types to mixer", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "video" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-source-invalid"); + }); + + it("rejects a second connection to the same mixer handle", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnect({ + source: "node-source", + target: "node-target", + sourceHandle: null, + targetHandle: "base", + }); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-handle-incoming-limit"); + }); + + it("allows one incoming edge per mixer handle", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "asset" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: "overlay", + }); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + }); + + it("rejects a third incoming edge to mixer", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "render" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-incoming-limit"); + }); + it("ignores onConnectEnd when no connect drag is active", async () => { const runCreateEdgeMutation = vi.fn(async () => undefined); const showConnectionRejectedToast = vi.fn(); @@ -535,4 +893,364 @@ describe("useCanvasConnections", () => { expect(showConnectionRejectedToast).not.toHaveBeenCalled(); expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); }); + + it("passes edgeIdToIgnore during reconnect replacement without client-side old-edge delete", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const runRemoveEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-1", + source: "node-source", + target: "node-target", + targetHandle: "base", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source", + target: "node-target", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: "overlay", + edgeIdToIgnore: "edge-1", + }); + expect(runRemoveEdgeMutation).not.toHaveBeenCalled(); + }); + + it("does not remove old edge when reconnect create fails", async () => { + const runCreateEdgeMutation = vi.fn(async () => { + throw new Error("incoming limit reached"); + }); + const runRemoveEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-1", + source: "node-source", + target: "node-target", + targetHandle: "base", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source", + target: "node-target", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledTimes(1); + expect(runRemoveEdgeMutation).not.toHaveBeenCalled(); + }); + + it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (base->overlay)", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const runRemoveEdgeMutation = vi.fn(async () => undefined); + const runSwapMixerInputsMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + const setEdgesMock = vi.fn(); + + const initialEdges: RFEdge[] = [ + { + id: "edge-base", + source: "node-source-base", + target: "node-mixer", + targetHandle: "base", + }, + { + id: "edge-overlay", + source: "node-source-overlay", + target: "node-mixer", + targetHandle: "overlay", + }, + ]; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = initialEdges[0] as RFEdge; + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source-base", + target: "node-mixer", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(runRemoveEdgeMutation).not.toHaveBeenCalled(); + expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + edgeId: "edge-base", + otherEdgeId: "edge-overlay", + }); + + expect(setEdgesMock).toHaveBeenCalledTimes(1); + const applyEdges = setEdgesMock.mock.calls[0]?.[0] as ((edges: RFEdge[]) => RFEdge[]); + const swappedEdges = applyEdges(initialEdges); + const baseEdge = swappedEdges.find((edge) => edge.id === "edge-base"); + const overlayEdge = swappedEdges.find((edge) => edge.id === "edge-overlay"); + expect(baseEdge?.targetHandle).toBe("overlay"); + expect(overlayEdge?.targetHandle).toBe("base"); + }); + + it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (overlay->base)", async () => { + const runSwapMixerInputsMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-overlay", + source: "node-source-overlay", + target: "node-mixer", + targetHandle: "overlay", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source-overlay", + target: "node-mixer", + sourceHandle: null, + targetHandle: "base", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + edgeId: "edge-overlay", + otherEdgeId: "edge-base", + }); + }); + + it("does not swap mixer reconnect when target mixer is not fully populated", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const runSwapMixerInputsMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-base", + source: "node-source-base", + target: "node-mixer", + targetHandle: "base", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source-base", + target: "node-mixer", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source-base", + targetNodeId: "node-mixer", + sourceHandle: undefined, + targetHandle: "overlay", + edgeIdToIgnore: "edge-base", + }); + }); + + it("does not perform mixer swap for non-mixer reconnect validation failures", async () => { + const runSwapMixerInputsMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-1", + source: "node-image", + target: "node-render", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-image", + target: "node-image", + sourceHandle: null, + targetHandle: null, + }); + }); + + expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + }); }); diff --git a/components/canvas/canvas-command-palette.tsx b/components/canvas/canvas-command-palette.tsx index 5b07aa7..be5bf35 100644 --- a/components/canvas/canvas-command-palette.tsx +++ b/components/canvas/canvas-command-palette.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; +import { getSingleCharacterHotkey } from "@/components/canvas/canvas-helpers"; import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position"; import { Command, @@ -98,7 +99,7 @@ export function CanvasCommandPalette() { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (!e.metaKey && !e.ctrlKey) return; - if (e.key.toLowerCase() !== "k") return; + if (getSingleCharacterHotkey(e) !== "k") return; e.preventDefault(); setOpen((prev) => !prev); }; diff --git a/components/canvas/canvas-connection-validation.ts b/components/canvas/canvas-connection-validation.ts index 3406ef6..847da4e 100644 --- a/components/canvas/canvas-connection-validation.ts +++ b/components/canvas/canvas-connection-validation.ts @@ -4,6 +4,7 @@ import { validateCanvasConnectionPolicy, type CanvasConnectionValidationReason, } from "@/lib/canvas-connection-policy"; +import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { isOptimisticEdgeId } from "./canvas-helpers"; @@ -27,6 +28,7 @@ export function validateCanvasConnection( sourceType: sourceNode.type ?? "", targetType: targetNode.type ?? "", targetNodeId: connection.target, + targetHandle: connection.targetHandle, edges, edgeToReplaceId, includeOptimisticEdges: options?.includeOptimisticEdges, @@ -37,22 +39,25 @@ export function validateCanvasConnectionByType(args: { sourceType: string; targetType: string; targetNodeId: string; + targetHandle?: string | null; edges: RFEdge[]; edgeToReplaceId?: string; includeOptimisticEdges?: boolean; }): CanvasConnectionValidationReason | null { - const targetIncomingCount = args.edges.filter( + const targetIncomingEdges = args.edges.filter( (edge) => edge.className !== "temp" && (args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) && edge.target === args.targetNodeId && edge.id !== args.edgeToReplaceId, - ).length; + ); return validateCanvasConnectionPolicy({ sourceType: args.sourceType, targetType: args.targetType, - targetIncomingCount, + targetIncomingCount: targetIncomingEdges.length, + targetHandle: args.targetHandle, + targetIncomingHandles: targetIncomingEdges.map((edge) => edge.targetHandle), }); } @@ -69,17 +74,21 @@ export function validateCanvasEdgeSplit(args: { return "unknown-node"; } + const middleNodeHandles = NODE_HANDLE_MAP[args.middleNode.type ?? ""]; + return ( validateCanvasConnectionByType({ sourceType: sourceNode.type ?? "", targetType: args.middleNode.type ?? "", targetNodeId: args.middleNode.id, + targetHandle: middleNodeHandles?.target, edges: args.edges, }) ?? validateCanvasConnectionByType({ sourceType: args.middleNode.type ?? "", targetType: targetNode.type ?? "", targetNodeId: targetNode.id, + targetHandle: args.splitEdge.targetHandle, edges: args.edges, edgeToReplaceId: args.splitEdge.id, }) diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 72f020d..816fe6f 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -759,6 +759,18 @@ export function isEditableKeyboardTarget(target: EventTarget | null): boolean { return target.closest("input, textarea, select, [contenteditable=true]") !== null; } +export function getSingleCharacterHotkey(event: { key?: string; type: string }): string { + if (typeof event.key !== "string") { + console.warn("[Canvas] keyboard event missing string key", { + eventType: event.type, + key: event.key, + }); + return ""; + } + + return event.key.length === 1 ? event.key.toLowerCase() : ""; +} + export function isEdgeCuttable(edge: RFEdge): boolean { if (edge.className === "temp") return false; if (isOptimisticEdgeId(edge.id)) return false; diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index da0fefd..792293f 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -11,6 +11,7 @@ import { Image, Package, Palette, + Layers, Sparkles, StickyNote, Sun, @@ -43,6 +44,7 @@ const NODE_ICONS: Record = { "light-adjust": Sun, "detail-adjust": Focus, render: ImageDown, + mixer: Layers, }; const NODE_SEARCH_KEYWORDS: Partial< diff --git a/components/canvas/canvas-reconnect.ts b/components/canvas/canvas-reconnect.ts index a728575..000ec0b 100644 --- a/components/canvas/canvas-reconnect.ts +++ b/components/canvas/canvas-reconnect.ts @@ -16,12 +16,28 @@ type UseCanvasReconnectHandlersParams = { targetNodeId: Id<"nodes">; sourceHandle?: string; targetHandle?: string; + edgeIdToIgnore?: Id<"edges">; + }) => Promise; + runSwapMixerInputsMutation?: (args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; }) => Promise; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; validateConnection?: ( oldEdge: RFEdge, newConnection: Connection, ) => string | null; + resolveMixerSwapReconnect?: ( + oldEdge: RFEdge, + newConnection: Connection, + validationError: string, + ) => { + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + nextEdgeHandle: "base" | "overlay"; + nextOtherEdgeHandle: "base" | "overlay"; + } | null; onInvalidConnection?: (message: string) => void; }; @@ -31,18 +47,29 @@ export function useCanvasReconnectHandlers({ isReconnectDragActiveRef, setEdges, runCreateEdgeMutation, + runSwapMixerInputsMutation, runRemoveEdgeMutation, validateConnection, + resolveMixerSwapReconnect, onInvalidConnection, }: UseCanvasReconnectHandlersParams): { onReconnectStart: () => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void; } { - const pendingReconnectRef = useRef<{ - oldEdge: RFEdge; - newConnection: Connection; - } | null>(null); + const pendingReconnectRef = useRef< + | { + kind: "replace"; + oldEdge: RFEdge; + newConnection: Connection; + } + | { + kind: "swap"; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + } + | null + >(null); const onReconnectStart = useCallback(() => { edgeReconnectSuccessful.current = false; @@ -54,6 +81,38 @@ export function useCanvasReconnectHandlers({ (oldEdge: RFEdge, newConnection: Connection) => { const validationError = validateConnection?.(oldEdge, newConnection) ?? null; if (validationError) { + const swapReconnect = resolveMixerSwapReconnect?.( + oldEdge, + newConnection, + validationError, + ); + if (swapReconnect) { + edgeReconnectSuccessful.current = true; + pendingReconnectRef.current = { + kind: "swap", + edgeId: swapReconnect.edgeId, + otherEdgeId: swapReconnect.otherEdgeId, + }; + setEdges((currentEdges) => + currentEdges.map((candidate) => { + if (candidate.id === swapReconnect.edgeId) { + return { + ...candidate, + targetHandle: swapReconnect.nextEdgeHandle, + }; + } + if (candidate.id === swapReconnect.otherEdgeId) { + return { + ...candidate, + targetHandle: swapReconnect.nextOtherEdgeHandle, + }; + } + return candidate; + }), + ); + return; + } + edgeReconnectSuccessful.current = true; pendingReconnectRef.current = null; onInvalidConnection?.(validationError); @@ -61,10 +120,20 @@ export function useCanvasReconnectHandlers({ } edgeReconnectSuccessful.current = true; - pendingReconnectRef.current = { oldEdge, newConnection }; + pendingReconnectRef.current = { + kind: "replace", + oldEdge, + newConnection, + }; setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges)); }, - [edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection], + [ + edgeReconnectSuccessful, + onInvalidConnection, + resolveMixerSwapReconnect, + setEdges, + validateConnection, + ], ); const onReconnectEnd = useCallback( @@ -95,32 +164,35 @@ export function useCanvasReconnectHandlers({ const pendingReconnect = pendingReconnectRef.current; pendingReconnectRef.current = null; - if ( - pendingReconnect && - pendingReconnect.newConnection.source && - pendingReconnect.newConnection.target - ) { + if (pendingReconnect?.kind === "replace" && pendingReconnect.newConnection.source && pendingReconnect.newConnection.target) { void runCreateEdgeMutation({ canvasId, sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">, targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">, sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined, targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined, - }).catch((error) => { - console.error("[Canvas edge reconnect failed] create edge", { - oldEdgeId: pendingReconnect.oldEdge.id, - source: pendingReconnect.newConnection.source, - target: pendingReconnect.newConnection.target, - error: String(error), - }); - }); - - if (pendingReconnect.oldEdge.className !== "temp") { - void runRemoveEdgeMutation({ - edgeId: pendingReconnect.oldEdge.id as Id<"edges">, - }).catch((error) => { - console.error("[Canvas edge reconnect failed] remove old edge", { + edgeIdToIgnore: pendingReconnect.oldEdge.id as Id<"edges">, + }) + .catch((error) => { + console.error("[Canvas edge reconnect failed] create edge", { oldEdgeId: pendingReconnect.oldEdge.id, + source: pendingReconnect.newConnection.source, + target: pendingReconnect.newConnection.target, + error: String(error), + }); + }); + } + + if (pendingReconnect?.kind === "swap") { + if (runSwapMixerInputsMutation) { + void runSwapMixerInputsMutation({ + canvasId, + edgeId: pendingReconnect.edgeId, + otherEdgeId: pendingReconnect.otherEdgeId, + }).catch((error) => { + console.error("[Canvas edge reconnect failed] swap mixer inputs", { + edgeId: pendingReconnect.edgeId, + otherEdgeId: pendingReconnect.otherEdgeId, error: String(error), }); }); @@ -138,6 +210,7 @@ export function useCanvasReconnectHandlers({ isReconnectDragActiveRef, runCreateEdgeMutation, runRemoveEdgeMutation, + runSwapMixerInputsMutation, setEdges, ], ); diff --git a/components/canvas/canvas-scissors.ts b/components/canvas/canvas-scissors.ts index 9e57241..f282199 100644 --- a/components/canvas/canvas-scissors.ts +++ b/components/canvas/canvas-scissors.ts @@ -12,6 +12,7 @@ import type { Id } from "@/convex/_generated/dataModel"; import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar"; import { collectCuttableEdgesAlongScreenSegment, + getSingleCharacterHotkey, getIntersectedEdgeId, isEdgeCuttable, isEditableKeyboardTarget, @@ -50,8 +51,7 @@ export function useCanvasScissors({ return; } if (event.metaKey || event.ctrlKey || event.altKey) return; - const isScissorHotkey = - event.key.length === 1 && event.key.toLowerCase() === "k"; + const isScissorHotkey = getSingleCharacterHotkey(event) === "k"; if (!isScissorHotkey) return; if (isEditableKeyboardTarget(event.target)) return; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 0c4330b..0eb8ae1 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -56,6 +56,7 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import { CANVAS_MIN_ZOOM, DEFAULT_EDGE_OPTIONS, + getSingleCharacterHotkey, getMiniMapNodeColor, getMiniMapNodeStrokeColor, getPendingRemovedEdgeIdsFromLocalOps, @@ -100,6 +101,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia); + const runSwapMixerInputsMutation = useMutation(api.edges.swapMixerInputs); const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set()); const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< string | null @@ -237,7 +239,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const onKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (isEditableKeyboardTarget(e.target)) return; - const key = e.key.length === 1 ? e.key.toLowerCase() : ""; + const key = getSingleCharacterHotkey(e); if (key === "v") { e.preventDefault(); handleNavToolChange("select"); @@ -342,6 +344,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { runCreateEdgeMutation, runSplitEdgeAtExistingNodeMutation, runRemoveEdgeMutation, + runSwapMixerInputsMutation, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, showConnectionRejectedToast, diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts index e31d1f1..3a0c56e 100644 --- a/components/canvas/node-types.ts +++ b/components/canvas/node-types.ts @@ -18,6 +18,7 @@ import RenderNode from "./nodes/render-node"; import CropNode from "./nodes/crop-node"; import AgentNode from "./nodes/agent-node"; import AgentOutputNode from "./nodes/agent-output-node"; +import MixerNode from "./nodes/mixer-node"; /** * Node-Type-Map für React Flow. @@ -46,5 +47,6 @@ export const nodeTypes = { crop: CropNode, render: RenderNode, agent: AgentNode, + mixer: MixerNode, "agent-output": AgentOutputNode, } as const; diff --git a/components/canvas/nodes/agent-output-node.tsx b/components/canvas/nodes/agent-output-node.tsx index e089c03..d1679e4 100644 --- a/components/canvas/nodes/agent-output-node.tsx +++ b/components/canvas/nodes/agent-output-node.tsx @@ -20,6 +20,7 @@ type AgentOutputNodeData = { content?: string; }>; metadata?: Record; + metadataLabels?: Record; qualityChecks?: string[]; outputType?: string; body?: string; @@ -102,6 +103,18 @@ function normalizeMetadata(raw: AgentOutputNodeData["metadata"]) { return entries; } +function resolveMetadataLabel( + key: string, + rawLabels: AgentOutputNodeData["metadataLabels"], +): string { + if (!rawLabels || typeof rawLabels !== "object" || Array.isArray(rawLabels)) { + return key; + } + + const candidate = rawLabels[key]; + return typeof candidate === "string" && candidate.trim() !== "" ? candidate.trim() : key; +} + function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): string[] { if (!Array.isArray(raw)) { return []; @@ -113,6 +126,66 @@ function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): stri .filter((value) => value !== ""); } +function normalizeSectionToken(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function partitionSections( + sections: Array<{ id: string; label: string; content: string }>, + artifactType: string, +) { + const artifactToken = normalizeSectionToken(artifactType); + const priorityTokens = artifactToken === "socialcaptionpack" ? ["caption", "hashtags", "cta"] : []; + const isSecondaryNote = (label: string) => { + const token = normalizeSectionToken(label); + return token.includes("formatnote") || token.includes("assumption"); + }; + + const primaryWithIndex: Array<{ section: (typeof sections)[number]; index: number }> = []; + const secondary: Array<{ id: string; label: string; content: string }> = []; + + sections.forEach((section, index) => { + if (isSecondaryNote(section.label)) { + secondary.push(section); + return; + } + primaryWithIndex.push({ section, index }); + }); + + if (priorityTokens.length === 0) { + return { + primary: primaryWithIndex.map((entry) => entry.section), + secondary, + }; + } + + const priorityIndexByToken = new Map(priorityTokens.map((token, index) => [token, index])); + const primary = [...primaryWithIndex] + .sort((left, right) => { + const leftToken = normalizeSectionToken(left.section.label); + const rightToken = normalizeSectionToken(right.section.label); + const leftPriority = priorityIndexByToken.get(leftToken); + const rightPriority = priorityIndexByToken.get(rightToken); + + if (leftPriority !== undefined && rightPriority !== undefined) { + return leftPriority - rightPriority; + } + if (leftPriority !== undefined) { + return -1; + } + if (rightPriority !== undefined) { + return 1; + } + return left.index - right.index; + }) + .map((entry) => entry.section); + + return { + primary, + secondary, + }; +} + export default function AgentOutputNode({ data, selected }: NodeProps) { const t = useTranslations("agentOutputNode"); const nodeData = data as AgentOutputNodeData; @@ -140,14 +213,23 @@ export default function AgentOutputNode({ data, selected }: NodeProps 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || previewText !== ""; + const hasMetaValues = + (typeof nodeData.channel === "string" && nodeData.channel.trim() !== "") || artifactType.trim() !== ""; + const hasDetailsContent = + secondarySections.length > 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || hasMetaValues; const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body); return ( @@ -186,24 +268,6 @@ export default function AgentOutputNode({ data, selected }: NodeProps -
-
-

{t("channelLabel")}

-

- {nodeData.channel ?? t("emptyValue")} -

-
-
-

{t("artifactTypeLabel")}

-

- {artifactType || t("emptyValue")} -

-
-
- {isSkeleton ? (

@@ -218,11 +282,20 @@ export default function AgentOutputNode({ data, selected }: NodeProps ) : hasStructuredOutput ? ( <> - {sections.length > 0 ? ( + {previewText !== "" ? ( +

+

{t("previewLabel")}

+
+

{previewText}

+
+
+ ) : null} + + {primarySections.length > 0 ? (

{t("sectionsLabel")}

- {sections.map((section) => ( + {primarySections.map((section) => (

{section.label}

@@ -234,41 +307,77 @@ export default function AgentOutputNode({ data, selected }: NodeProps ) : null} - {metadataEntries.length > 0 ? ( -

-

{t("metadataLabel")}

-
- {metadataEntries.map(([key, value]) => ( -

- {key}: {value} -

- ))} -
-
- ) : null} - - {qualityChecks.length > 0 ? ( -
-

{t("qualityChecksLabel")}

-
- {qualityChecks.map((qualityCheck) => ( - + {t("detailsLabel")} +
+ {hasMetaValues ? ( +
- {qualityCheck} - - ))} -
-
- ) : null} +
+

{t("channelLabel")}

+

+ {nodeData.channel ?? t("emptyValue")} +

+
+
+

{t("artifactTypeLabel")}

+

+ {artifactType || t("emptyValue")} +

+
+
+ ) : null} -
-

{t("previewLabel")}

-
-

{previewText || t("previewFallback")}

-
-
+ {secondarySections.length > 0 ? ( +
+

{t("sectionsLabel")}

+
+ {secondarySections.map((section) => ( +
+

{section.label}

+

+ {section.content} +

+
+ ))} +
+
+ ) : null} + + {metadataEntries.length > 0 ? ( +
+

{t("metadataLabel")}

+
+ {metadataEntries.map(([key, value]) => ( +

+ {resolveMetadataLabel(key, metadataLabels)}: {value} +

+ ))} +
+
+ ) : null} + + {qualityChecks.length > 0 ? ( +
+

{t("qualityChecksLabel")}

+
+ {qualityChecks.map((qualityCheck) => ( + + {qualityCheck} + + ))} +
+
+ ) : null} +
+ + ) : null} ) : formattedJsonBody ? (
diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index 6c8fa98..715945a 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -11,6 +11,10 @@ import { resolveRenderPreviewInputFromGraph, type RenderPreviewInput, } from "@/lib/canvas-render-preview"; +import { + resolveMixerPreviewFromGraph, + type MixerPreviewState, +} from "@/lib/canvas-mixer-preview"; interface CompareNodeData { leftUrl?: string; @@ -25,6 +29,7 @@ type CompareSideState = { finalUrl?: string; label?: string; previewInput?: RenderPreviewInput; + mixerPreviewState?: MixerPreviewState; isStaleRenderOutput: boolean; }; @@ -59,6 +64,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { const label = finalLabel ?? sourceLabel ?? defaultLabel; let previewInput: RenderPreviewInput | undefined; + let mixerPreviewState: MixerPreviewState | undefined; let isStaleRenderOutput = false; if (sourceNode && sourceNode.type === "render") { @@ -97,11 +103,36 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { } } - if (finalUrl) { - return { finalUrl, label, previewInput, isStaleRenderOutput }; + if (sourceNode && sourceNode.type === "mixer") { + const mixerPreview = resolveMixerPreviewFromGraph({ + nodeId: sourceNode.id, + graph, + }); + + if (mixerPreview.status === "ready") { + mixerPreviewState = mixerPreview; + } } - return { label, previewInput, isStaleRenderOutput }; + const visibleFinalUrl = + sourceNode?.type === "mixer" && mixerPreviewState ? undefined : finalUrl; + + if (visibleFinalUrl) { + return { + finalUrl: visibleFinalUrl, + label, + previewInput, + mixerPreviewState, + isStaleRenderOutput, + }; + } + + return { + label, + previewInput, + mixerPreviewState, + isStaleRenderOutput, + }; }; return { @@ -117,8 +148,16 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { graph, ]); - const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput); - const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput); + const hasLeft = Boolean( + resolvedSides.left.finalUrl || + resolvedSides.left.previewInput || + resolvedSides.left.mixerPreviewState, + ); + const hasRight = Boolean( + resolvedSides.right.finalUrl || + resolvedSides.right.previewInput || + resolvedSides.right.mixerPreviewState, + ); const hasConnectedRenderInput = useMemo( () => incomingEdges.some((edge) => { @@ -273,6 +312,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { finalUrl={resolvedSides.right.finalUrl} label={resolvedSides.right.label} previewInput={resolvedSides.right.previewInput} + mixerPreviewState={resolvedSides.right.mixerPreviewState} nodeWidth={previewNodeWidth} preferPreview={effectiveDisplayMode === "preview"} /> @@ -283,6 +323,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { finalUrl={resolvedSides.left.finalUrl} label={resolvedSides.left.label} previewInput={resolvedSides.left.previewInput} + mixerPreviewState={resolvedSides.left.mixerPreviewState} nodeWidth={previewNodeWidth} clipWidthPercent={sliderX} preferPreview={effectiveDisplayMode === "preview"} diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx index 9df4f5b..a5411c3 100644 --- a/components/canvas/nodes/compare-surface.tsx +++ b/components/canvas/nodes/compare-surface.tsx @@ -6,6 +6,7 @@ import { shouldFastPathPreviewPipeline, type RenderPreviewInput, } from "@/lib/canvas-render-preview"; +import type { MixerPreviewState } from "@/lib/canvas-mixer-preview"; const EMPTY_STEPS: RenderPreviewInput["steps"] = []; @@ -13,6 +14,7 @@ type CompareSurfaceProps = { finalUrl?: string; label?: string; previewInput?: RenderPreviewInput; + mixerPreviewState?: MixerPreviewState; nodeWidth: number; clipWidthPercent?: number; preferPreview?: boolean; @@ -22,6 +24,7 @@ export default function CompareSurface({ finalUrl, label, previewInput, + mixerPreviewState, nodeWidth, clipWidthPercent, preferPreview, @@ -52,6 +55,7 @@ export default function CompareSurface({ }); const hasPreview = Boolean(usePreview && previewInput); + const hasMixerPreview = mixerPreviewState?.status === "ready"; const clipStyle = typeof clipWidthPercent === "number" ? { @@ -75,6 +79,28 @@ export default function CompareSurface({ ref={canvasRef} className="absolute inset-0 h-full w-full object-contain" /> + ) : hasMixerPreview ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {label + {/* eslint-disable-next-line @next/next/no-img-element */} + {label + ) : null} {hasPreview ? ( diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index e232362..83b10be 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -71,6 +71,7 @@ export default function ImageNode({ height, }: NodeProps) { const t = useTranslations('toasts'); + const tMedia = useTranslations("mediaLibrary.imageNode"); const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); @@ -377,7 +378,7 @@ export default function ImageNode({ } if (item.kind !== "image" || !item.storageId) { - toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden."); + toast.error(t('canvas.uploadFailed'), tMedia("invalidSelection")); return; } @@ -427,7 +428,7 @@ export default function ImageNode({ ); } }, - [data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t], + [data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t, tMedia], ); const handleClick = useCallback(() => { @@ -586,7 +587,9 @@ export default function ImageNode({ disabled={isNodeLoading || !isNodeStable} className="nodrag mt-3 inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60" > - {isNodeStable ? "Aus Mediathek" : "Mediathek wird vorbereitet..."} + {isNodeStable + ? tMedia("openButton") + : tMedia("preparingButton")} )} @@ -650,7 +653,7 @@ export default function ImageNode({ onOpenChange={setIsMediaLibraryOpen} onPick={handlePickFromMediaLibrary} kindFilter="image" - pickCtaLabel="Uebernehmen" + pickCtaLabel={tMedia("pickCta")} /> ); diff --git a/components/canvas/nodes/mixer-node.tsx b/components/canvas/nodes/mixer-node.tsx new file mode 100644 index 0000000..0511eff --- /dev/null +++ b/components/canvas/nodes/mixer-node.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useMemo, useState, type ChangeEvent, type FormEvent } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; +import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { + normalizeMixerPreviewData, + resolveMixerPreviewFromGraph, + type MixerBlendMode, +} from "@/lib/canvas-mixer-preview"; +import type { Id } from "@/convex/_generated/dataModel"; + +const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"]; + +export default function MixerNode({ id, data, selected }: NodeProps) { + const graph = useCanvasGraph(); + const { queueNodeDataUpdate } = useCanvasSync(); + const [hasImageLoadError, setHasImageLoadError] = useState(false); + + const normalizedData = useMemo(() => normalizeMixerPreviewData(data), [data]); + const previewState = useMemo( + () => resolveMixerPreviewFromGraph({ nodeId: id, graph }), + [graph, id], + ); + + const currentData = (data ?? {}) as Record; + + const updateData = (patch: Partial>) => { + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: { + ...currentData, + ...patch, + }, + }); + }; + + const onBlendModeChange = (event: ChangeEvent) => { + setHasImageLoadError(false); + updateData({ blendMode: event.target.value as MixerBlendMode }); + }; + + const onNumberChange = (field: "opacity" | "offsetX" | "offsetY") => ( + event: FormEvent, + ) => { + setHasImageLoadError(false); + const nextValue = Number(event.currentTarget.value); + updateData({ [field]: Number.isFinite(nextValue) ? nextValue : 0 }); + }; + + const showReadyPreview = previewState.status === "ready" && !hasImageLoadError; + const showPreviewError = hasImageLoadError || previewState.status === "error"; + + return ( + + + + + +
+
+ Mixer +
+ +
+ {showReadyPreview ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + Mixer base setHasImageLoadError(true)} + /> + {/* eslint-disable-next-line @next/next/no-img-element */} + Mixer overlay setHasImageLoadError(true)} + style={{ + mixBlendMode: previewState.blendMode, + opacity: previewState.opacity / 100, + transform: `translate(${previewState.offsetX}px, ${previewState.offsetY}px)`, + }} + /> + + ) : null} + + {previewState.status === "empty" && !showPreviewError ? ( +
+ Connect base and overlay images +
+ ) : null} + + {previewState.status === "partial" && !showPreviewError ? ( +
+ Waiting for second input +
+ ) : null} + + {showPreviewError ? ( +
+ Preview unavailable +
+ ) : null} +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index 2f0b336..e3ea26c 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -50,6 +50,7 @@ type UseCanvasConnectionsParams = { targetNodeId: Id<"nodes">; sourceHandle?: string; targetHandle?: string; + edgeIdToIgnore?: Id<"edges">; }) => Promise; runSplitEdgeAtExistingNodeMutation: (args: { canvasId: Id<"canvases">; @@ -61,6 +62,11 @@ type UseCanvasConnectionsParams = { newNodeTargetHandle?: string; }) => Promise; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; + runSwapMixerInputsMutation: (args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + }) => Promise; runCreateNodeWithEdgeFromSourceOnlineOnly: (args: { canvasId: Id<"canvases">; type: CanvasNodeType; @@ -113,6 +119,7 @@ export function useCanvasConnections({ runRemoveEdgeMutation, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, + runSwapMixerInputsMutation, showConnectionRejectedToast, }: UseCanvasConnectionsParams) { const [connectionDropMenu, setConnectionDropMenu] = @@ -178,6 +185,70 @@ export function useCanvasConnections({ [canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast], ); + const resolveMixerSwapReconnect = useCallback( + (oldEdge: RFEdge, newConnection: Connection, validationError: string) => { + if (validationError !== "mixer-handle-incoming-limit") { + return null; + } + + if (!newConnection.target || oldEdge.target !== newConnection.target) { + return null; + } + + const targetNode = nodes.find((node) => node.id === newConnection.target); + if (!targetNode || targetNode.type !== "mixer") { + return null; + } + + const normalizeMixerHandle = (handle: string | null | undefined): "base" | "overlay" | null => { + if (handle == null || handle === "" || handle === "null") { + return "base"; + } + if (handle === "base" || handle === "overlay") { + return handle; + } + return null; + }; + + const oldHandle = normalizeMixerHandle(oldEdge.targetHandle); + const requestedHandle = normalizeMixerHandle(newConnection.targetHandle); + if (!oldHandle || !requestedHandle || oldHandle === requestedHandle) { + return null; + } + + const mixerIncomingEdges = edges.filter( + (edge) => + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id) && + edge.target === newConnection.target, + ); + + if (mixerIncomingEdges.length !== 2) { + return null; + } + + const otherEdge = mixerIncomingEdges.find( + (candidate) => candidate.id !== oldEdge.id, + ); + if (!otherEdge) { + return null; + } + + const otherHandle = normalizeMixerHandle(otherEdge.targetHandle); + if (!otherHandle || otherHandle !== requestedHandle) { + return null; + } + + return { + edgeId: oldEdge.id as Id<"edges">, + otherEdgeId: otherEdge.id as Id<"edges">, + nextEdgeHandle: requestedHandle, + nextOtherEdgeHandle: oldHandle, + }; + }, + [edges, nodes], + ); + const onConnectEnd = useCallback( (event, connectionState) => { if (!isConnectDragActiveRef.current) { @@ -438,6 +509,7 @@ export function useCanvasConnections({ sourceType: fromNode.type ?? "", targetType: template.type, targetNodeId: `__pending_${template.type}_${Date.now()}`, + targetHandle: handles?.target, edges: edgesRef.current, }); if (validationError) { @@ -469,6 +541,7 @@ export function useCanvasConnections({ sourceType: template.type, targetType: fromNode.type ?? "", targetNodeId: fromNode.id, + targetHandle: ctx.fromHandleId, edges: edgesRef.current, }); if (validationError) { @@ -518,8 +591,10 @@ export function useCanvasConnections({ setEdges, runCreateEdgeMutation, runRemoveEdgeMutation, + runSwapMixerInputsMutation, validateConnection: (oldEdge, nextConnection) => validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id), + resolveMixerSwapReconnect, onInvalidConnection: (reason) => { showConnectionRejectedToast(reason as CanvasConnectionValidationReason); }, diff --git a/components/media/__tests__/media-library-dialog.test.tsx b/components/media/__tests__/media-library-dialog.test.tsx index 6e9dd6c..33d3d86 100644 --- a/components/media/__tests__/media-library-dialog.test.tsx +++ b/components/media/__tests__/media-library-dialog.test.tsx @@ -7,8 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ useAuthQuery: vi.fn(), resolveUrls: vi.fn(async () => ({})), + useTranslations: vi.fn(), })); +const translations = { + previous: "Zurueck", + next: "Weiter", + pageOf: "Seite {page} von {totalPages}", +} as const; + vi.mock("convex/react", () => ({ useMutation: () => mocks.resolveUrls, })); @@ -17,6 +24,10 @@ vi.mock("@/hooks/use-auth-query", () => ({ useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args), })); +vi.mock("next-intl", () => ({ + useTranslations: (...args: unknown[]) => mocks.useTranslations(...args), +})); + vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, @@ -49,7 +60,23 @@ describe("MediaLibraryDialog", () => { beforeEach(() => { mocks.useAuthQuery.mockReset(); mocks.resolveUrls.mockReset(); + mocks.useTranslations.mockReset(); mocks.resolveUrls.mockImplementation(async () => ({})); + const translate = ( + key: keyof typeof translations, + values?: Record, + ) => { + const template = translations[key] ?? key; + if (!values) { + return template; + } + + return template.replace(/\{(\w+)\}/g, (_, token: string) => { + const value = values[token]; + return value === undefined ? `{${token}}` : String(value); + }); + }; + mocks.useTranslations.mockReturnValue(translate); container = document.createElement("div"); document.body.appendChild(container); @@ -86,7 +113,7 @@ describe("MediaLibraryDialog", () => { ); }); - it("renders at most 8 cards and shows Freepik-style pagination footer", async () => { + it("renders at most 8 cards and shows localized pagination footer", async () => { mocks.useAuthQuery.mockReturnValue({ items: makeItems(10), page: 1, @@ -102,9 +129,9 @@ describe("MediaLibraryDialog", () => { const cards = document.querySelectorAll("img[alt^='Item 1-']"); expect(cards).toHaveLength(8); - expect(document.body.textContent).toContain("Previous"); - expect(document.body.textContent).toContain("Page 1 of 3"); - expect(document.body.textContent).toContain("Next"); + expect(document.body.textContent).toContain("Zurueck"); + expect(document.body.textContent).toContain("Seite 1 von 3"); + expect(document.body.textContent).toContain("Weiter"); }); it("updates query args when clicking next and previous", async () => { @@ -135,7 +162,7 @@ describe("MediaLibraryDialog", () => { }); const nextButton = Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Next", + (button) => button.textContent?.trim() === "Weiter", ); if (!(nextButton instanceof HTMLButtonElement)) { throw new Error("Next button not found"); @@ -149,7 +176,7 @@ describe("MediaLibraryDialog", () => { expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 })); const previousButton = Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Previous", + (button) => button.textContent?.trim() === "Zurueck", ); if (!(previousButton instanceof HTMLButtonElement)) { throw new Error("Previous button not found"); diff --git a/components/media/media-library-dialog.tsx b/components/media/media-library-dialog.tsx index 9e3ca4f..bb14162 100644 --- a/components/media/media-library-dialog.tsx +++ b/components/media/media-library-dialog.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation } from "convex/react"; import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react"; +import { useTranslations } from "next-intl"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -73,15 +74,18 @@ function formatDimensions(width: number | undefined, height: number | undefined) return `${width} x ${height}px`; } -function formatMediaMeta(item: MediaLibraryItem): string { +function formatMediaMeta( + item: MediaLibraryItem, + tCommon: ReturnType, +): string { if (item.kind === "video") { if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) { return `${Math.max(1, Math.round(item.durationSeconds))}s`; } - return "Videodatei"; + return tCommon("videoFile"); } - return formatDimensions(item.width, item.height) ?? "Groesse unbekannt"; + return formatDimensions(item.width, item.height) ?? tCommon("unknownSize"); } function getItemKey(item: MediaLibraryItem): string { @@ -104,32 +108,37 @@ function getItemKey(item: MediaLibraryItem): string { return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`; } -function getItemLabel(item: MediaLibraryItem): string { +function getItemLabel( + item: MediaLibraryItem, + tCommon: ReturnType, +): string { if (item.filename) { return item.filename; } if (item.kind === "video") { - return "Unbenanntes Video"; + return tCommon("untitledVideo"); } if (item.kind === "asset") { - return "Unbenanntes Asset"; + return tCommon("untitledAsset"); } - return "Unbenanntes Bild"; + return tCommon("untitledImage"); } export function MediaLibraryDialog({ open, onOpenChange, onPick, - title = "Mediathek", + title, description, pageSize = DEFAULT_PAGE_SIZE, kindFilter, - pickCtaLabel = "Auswaehlen", + pickCtaLabel, }: MediaLibraryDialogProps) { + const tDialog = useTranslations("mediaLibrary.dialog"); + const tCommon = useTranslations("mediaLibrary.common"); const [page, setPage] = useState(1); const normalizedPageSize = useMemo(() => { if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) { @@ -203,7 +212,7 @@ export function MediaLibraryDialog({ return; } setUrlMap({}); - setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden."); + setUrlError(error instanceof Error ? error.message : tDialog("urlResolveError")); } finally { if (!isCancelled) { setIsResolvingUrls(false); @@ -216,7 +225,7 @@ export function MediaLibraryDialog({ return () => { isCancelled = true; }; - }, [metadata, open, resolveUrls]); + }, [metadata, open, resolveUrls, tDialog]); const items: MediaLibraryItem[] = useMemo(() => { if (!metadata) { @@ -229,16 +238,25 @@ export function MediaLibraryDialog({ })); }, [metadata, urlMap]); - const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]); + const visibleItems = useMemo( + () => items.slice(0, normalizedPageSize), + [items, normalizedPageSize], + ); const isMetadataLoading = open && metadata === undefined; const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls); const isPreviewMode = typeof onPick !== "function"; + const effectiveTitle = title ?? tDialog("title"); + const effectivePickCtaLabel = pickCtaLabel ?? tDialog("pick"); const effectiveDescription = description ?? (kindFilter === "image" - ? "Waehle ein Bild aus deiner LemonSpace-Mediathek." - : "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen."); + ? tDialog("descriptionImage") + : kindFilter === "video" + ? tDialog("descriptionVideo") + : kindFilter === "asset" + ? tDialog("descriptionAsset") + : tDialog("descriptionDefault")); async function handlePick(item: MediaLibraryItem): Promise { if (!onPick || pendingPickItemKey) { @@ -257,7 +275,7 @@ export function MediaLibraryDialog({ - {title} + {effectiveTitle} {effectiveDescription} @@ -277,15 +295,15 @@ export function MediaLibraryDialog({ ) : urlError ? (
-

Mediathek konnte nicht geladen werden

+

{tDialog("errorTitle")}

{urlError}

) : items.length === 0 ? (
-

Keine Medien vorhanden

+

{tDialog("emptyTitle")}

- Sobald du Medien hochlaedst oder generierst, erscheinen sie hier. + {tDialog("emptyDescription")}

) : ( @@ -293,8 +311,8 @@ export function MediaLibraryDialog({ {visibleItems.map((item) => { const itemKey = getItemKey(item); const isPickingThis = pendingPickItemKey === itemKey; - const itemLabel = getItemLabel(item); - const metaLabel = formatMediaMeta(item); + const itemLabel = getItemLabel(item, tCommon); + const metaLabel = formatMediaMeta(item, tCommon); return (
{isPreviewMode ? ( -

Nur Vorschau

+

{tDialog("previewOnly")}

) : ( )} @@ -375,10 +393,10 @@ export function MediaLibraryDialog({ onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1} > - Previous + {tDialog("previous")} - Page {metadata.page} of {metadata.totalPages} + {tDialog("pageOf", { page: metadata.page, totalPages: metadata.totalPages })}
) : null} diff --git a/convex/CLAUDE.md b/convex/CLAUDE.md index 56cd6ec..e6a0f83 100644 --- a/convex/CLAUDE.md +++ b/convex/CLAUDE.md @@ -58,6 +58,7 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n | `video-prompt` | `content`, `modelId`, `durationSeconds` | KI-Video-Steuer-Node (Eingabe) | | `ai-video` | `storageId`, `prompt`, `model`, `modelLabel`, `durationSeconds`, `creditCost`, `generatedAt`, `taskId` (transient) | Generiertes KI-Video (System-Output) | | `compare` | `leftNodeId`, `rightNodeId`, `sliderPosition` | Vergleichs-Node | +| `mixer` | `blendMode`, `opacity`, `offsetX`, `offsetY` | V1 Merge-Control-Node mit pseudo-image Output (kein Storage-Write) | | `frame` | `label`, `exportWidth`, `exportHeight`, `backgroundColor` | Artboard | | `group` | `label`, `collapsed` | Container-Node | | `note` | `content`, `color` | Anmerkung | @@ -327,8 +328,17 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu - Ziel: `ai-image`, `ai-video`, `compare` → Target-Ports - `video-prompt` → `ai-video` ✅ (einzige gültige Kombination für Video-Flow) - `ai-video` als Source für andere Nodes → ❌ (nur Compare) +- `mixer` akzeptiert nur `image|asset|ai-image|render` als Source-Typ +- `mixer` akzeptiert nur Target-Handles `base` und `overlay` +- `mixer` erlaubt max. eine eingehende Kante pro Handle und max. zwei insgesamt - Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges +### Mixer V1: Backend-Scope + +- `mixer` ist ein Control-Node mit pseudo-image Semantik, nicht mit persistiertem Medien-Output. +- Keine zusaetzlichen Convex-Tabellen oder Storage-Flows fuer Mixer-Vorschauen. +- Validierung laeuft client- und serverseitig ueber dieselbe Policy (`validateCanvasConnectionPolicy`); `edges.ts` delegiert darauf fuer Paritaet. + --- ## Storage (`storage.ts`) diff --git a/convex/agents.ts b/convex/agents.ts index da73bf3..4832f62 100644 --- a/convex/agents.ts +++ b/convex/agents.ts @@ -11,7 +11,13 @@ import { api, internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { generateStructuredObjectViaOpenRouter } from "./openrouter"; import { getNodeDataRecord } from "./ai_node_data"; -import { formatTerminalStatusMessage } from "./ai_errors"; +import { + errorMessage, + formatTerminalStatusMessage, + getErrorCode, + getErrorSource, + getProviderStatus, +} from "./ai_errors"; import { areClarificationAnswersComplete, buildPreflightClarificationQuestions, @@ -119,14 +125,17 @@ function buildExecuteSchema(stepIds: string[]): Record { }, }; - const metadataValueSchema: Record = { - anyOf: [ - { type: "string" }, - { + const metadataEntrySchema: Record = { + type: "object", + additionalProperties: false, + required: ["key", "values"], + properties: { + key: { type: "string" }, + values: { type: "array", items: { type: "string" }, }, - ], + }, }; const stepOutputProperties: Record = {}; @@ -134,31 +143,31 @@ function buildExecuteSchema(stepIds: string[]): Record { stepOutputProperties[stepId] = { type: "object", additionalProperties: false, - required: [ - "title", - "channel", - "artifactType", - "previewText", - "sections", - "metadata", - "qualityChecks", - ], - properties: { - title: { type: "string" }, - channel: { type: "string" }, + required: [ + "title", + "channel", + "artifactType", + "previewText", + "sections", + "metadataEntries", + "qualityChecks", + ], + properties: { + title: { type: "string" }, + channel: { type: "string" }, artifactType: { type: "string" }, previewText: { type: "string" }, - sections: { - type: "array", - items: sectionSchema, - }, - metadata: { - type: "object", - additionalProperties: metadataValueSchema, - }, - qualityChecks: { - type: "array", - items: { type: "string" }, + sections: { + type: "array", + items: sectionSchema, + }, + metadataEntries: { + type: "array", + items: metadataEntrySchema, + }, + qualityChecks: { + type: "array", + items: { type: "string" }, }, }, }; @@ -297,6 +306,7 @@ type InternalApiShape = { previewText: string; sections: AgentOutputSection[]; metadata: Record; + metadataLabels: Record; body: string; }, unknown @@ -351,6 +361,18 @@ function trimText(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function logAgentFailure(stage: string, context: Record, error: unknown): void { + const formattedStatus = formatTerminalStatusMessage(error); + console.error(`[agents][${stage}] failed`, { + ...context, + statusMessage: formattedStatus, + code: getErrorCode(error), + source: getErrorSource(error), + providerStatus: getProviderStatus(error), + message: errorMessage(error), + }); +} + function normalizeAnswerMap(raw: unknown): AgentClarificationAnswerMap { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return {}; @@ -519,6 +541,7 @@ function buildSkeletonOutputData(input: { previewText: buildSkeletonPreviewPlaceholder(input.step.title), sections: [], metadata: {}, + metadataLabels: {}, body: "", ...(definitionVersion ? { definitionVersion } : {}), }; @@ -535,6 +558,7 @@ function buildCompletedOutputData(input: { previewText: string; sections: AgentOutputSection[]; metadata: Record; + metadataLabels: Record; qualityChecks: string[]; body: string; }; @@ -563,6 +587,10 @@ function buildCompletedOutputData(input: { sections: normalizedSections, metadata: input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {}, + metadataLabels: + input.output.metadataLabels && typeof input.output.metadataLabels === "object" + ? input.output.metadataLabels + : {}, body: deriveLegacyBodyFallback({ title: trimText(input.output.title) || trimText(input.step.title), previewText: normalizedPreviewText, @@ -976,6 +1004,7 @@ export const completeExecutionStepOutput = internalMutation({ }), ), metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))), + metadataLabels: v.record(v.string(), v.string()), body: v.string(), }, handler: async (ctx, args) => { @@ -1018,6 +1047,7 @@ export const completeExecutionStepOutput = internalMutation({ previewText: args.previewText, sections: args.sections, metadata: args.metadata, + metadataLabels: args.metadataLabels, qualityChecks: args.qualityChecks, body: args.body, }, @@ -1254,6 +1284,7 @@ export const analyzeAgent = internalAction({ shouldDecrementConcurrency: args.shouldDecrementConcurrency, }); } catch (error) { + logAgentFailure("analyzeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error); await releaseInternalReservationBestEffort(ctx, args.reservationId); await ctx.runMutation(internalApi.agents.setAgentError, { nodeId: args.nodeId, @@ -1306,6 +1337,17 @@ export const executeAgent = internalAction({ const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id)); + console.info("[agents][executeAgent] request context", { + nodeId: args.nodeId, + modelId: args.modelId, + stepCount: executionSteps.length, + stepIds: executionSteps.map((step) => step.id), + artifactTypes: executionSteps.map((step) => step.artifactType), + channels: executionSteps.map((step) => step.channel), + incomingContextLength: incomingContext.length, + executionPlanSummaryLength: executionPlanSummary.length, + }); + const execution = await generateStructuredObjectViaOpenRouter<{ summary: string; stepOutputs: Record; @@ -1375,6 +1417,7 @@ export const executeAgent = internalAction({ previewText: normalized.previewText, sections: normalized.sections, metadata: normalized.metadata, + metadataLabels: normalized.metadataLabels, body: normalized.body, }); } @@ -1393,6 +1436,7 @@ export const executeAgent = internalAction({ await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId); } catch (error) { + logAgentFailure("executeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error); await releaseInternalReservationBestEffort(ctx, args.reservationId); await ctx.runMutation(internalApi.agents.setAgentError, { nodeId: args.nodeId, @@ -1404,6 +1448,7 @@ export const executeAgent = internalAction({ }); export const __testables = { + buildExecuteSchema, buildSkeletonOutputData, buildCompletedOutputData, getAnalyzeExecutionStepRequiredFields, @@ -1485,6 +1530,7 @@ export const runAgent = action({ scheduled = true; return { queued: true, nodeId: args.nodeId }; } catch (error) { + logAgentFailure("runAgent", { nodeId: args.nodeId, modelId: selectedModel.id }, error); await releasePublicReservationBestEffort(ctx, reservationId); await ctx.runMutation(internalApi.agents.setAgentError, { nodeId: args.nodeId, @@ -1572,6 +1618,7 @@ export const resumeAgent = action({ return { queued: true, nodeId: args.nodeId }; } catch (error) { + logAgentFailure("resumeAgent", { nodeId: args.nodeId, modelId }, error); await releasePublicReservationBestEffort(ctx, reservationId ?? null); await ctx.runMutation(internalApi.agents.setAgentError, { nodeId: args.nodeId, diff --git a/convex/ai_errors.ts b/convex/ai_errors.ts index 87aa3d9..cefb7c8 100644 --- a/convex/ai_errors.ts +++ b/convex/ai_errors.ts @@ -14,6 +14,47 @@ interface ErrorData { [key: string]: unknown; } +function trimText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function parseStructuredProviderErrorMessage(raw: string): { + message: string; + code: string; + type: string; +} | null { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const record = parsed as Record; + const errorBlock = + record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? (record.error as Record) + : undefined; + + const message = + trimText(errorBlock?.message) || trimText(record.message) || trimText(errorBlock?.detail); + const code = trimText(errorBlock?.code) || trimText(record.code); + const type = trimText(errorBlock?.type) || trimText(record.type); + + if (!message) { + return null; + } + + return { message, code, type }; + } catch { + return null; + } +} + export function getErrorCode(error: unknown): string | undefined { if (error instanceof ConvexError) { const data = error.data as ErrorData; @@ -166,13 +207,34 @@ export function formatTerminalStatusMessage(error: unknown): string { typeof convexData?.status === "number" && Number.isFinite(convexData.status) ? convexData.status : null; + const structuredProviderFromMessage = parseStructuredProviderErrorMessage(convexDataMessage); + + const structuredProviderMessageFromData = + trimText(convexData?.providerMessage) || + structuredProviderFromMessage?.message; + const structuredProviderCodeFromData = + trimText(convexData?.providerCode) || structuredProviderFromMessage?.code; + const structuredProviderTypeFromData = + trimText(convexData?.providerType) || structuredProviderFromMessage?.type; + + const structuredProviderDecorators = [ + structuredProviderCodeFromData ? `code=${structuredProviderCodeFromData}` : "", + structuredProviderTypeFromData ? `type=${structuredProviderTypeFromData}` : "", + ].filter(Boolean); + + const structuredProviderSuffix = + structuredProviderDecorators.length > 0 + ? ` [${structuredProviderDecorators.join(", ")}]` + : ""; const message = code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR" - ? convexDataMessage || - (convexDataStatus !== null - ? `HTTP ${convexDataStatus}` - : "Anfrage fehlgeschlagen") + ? structuredProviderMessageFromData + ? `${convexDataStatus !== null ? `OpenRouter ${convexDataStatus}: ` : ""}${structuredProviderMessageFromData}${structuredProviderSuffix}` + : convexDataMessage || + (convexDataStatus !== null + ? `HTTP ${convexDataStatus}` + : "Anfrage fehlgeschlagen") : errorMessage(error).trim() || "Generation failed"; const { category } = diff --git a/convex/edges.ts b/convex/edges.ts index 93b95ac..8100fdd 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -8,37 +8,41 @@ import { } from "../lib/canvas-connection-policy"; const PERFORMANCE_LOG_THRESHOLD_MS = 250; +const MIXER_HANDLES = new Set(["base", "overlay"] as const); -async function countIncomingEdges( +function normalizeMixerHandle(handle: string | undefined): "base" | "overlay" | null { + if (handle == null || handle === "" || handle === "null") { + return "base"; + } + + if (MIXER_HANDLES.has(handle as "base" | "overlay")) { + return handle as "base" | "overlay"; + } + + return null; +} + +async function getIncomingEdgePolicyContext( ctx: MutationCtx, args: { targetNodeId: Id<"nodes">; edgeIdToIgnore?: Id<"edges">; }, -): Promise { +): Promise<{ count: number; targetHandles: Array }> { const incomingEdgesQuery = ctx.db .query("edges") .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); const checkStartedAt = Date.now(); - const incomingEdges = await ( - args.edgeIdToIgnore - ? incomingEdgesQuery.take(2) - : incomingEdgesQuery.first() - ); + const incomingEdges = await incomingEdgesQuery.take(3); const checkDurationMs = Date.now() - checkStartedAt; - const incomingCount = Array.isArray(incomingEdges) - ? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length - : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore - ? 1 - : 0; + const filteredIncomingEdges = incomingEdges.filter( + (edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore, + ); + const incomingCount = filteredIncomingEdges.length; if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { - const inspected = Array.isArray(incomingEdges) - ? incomingEdges.length - : incomingEdges === null - ? 0 - : 1; + const inspected = incomingEdges.length; console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", { targetNodeId: args.targetNodeId, @@ -48,7 +52,10 @@ async function countIncomingEdges( }); } - return incomingCount; + return { + count: incomingCount, + targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle), + }; } async function assertConnectionPolicy( @@ -56,6 +63,7 @@ async function assertConnectionPolicy( args: { sourceNodeId: Id<"nodes">; targetNodeId: Id<"nodes">; + targetHandle?: string; edgeIdToIgnore?: Id<"edges">; }, ): Promise { @@ -65,7 +73,7 @@ async function assertConnectionPolicy( throw new Error("Source or target node not found"); } - const targetIncomingCount = await countIncomingEdges(ctx, { + const targetIncoming = await getIncomingEdgePolicyContext(ctx, { targetNodeId: args.targetNodeId, edgeIdToIgnore: args.edgeIdToIgnore, }); @@ -73,7 +81,9 @@ async function assertConnectionPolicy( const reason = validateCanvasConnectionPolicy({ sourceType: sourceNode.type, targetType: targetNode.type, - targetIncomingCount, + targetIncomingCount: targetIncoming.count, + targetHandle: args.targetHandle, + targetIncomingHandles: targetIncoming.targetHandles, }); if (reason) { @@ -83,7 +93,7 @@ async function assertConnectionPolicy( edgeIdToIgnore: args.edgeIdToIgnore, sourceType: sourceNode.type, targetType: targetNode.type, - targetIncomingCount, + targetIncomingCount: targetIncoming.count, reason, }); throw new Error(getCanvasConnectionValidationMessage(reason)); @@ -151,6 +161,7 @@ export const create = mutation({ targetNodeId: v.id("nodes"), sourceHandle: v.optional(v.string()), targetHandle: v.optional(v.string()), + edgeIdToIgnore: v.optional(v.id("edges")), clientRequestId: v.optional(v.string()), }, handler: async (ctx, args) => { @@ -207,9 +218,23 @@ export const create = mutation({ throw new Error("Cannot connect a node to itself"); } + const edgeToIgnore = args.edgeIdToIgnore + ? await ctx.db.get(args.edgeIdToIgnore) + : null; + if (args.edgeIdToIgnore) { + if (!edgeToIgnore) { + throw new Error("Edge to ignore not found"); + } + if (edgeToIgnore.canvasId !== args.canvasId) { + throw new Error("Edge to ignore must belong to the same canvas"); + } + } + await assertConnectionPolicy(ctx, { sourceNodeId: args.sourceNodeId, targetNodeId: args.targetNodeId, + targetHandle: args.targetHandle, + edgeIdToIgnore: args.edgeIdToIgnore, }); const edgeId = await ctx.db.insert("edges", { @@ -220,6 +245,10 @@ export const create = mutation({ targetHandle: args.targetHandle, }); + if (edgeToIgnore) { + await ctx.db.delete(edgeToIgnore._id); + } + console.info("[canvas.updatedAt] touch", { canvasId: args.canvasId, source: "edges.create", @@ -242,6 +271,54 @@ export const create = mutation({ }, }); +export const swapMixerInputs = mutation({ + args: { + canvasId: v.id("canvases"), + edgeId: v.id("edges"), + otherEdgeId: v.id("edges"), + }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(args.canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + + if (args.edgeId === args.otherEdgeId) { + throw new Error("Edge IDs must be different"); + } + + const edge = await ctx.db.get(args.edgeId); + const otherEdge = await ctx.db.get(args.otherEdgeId); + if (!edge || !otherEdge) { + throw new Error("Edge not found"); + } + + if (edge.canvasId !== args.canvasId || otherEdge.canvasId !== args.canvasId) { + throw new Error("Edges must belong to the same canvas"); + } + + if (edge.targetNodeId !== otherEdge.targetNodeId) { + throw new Error("Edges must target the same mixer node"); + } + + const mixerNode = await ctx.db.get(edge.targetNodeId); + if (!mixerNode || mixerNode.canvasId !== args.canvasId || mixerNode.type !== "mixer") { + throw new Error("Mixer node not found"); + } + + const edgeHandle = normalizeMixerHandle(edge.targetHandle); + const otherEdgeHandle = normalizeMixerHandle(otherEdge.targetHandle); + if (!edgeHandle || !otherEdgeHandle || edgeHandle === otherEdgeHandle) { + throw new Error("Mixer swap requires one base and one overlay edge"); + } + + await ctx.db.patch(edge._id, { targetHandle: otherEdgeHandle }); + await ctx.db.patch(otherEdge._id, { targetHandle: edgeHandle }); + await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + }, +}); + /** * Edge löschen. */ diff --git a/convex/nodes.ts b/convex/nodes.ts index 11c5c18..f33508d 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -418,34 +418,27 @@ function normalizeNodeDataForWrite( return preserveNodeFavorite(data, data); } -async function countIncomingEdges( +async function getIncomingEdgePolicyContext( ctx: MutationCtx, args: { targetNodeId: Id<"nodes">; edgeIdToIgnore?: Id<"edges">; }, -): Promise { +): Promise<{ count: number; targetHandles: Array }> { const incomingEdgesQuery = ctx.db .query("edges") .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); const checkStartedAt = Date.now(); - const incomingEdges = await ( - args.edgeIdToIgnore ? incomingEdgesQuery.take(2) : incomingEdgesQuery.first() - ); + const incomingEdges = await incomingEdgesQuery.take(3); const checkDurationMs = Date.now() - checkStartedAt; - const incomingCount = Array.isArray(incomingEdges) - ? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length - : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore - ? 1 - : 0; + const filteredIncomingEdges = incomingEdges.filter( + (edge) => edge._id !== args.edgeIdToIgnore, + ); + const incomingCount = filteredIncomingEdges.length; if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { - const inspected = Array.isArray(incomingEdges) - ? incomingEdges.length - : incomingEdges === null - ? 0 - : 1; + const inspected = incomingEdges.length; console.warn("[nodes.countIncomingEdges] slow incoming edge check", { targetNodeId: args.targetNodeId, @@ -455,7 +448,10 @@ async function countIncomingEdges( }); } - return incomingCount; + return { + count: incomingCount, + targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle), + }; } async function assertConnectionPolicyForTypes( @@ -464,16 +460,21 @@ async function assertConnectionPolicyForTypes( sourceType: Doc<"nodes">["type"]; targetType: Doc<"nodes">["type"]; targetNodeId: Id<"nodes">; + targetHandle?: string; edgeIdToIgnore?: Id<"edges">; }, ): Promise { + const targetIncoming = await getIncomingEdgePolicyContext(ctx, { + targetNodeId: args.targetNodeId, + edgeIdToIgnore: args.edgeIdToIgnore, + }); + const reason = validateCanvasConnectionPolicy({ sourceType: args.sourceType, targetType: args.targetType, - targetIncomingCount: await countIncomingEdges(ctx, { - targetNodeId: args.targetNodeId, - edgeIdToIgnore: args.edgeIdToIgnore, - }), + targetIncomingCount: targetIncoming.count, + targetHandle: args.targetHandle, + targetIncomingHandles: targetIncoming.targetHandles, }); if (reason) { @@ -870,6 +871,8 @@ export const createWithEdgeSplit = mutation({ sourceType: sourceNode.type, targetType: args.type, targetIncomingCount: 0, + targetHandle: args.newNodeTargetHandle, + targetIncomingHandles: [], }); if (firstEdgeReason) { throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason)); @@ -879,6 +882,7 @@ export const createWithEdgeSplit = mutation({ sourceType: args.type, targetType: targetNode.type, targetNodeId: edge.targetNodeId, + targetHandle: args.splitTargetHandle, edgeIdToIgnore: args.splitEdgeId, }); @@ -1008,6 +1012,7 @@ export const splitEdgeAtExistingNode = mutation({ sourceType: sourceNode.type, targetType: middle.type, targetNodeId: args.middleNodeId, + targetHandle: args.newNodeTargetHandle, }); await ctx.db.insert("edges", { @@ -1022,6 +1027,7 @@ export const splitEdgeAtExistingNode = mutation({ sourceType: middle.type, targetType: targetNode.type, targetNodeId: edge.targetNodeId, + targetHandle: args.splitTargetHandle, edgeIdToIgnore: args.splitEdgeId, }); @@ -1098,6 +1104,8 @@ export const createWithEdgeFromSource = mutation({ sourceType: source.type, targetType: args.type, targetIncomingCount: 0, + targetHandle: args.targetHandle, + targetIncomingHandles: [], }); if (fromSourceReason) { throw new Error(getCanvasConnectionValidationMessage(fromSourceReason)); @@ -1188,6 +1196,7 @@ export const createWithEdgeToTarget = mutation({ sourceType: args.type, targetType: target.type, targetNodeId, + targetHandle: args.targetHandle, }); const normalizedData = normalizeNodeDataForWrite(args.type, args.data); diff --git a/convex/openrouter.ts b/convex/openrouter.ts index 646edbd..ad14868 100644 --- a/convex/openrouter.ts +++ b/convex/openrouter.ts @@ -151,6 +151,161 @@ function parseStructuredJsonFromMessageContent(contentText: string): return { ok: false }; } +type StructuredOpenRouterErrorInfo = { + userMessage: string; + providerMessage: string; + providerCode: string; + providerType: string; + rawBodyPreview: string; +}; + +type StructuredSchemaDiagnostics = { + topLevelType: string; + topLevelRequiredCount: number; + topLevelPropertyCount: number; + schemaBytes: number; + messageCount: number; + messageLengths: number[]; + hasAnyOf: boolean; + hasOneOf: boolean; + hasAllOf: boolean; + hasPatternProperties: boolean; + hasDynamicAdditionalProperties: boolean; +}; + +function walkStructuredSchema( + value: unknown, + visitor: (node: Record) => void, +): void { + if (!value || typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + walkStructuredSchema(item, visitor); + } + return; + } + + const record = value as Record; + visitor(record); + + for (const nested of Object.values(record)) { + walkStructuredSchema(nested, visitor); + } +} + +function getStructuredSchemaDiagnostics(args: { + schema: Record; + messages: Array<{ + role: "system" | "user" | "assistant"; + content: string; + }>; +}): StructuredSchemaDiagnostics { + const topLevelType = typeof args.schema.type === "string" ? args.schema.type : "unknown"; + const topLevelRequiredCount = Array.isArray(args.schema.required) ? args.schema.required.length : 0; + const properties = + args.schema.properties && typeof args.schema.properties === "object" && !Array.isArray(args.schema.properties) + ? (args.schema.properties as Record) + : null; + + const diagnostics: StructuredSchemaDiagnostics = { + topLevelType, + topLevelRequiredCount, + topLevelPropertyCount: properties ? Object.keys(properties).length : 0, + schemaBytes: JSON.stringify(args.schema).length, + messageCount: args.messages.length, + messageLengths: args.messages.map((message) => message.content.length), + hasAnyOf: false, + hasOneOf: false, + hasAllOf: false, + hasPatternProperties: false, + hasDynamicAdditionalProperties: false, + }; + + walkStructuredSchema(args.schema, (node) => { + if (Array.isArray(node.anyOf) && node.anyOf.length > 0) { + diagnostics.hasAnyOf = true; + } + if (Array.isArray(node.oneOf) && node.oneOf.length > 0) { + diagnostics.hasOneOf = true; + } + if (Array.isArray(node.allOf) && node.allOf.length > 0) { + diagnostics.hasAllOf = true; + } + if ( + node.patternProperties && + typeof node.patternProperties === "object" && + !Array.isArray(node.patternProperties) + ) { + diagnostics.hasPatternProperties = true; + } + if ( + node.additionalProperties && + typeof node.additionalProperties === "object" && + !Array.isArray(node.additionalProperties) + ) { + diagnostics.hasDynamicAdditionalProperties = true; + } + }); + + return diagnostics; +} + +function summarizeStructuredOpenRouterError(errorText: string, status: number): StructuredOpenRouterErrorInfo { + const trimmed = errorText.trim(); + const rawBodyPreview = trimmed.slice(0, 4000); + + let providerMessage = ""; + let providerCode = ""; + let providerType = ""; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record; + const errorBlock = + record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? (record.error as Record) + : undefined; + + providerMessage = + (typeof errorBlock?.message === "string" ? errorBlock.message.trim() : "") || + (typeof record.message === "string" ? record.message.trim() : ""); + providerCode = + (typeof errorBlock?.code === "string" ? errorBlock.code.trim() : "") || + (typeof record.code === "string" ? record.code.trim() : ""); + providerType = + (typeof errorBlock?.type === "string" ? errorBlock.type.trim() : "") || + (typeof record.type === "string" ? record.type.trim() : ""); + } + } catch { + // Keep defaults and fall back to raw text below. + } + } + + const decorators = [ + providerCode ? `code=${providerCode}` : "", + providerType ? `type=${providerType}` : "", + ].filter(Boolean); + + const suffix = decorators.length > 0 ? ` [${decorators.join(", ")}]` : ""; + const fallbackMessage = rawBodyPreview || `HTTP ${status}`; + const userMessage = providerMessage + ? `OpenRouter ${status}: ${providerMessage}${suffix}` + : fallbackMessage; + + return { + userMessage, + providerMessage, + providerCode, + providerType, + rawBodyPreview, + }; +} + export async function generateStructuredObjectViaOpenRouter( apiKey: string, args: { @@ -163,6 +318,17 @@ export async function generateStructuredObjectViaOpenRouter( schema: Record; }, ): Promise { + const schemaDiagnostics = getStructuredSchemaDiagnostics({ + schema: args.schema, + messages: args.messages, + }); + + console.info("[openrouter][structured] request", { + model: args.model, + schemaName: args.schemaName, + ...schemaDiagnostics, + }); + const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: "POST", headers: { @@ -188,10 +354,25 @@ export async function generateStructuredObjectViaOpenRouter( if (!response.ok) { const errorText = await response.text(); + const errorInfo = summarizeStructuredOpenRouterError(errorText, response.status); + console.error("[openrouter][structured] non-ok response", { + model: args.model, + schemaName: args.schemaName, + status: response.status, + providerMessage: errorInfo.providerMessage || undefined, + providerCode: errorInfo.providerCode || undefined, + providerType: errorInfo.providerType || undefined, + rawBodyPreview: errorInfo.rawBodyPreview, + }); + throw new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR", status: response.status, - message: errorText, + message: errorInfo.userMessage, + providerMessage: errorInfo.providerMessage || undefined, + providerCode: errorInfo.providerCode || undefined, + providerType: errorInfo.providerType || undefined, + rawBodyPreview: errorInfo.rawBodyPreview, }); } @@ -223,6 +404,11 @@ export async function generateStructuredObjectViaOpenRouter( return parsedContent.value as T; } +export const __testables = { + getStructuredSchemaDiagnostics, + summarizeStructuredOpenRouterError, +}; + export interface OpenRouterModel { id: string; name: string; diff --git a/lib/CLAUDE.md b/lib/CLAUDE.md index b2d6272..0c9f6ad 100644 --- a/lib/CLAUDE.md +++ b/lib/CLAUDE.md @@ -107,7 +107,8 @@ isNodePaletteEnabled // true wenn: implementiert + kein systemOutput + Template **Kategorien:** - `source` — Quelle (image, text, video, asset, color) -- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text, ai-video, agent-output) +- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text) +- `agents` — Agents (agent, agent-output) - `transform` — Transformation (crop, bg-remove, upscale) - `image-edit` — Bildbearbeitung (adjustments) - `control` — Steuerung & Flow diff --git a/lib/agent-prompting.ts b/lib/agent-prompting.ts index 2cb1377..0e0bb4a 100644 --- a/lib/agent-prompting.ts +++ b/lib/agent-prompting.ts @@ -215,6 +215,24 @@ function formatExecutionRequirements(plan: AgentExecutionPlan): string { .join("\n"); } +function formatDeliverableFirstInstructions(definition: AgentDefinition): string { + const rules = [ + "Prioritize publishable, user-facing deliverables for every execution step.", + "Lead with final copy/content that can be shipped immediately.", + "Keep assumptions, rationale, and risk notes secondary and concise.", + "Do not produce reasoning-dominant output or long meta commentary.", + "When context is partial, deliver the best safe draft first and clearly note assumptions in brief form.", + ]; + + if (definition.id === "campaign-distributor") { + rules.push( + "For Campaign Distributor steps, output channel-ready publishable copy first, then short format/assumption notes.", + ); + } + + return `deliverable-first rules:\n- ${rules.join("\n- ")}`; +} + export function buildExecuteMessages(input: { definition: AgentDefinition; locale: AgentLocale; @@ -235,6 +253,7 @@ export function buildExecuteMessages(input: { "Use the following compiled prompt segments:", formatPromptSegments(segments), `execution rules:\n- ${input.definition.executionRules.join("\n- ")}`, + formatDeliverableFirstInstructions(input.definition), "Return one output payload per execution step keyed by step id.", ].join("\n\n"), }, diff --git a/lib/agent-run-contract.ts b/lib/agent-run-contract.ts index 3a08a8f..ff4e08b 100644 --- a/lib/agent-run-contract.ts +++ b/lib/agent-run-contract.ts @@ -26,16 +26,23 @@ export type AgentStructuredOutput = { previewText: string; sections: AgentOutputSection[]; metadata: Record; + metadataLabels: Record; qualityChecks: string[]; body: string; }; +export type AgentStructuredMetadataEntry = { + key: string; + values: string[]; +}; + export type AgentStructuredOutputDraft = Partial< - AgentStructuredOutput & { - sections: Array | null>; - metadata: Record; - } ->; + Omit +> & { + sections?: unknown[]; + metadata?: Record; + metadataEntries?: unknown[]; +}; export type AgentExecutionStep = { id: string; @@ -178,6 +185,93 @@ function normalizeStructuredMetadata(raw: unknown): Record { + if (!Array.isArray(raw)) { + return {}; + } + + const metadata: Record = {}; + + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + continue; + } + + const record = item as Record; + const key = trimString(record.key); + if (key === "") { + continue; + } + + const values = normalizeStringArray(record.values); + const singleValue = trimString(record.value); + + if (values.length > 1) { + metadata[key] = values; + continue; + } + + if (values.length === 1) { + metadata[key] = values[0]!; + continue; + } + + if (singleValue !== "") { + metadata[key] = singleValue; + } + } + + return metadata; +} + +function slugifyMetadataKey(value: string): string { + const normalized = value + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/Ä/g, "ae") + .replace(/Ö/g, "oe") + .replace(/Ü/g, "ue") + .replace(/ß/g, "ss") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^\x20-\x7e]+/g, " ") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + + return normalized || "metadata"; +} + +function sanitizeStructuredMetadata(raw: Record): { + metadata: Record; + metadataLabels: Record; +} { + const metadata: Record = {}; + const metadataLabels: Record = {}; + + for (const [rawKey, value] of Object.entries(raw)) { + const trimmedKey = trimString(rawKey); + if (trimmedKey === "") { + continue; + } + + const slugBase = slugifyMetadataKey(trimmedKey); + let slug = slugBase; + let suffix = 2; + + while (slug in metadata) { + slug = `${slugBase}_${suffix}`; + suffix += 1; + } + + metadata[slug] = value; + metadataLabels[slug] = trimmedKey; + } + + return { metadata, metadataLabels }; +} + function derivePreviewTextFromSections(sections: AgentOutputSection[]): string { return sections[0]?.content ?? ""; } @@ -365,7 +459,12 @@ export function normalizeAgentStructuredOutput( trimString(draft.artifactType) || trimString(fallback.artifactType) || SAFE_FALLBACK_OUTPUT_TYPE; const sections = normalizeOutputSections(draft.sections); const previewText = trimString(draft.previewText) || derivePreviewTextFromSections(sections); - const metadata = normalizeStructuredMetadata(draft.metadata); + const metadataFromEntries = normalizeStructuredMetadataEntries(draft.metadataEntries); + const rawMetadata = + Object.keys(metadataFromEntries).length > 0 + ? metadataFromEntries + : normalizeStructuredMetadata(draft.metadata); + const { metadata, metadataLabels } = sanitizeStructuredMetadata(rawMetadata); const qualityChecks = normalizeStringArray(draft.qualityChecks); const body = trimString(draft.body) || @@ -382,6 +481,7 @@ export function normalizeAgentStructuredOutput( previewText, sections, metadata, + metadataLabels, qualityChecks, body, }; diff --git a/lib/canvas-connection-policy.ts b/lib/canvas-connection-policy.ts index 54cb629..7b664f4 100644 --- a/lib/canvas-connection-policy.ts +++ b/lib/canvas-connection-policy.ts @@ -50,6 +50,23 @@ const AGENT_ALLOWED_SOURCE_TYPES = new Set([ "ai-video", ]); +const MIXER_ALLOWED_SOURCE_TYPES = new Set([ + "image", + "asset", + "ai-image", + "render", +]); + +const MIXER_TARGET_HANDLES = new Set(["base", "overlay"]); + +function normalizeMixerHandle(handle: string | null | undefined): string { + if (handle == null || handle === "" || handle === "null") { + return "base"; + } + + return handle; +} + const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set(["prompt", "ai-image"]); export type CanvasConnectionValidationReason = @@ -66,14 +83,52 @@ export type CanvasConnectionValidationReason = | "adjustment-target-forbidden" | "render-source-invalid" | "agent-source-invalid" - | "agent-output-source-invalid"; + | "agent-output-source-invalid" + | "mixer-source-invalid" + | "mixer-target-handle-invalid" + | "mixer-handle-incoming-limit" + | "mixer-incoming-limit"; export function validateCanvasConnectionPolicy(args: { sourceType: string; targetType: string; targetIncomingCount: number; + targetHandle?: string | null; + targetIncomingHandles?: Array; }): CanvasConnectionValidationReason | null { - const { sourceType, targetType, targetIncomingCount } = args; + const { + sourceType, + targetType, + targetIncomingCount, + targetHandle, + targetIncomingHandles, + } = args; + + if (targetType === "mixer") { + if (!MIXER_ALLOWED_SOURCE_TYPES.has(sourceType)) { + return "mixer-source-invalid"; + } + + const normalizedTargetHandle = normalizeMixerHandle(targetHandle); + if (!MIXER_TARGET_HANDLES.has(normalizedTargetHandle)) { + return "mixer-target-handle-invalid"; + } + + if (targetIncomingCount >= 2) { + return "mixer-incoming-limit"; + } + + const normalizedIncomingHandles = (targetIncomingHandles ?? []).map((handle) => + normalizeMixerHandle(handle), + ); + const incomingOnHandle = normalizedIncomingHandles.filter( + (handle) => handle === normalizedTargetHandle, + ).length; + + if (incomingOnHandle >= 1) { + return "mixer-handle-incoming-limit"; + } + } if (targetType === "agent-output" && sourceType !== "agent") { return "agent-output-source-invalid"; @@ -159,6 +214,14 @@ export function getCanvasConnectionValidationMessage( return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt."; case "agent-output-source-invalid": return "Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes."; + case "mixer-source-invalid": + return "Mixer akzeptiert nur Bild-, Asset-, KI-Bild- oder Render-Input."; + case "mixer-target-handle-invalid": + return "Mixer akzeptiert nur die Ziel-Handles 'base' und 'overlay'."; + case "mixer-handle-incoming-limit": + return "Jeder Mixer-Handle akzeptiert nur eine eingehende Verbindung."; + case "mixer-incoming-limit": + return "Mixer-Nodes erlauben maximal zwei eingehende Verbindungen."; default: return "Verbindung ist fuer diese Node-Typen nicht erlaubt."; } diff --git a/lib/canvas-mixer-preview.ts b/lib/canvas-mixer-preview.ts new file mode 100644 index 0000000..c2aa899 --- /dev/null +++ b/lib/canvas-mixer-preview.ts @@ -0,0 +1,216 @@ +import { + buildGraphSnapshot, + resolveNodeImageUrl, + resolveRenderPreviewInputFromGraph, + type CanvasGraphEdgeLike, + type CanvasGraphNodeLike, + type CanvasGraphSnapshot, +} from "@/lib/canvas-render-preview"; + +export type MixerBlendMode = "normal" | "multiply" | "screen" | "overlay"; + +export type MixerPreviewStatus = "empty" | "partial" | "ready" | "error"; + +export type MixerPreviewError = "duplicate-handle-edge"; + +export type MixerPreviewState = { + status: MixerPreviewStatus; + baseUrl?: string; + overlayUrl?: string; + blendMode: MixerBlendMode; + opacity: number; + offsetX: number; + offsetY: number; + error?: MixerPreviewError; +}; + +const MIXER_SOURCE_NODE_TYPES = new Set(["image", "asset", "ai-image", "render"]); +const MIXER_BLEND_MODES = new Set([ + "normal", + "multiply", + "screen", + "overlay", +]); +const DEFAULT_BLEND_MODE: MixerBlendMode = "normal"; +const DEFAULT_OPACITY = 100; +const MIN_OPACITY = 0; +const MAX_OPACITY = 100; +const DEFAULT_OFFSET = 0; +const MIN_OFFSET = -2048; +const MAX_OFFSET = 2048; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function parseNumeric(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function normalizeOpacity(value: unknown): number { + const parsed = parseNumeric(value); + if (parsed === null) { + return DEFAULT_OPACITY; + } + + return clamp(parsed, MIN_OPACITY, MAX_OPACITY); +} + +function normalizeOffset(value: unknown): number { + const parsed = parseNumeric(value); + if (parsed === null) { + return DEFAULT_OFFSET; + } + + return clamp(parsed, MIN_OFFSET, MAX_OFFSET); +} + +export function normalizeMixerPreviewData(data: unknown): Pick< + MixerPreviewState, + "blendMode" | "opacity" | "offsetX" | "offsetY" +> { + const record = (data ?? {}) as Record; + const blendMode = MIXER_BLEND_MODES.has(record.blendMode as MixerBlendMode) + ? (record.blendMode as MixerBlendMode) + : DEFAULT_BLEND_MODE; + + return { + blendMode, + opacity: normalizeOpacity(record.opacity), + offsetX: normalizeOffset(record.offsetX), + offsetY: normalizeOffset(record.offsetY), + }; +} + +function resolveHandleEdge(args: { + incomingEdges: readonly CanvasGraphEdgeLike[]; + handle: "base" | "overlay"; +}): { edge: CanvasGraphEdgeLike | null; duplicate: boolean } { + const edges = args.incomingEdges.filter((edge) => { + if (args.handle === "base") { + return edge.targetHandle === "base" || edge.targetHandle == null || edge.targetHandle === ""; + } + + return edge.targetHandle === "overlay"; + }); + + if (edges.length > 1) { + return { edge: null, duplicate: true }; + } + + return { edge: edges[0] ?? null, duplicate: false }; +} + +function resolveSourceUrlFromNode(args: { + sourceNode: CanvasGraphNodeLike; + graph: CanvasGraphSnapshot; +}): string | undefined { + if (!MIXER_SOURCE_NODE_TYPES.has(args.sourceNode.type)) { + return undefined; + } + + if (args.sourceNode.type === "render") { + const renderData = (args.sourceNode.data ?? {}) as Record; + const renderOutputUrl = + typeof renderData.lastUploadUrl === "string" && renderData.lastUploadUrl.length > 0 + ? renderData.lastUploadUrl + : undefined; + if (renderOutputUrl) { + return renderOutputUrl; + } + + const directRenderUrl = resolveNodeImageUrl(args.sourceNode.data); + if (directRenderUrl) { + return directRenderUrl; + } + + const preview = resolveRenderPreviewInputFromGraph({ + nodeId: args.sourceNode.id, + graph: args.graph, + }); + return preview.sourceUrl ?? undefined; + } + + return resolveNodeImageUrl(args.sourceNode.data) ?? undefined; +} + +function resolveSourceUrlFromEdge(args: { + edge: CanvasGraphEdgeLike | null; + graph: CanvasGraphSnapshot; +}): string | undefined { + if (!args.edge) { + return undefined; + } + + const sourceNode = args.graph.nodesById.get(args.edge.source); + if (!sourceNode) { + return undefined; + } + + return resolveSourceUrlFromNode({ sourceNode, graph: args.graph }); +} + +export function resolveMixerPreviewFromGraph(args: { + nodeId: string; + graph: CanvasGraphSnapshot; +}): MixerPreviewState { + const node = args.graph.nodesById.get(args.nodeId); + const normalized = normalizeMixerPreviewData(node?.data); + const incomingEdges = args.graph.incomingEdgesByTarget.get(args.nodeId) ?? []; + const base = resolveHandleEdge({ incomingEdges, handle: "base" }); + const overlay = resolveHandleEdge({ incomingEdges, handle: "overlay" }); + + if (base.duplicate || overlay.duplicate) { + return { + status: "error", + ...normalized, + error: "duplicate-handle-edge", + }; + } + + const baseUrl = resolveSourceUrlFromEdge({ edge: base.edge, graph: args.graph }); + const overlayUrl = resolveSourceUrlFromEdge({ edge: overlay.edge, graph: args.graph }); + + if (baseUrl && overlayUrl) { + return { + status: "ready", + ...normalized, + baseUrl, + overlayUrl, + }; + } + + if (baseUrl || overlayUrl) { + return { + status: "partial", + ...normalized, + baseUrl, + overlayUrl, + }; + } + + return { + status: "empty", + ...normalized, + }; +} + +export function resolveMixerPreview(args: { + nodeId: string; + nodes: readonly CanvasGraphNodeLike[]; + edges: readonly CanvasGraphEdgeLike[]; +}): MixerPreviewState { + return resolveMixerPreviewFromGraph({ + nodeId: args.nodeId, + graph: buildGraphSnapshot(args.nodes, args.edges), + }); +} diff --git a/lib/canvas-node-catalog.ts b/lib/canvas-node-catalog.ts index 25795f7..7a1da27 100644 --- a/lib/canvas-node-catalog.ts +++ b/lib/canvas-node-catalog.ts @@ -9,6 +9,7 @@ import type { CanvasNodeType } from "@/lib/canvas-node-types"; export type NodeCategoryId = | "source" | "ai-output" + | "agents" | "transform" | "image-edit" | "control" @@ -20,10 +21,11 @@ export const NODE_CATEGORY_META: Record< > = { source: { label: "Quelle", order: 0 }, "ai-output": { label: "KI-Ausgabe", order: 1 }, - transform: { label: "Transformation", order: 2 }, - "image-edit": { label: "Bildbearbeitung", order: 3 }, - control: { label: "Steuerung & Flow", order: 4 }, - layout: { label: "Canvas & Layout", order: 5 }, + agents: { label: "Agents", order: 2 }, + transform: { label: "Transformation", order: 3 }, + "image-edit": { label: "Bildbearbeitung", order: 4 }, + control: { label: "Steuerung & Flow", order: 5 }, + layout: { label: "Canvas & Layout", order: 6 }, }; export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = ( @@ -85,6 +87,14 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [ category: "source", phase: 2, }), + entry({ + type: "ai-video", + label: "KI-Video-Ausgabe", + category: "source", + phase: 2, + systemOutput: true, + disabledHint: "Wird von der KI erzeugt", + }), entry({ type: "asset", label: "Asset (Stock)", @@ -112,18 +122,17 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [ systemOutput: true, disabledHint: "Wird von der KI erzeugt", }), + // Agents entry({ - type: "ai-video", - label: "KI-Video-Ausgabe", - category: "ai-output", + type: "agent", + label: "Campaign Orchestrator", + category: "agents", phase: 2, - systemOutput: true, - disabledHint: "Wird von der KI erzeugt", }), entry({ type: "agent-output", label: "Agent-Ausgabe", - category: "ai-output", + category: "agents", phase: 2, implemented: true, systemOutput: true, @@ -216,19 +225,11 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [ implemented: false, disabledHint: "Folgt in Phase 2", }), - entry({ - type: "agent", - label: "Agent", - category: "control", - phase: 2, - }), entry({ type: "mixer", label: "Mixer / Merge", category: "control", - phase: 3, - implemented: false, - disabledHint: "Folgt in Phase 3", + phase: 1, }), entry({ type: "switch", diff --git a/lib/canvas-node-templates.ts b/lib/canvas-node-templates.ts index 819cf1b..405a479 100644 --- a/lib/canvas-node-templates.ts +++ b/lib/canvas-node-templates.ts @@ -43,6 +43,18 @@ export const CANVAS_NODE_TEMPLATES = [ templateId: "campaign-distributor", }, }, + { + type: "mixer", + label: "Mixer / Merge", + width: 360, + height: 320, + defaultData: { + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }, + }, { type: "note", label: "Notiz", diff --git a/lib/canvas-op-queue.ts b/lib/canvas-op-queue.ts index 70d0397..e387632 100644 --- a/lib/canvas-op-queue.ts +++ b/lib/canvas-op-queue.ts @@ -72,6 +72,7 @@ export type CanvasSyncOpPayloadByType = { targetNodeId: Id<"nodes">; sourceHandle?: string; targetHandle?: string; + edgeIdToIgnore?: Id<"edges">; clientRequestId: string; }; removeEdge: { @@ -477,6 +478,10 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null { typeof payload.targetHandle === "string" ? payload.targetHandle : undefined, + edgeIdToIgnore: + typeof payload.edgeIdToIgnore === "string" + ? (payload.edgeIdToIgnore as Id<"edges">) + : undefined, clientRequestId: payload.clientRequestId, }, enqueuedAt, diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 5208459..3a617e6 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -231,6 +231,7 @@ export const NODE_HANDLE_MAP: Record< crop: { source: undefined, target: undefined }, render: { source: undefined, target: undefined }, agent: { target: "agent-in" }, + mixer: { source: "mixer-out", target: "base" }, "agent-output": { target: "agent-output-in" }, }; @@ -292,6 +293,16 @@ export const NODE_DEFAULTS: Record< outputNodeIds: [], }, }, + mixer: { + width: 360, + height: 320, + data: { + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }, + }, "agent-output": { width: 360, height: 260, diff --git a/messages/de.json b/messages/de.json index a19970f..7720306 100644 --- a/messages/de.json +++ b/messages/de.json @@ -223,6 +223,7 @@ "sectionsLabel": "Abschnitte", "metadataLabel": "Metadaten", "qualityChecksLabel": "Qualitaetschecks", + "detailsLabel": "Details", "previewLabel": "Vorschau", "previewFallback": "Keine Vorschau verfuegbar", "emptyValue": "-", @@ -275,6 +276,46 @@ "openrouterDataUriCreationFailed": "OpenRouter: Bild konnte nicht verarbeitet werden.", "openrouterDataUriMissingBase64": "OpenRouter: Bild konnte nicht verarbeitet werden." }, + "mediaLibrary": { + "common": { + "untitledImage": "Unbenanntes Bild", + "untitledVideo": "Unbenanntes Video", + "untitledAsset": "Unbenanntes Asset", + "unknownSize": "Groesse unbekannt", + "videoFile": "Videodatei" + }, + "dialog": { + "title": "Mediathek", + "descriptionDefault": "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.", + "descriptionImage": "Waehle ein Bild aus deiner LemonSpace-Mediathek.", + "descriptionVideo": "Waehle ein Video aus deiner LemonSpace-Mediathek.", + "descriptionAsset": "Waehle ein Asset aus deiner LemonSpace-Mediathek.", + "urlResolveError": "URLs konnten nicht geladen werden.", + "errorTitle": "Mediathek konnte nicht geladen werden", + "emptyTitle": "Keine Medien vorhanden", + "emptyDescription": "Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.", + "previewOnly": "Nur Vorschau", + "pick": "Auswaehlen", + "pickLoading": "Wird uebernommen...", + "previous": "Zurueck", + "next": "Weiter", + "pageOf": "Seite {page} von {totalPages}" + }, + "dashboard": { + "sectionTitle": "Mediathek", + "openAll": "Ganze Mediathek oeffnen", + "loading": "Mediathek wird geladen...", + "previewError": "Mediathek-Vorschau konnte nicht geladen werden. {error}", + "empty": "Noch keine Medien vorhanden. Sobald du Bilder hochlaedst oder generierst, werden sie hier angezeigt.", + "dialogDescription": "Alle deine Medien aus LemonSpace in einer zentralen Vorschau." + }, + "imageNode": { + "openButton": "Aus Mediathek", + "preparingButton": "Mediathek wird vorbereitet...", + "pickCta": "Uebernehmen", + "invalidSelection": "Nur Bilddateien mit Storage-ID koennen uebernommen werden." + } + }, "toasts": { "canvas": { "imageUploaded": "Bild hochgeladen", diff --git a/messages/en.json b/messages/en.json index 64d1ecf..f96aafb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -223,6 +223,7 @@ "sectionsLabel": "Sections", "metadataLabel": "Metadata", "qualityChecksLabel": "Quality checks", + "detailsLabel": "Details", "previewLabel": "Preview", "previewFallback": "No preview available", "emptyValue": "-", @@ -275,6 +276,46 @@ "openrouterDataUriCreationFailed": "OpenRouter: Could not process image.", "openrouterDataUriMissingBase64": "OpenRouter: Could not process image." }, + "mediaLibrary": { + "common": { + "untitledImage": "Untitled image", + "untitledVideo": "Untitled video", + "untitledAsset": "Untitled asset", + "unknownSize": "Unknown size", + "videoFile": "Video file" + }, + "dialog": { + "title": "Media library", + "descriptionDefault": "Browse your media from uploads, AI generation, and archive sources.", + "descriptionImage": "Choose an image from your LemonSpace media library.", + "descriptionVideo": "Choose a video from your LemonSpace media library.", + "descriptionAsset": "Choose an asset from your LemonSpace media library.", + "urlResolveError": "Could not load URLs.", + "errorTitle": "Media library could not be loaded", + "emptyTitle": "No media yet", + "emptyDescription": "As soon as you upload or generate media, it will appear here.", + "previewOnly": "Preview only", + "pick": "Select", + "pickLoading": "Applying...", + "previous": "Previous", + "next": "Next", + "pageOf": "Page {page} of {totalPages}" + }, + "dashboard": { + "sectionTitle": "Media library", + "openAll": "Open full media library", + "loading": "Loading media library...", + "previewError": "Could not load media preview. {error}", + "empty": "No media yet. As soon as you upload or generate images, they will appear here.", + "dialogDescription": "All your LemonSpace media in one central preview." + }, + "imageNode": { + "openButton": "From media library", + "preparingButton": "Preparing media library...", + "pickCta": "Apply", + "invalidSelection": "Only image files with a storage ID can be applied." + } + }, "toasts": { "canvas": { "imageUploaded": "Image uploaded", diff --git a/tests/agent-output-node.test.ts b/tests/agent-output-node.test.ts index de6256e..54cc19b 100644 --- a/tests/agent-output-node.test.ts +++ b/tests/agent-output-node.test.ts @@ -32,6 +32,7 @@ const translations: Record = { "agentOutputNode.sectionsLabel": "Sections", "agentOutputNode.metadataLabel": "Metadata", "agentOutputNode.qualityChecksLabel": "Quality checks", + "agentOutputNode.detailsLabel": "Details", "agentOutputNode.previewLabel": "Preview", "agentOutputNode.previewFallback": "No preview available", "agentOutputNode.emptyValue": "-", @@ -76,7 +77,7 @@ describe("AgentOutputNode", () => { root = null; }); - it("renders structured output with artifact meta, sections, metadata, quality checks, and preview fallback", async () => { + it("renders structured output with deliverable first and default-collapsed details", async () => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -134,6 +135,65 @@ describe("AgentOutputNode", () => { expect(container.querySelector('[data-testid="agent-output-metadata"]')).not.toBeNull(); expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull(); expect(container.querySelector('[data-testid="agent-output-preview"]')).not.toBeNull(); + const details = container.querySelector('[data-testid="agent-output-details"]') as + | HTMLDetailsElement + | null; + expect(details).not.toBeNull(); + expect(details?.open).toBe(false); + }); + + it("prioritizes social caption sections and moves secondary notes into details", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(AgentOutputNode, { + id: "agent-output-caption-pack", + selected: false, + dragging: false, + draggable: true, + selectable: true, + deletable: true, + zIndex: 1, + isConnectable: true, + type: "agent-output", + data: { + title: "Caption Pack", + channel: "instagram-feed", + artifactType: "social-caption-pack", + sections: [ + { id: "hook", label: "Hook", content: "Start strong" }, + { id: "format", label: "Format note", content: "Best as 4:5" }, + { id: "cta", label: "CTA", content: "Save this post" }, + { id: "hashtags", label: "Hashtags", content: "#buildinpublic #launch" }, + { id: "caption", label: "Caption", content: "Launch day is here" }, + { id: "assumptions", label: "Assumptions", content: "Audience is founder-led" }, + ], + qualityChecks: ["channel-fit"], + } as Record, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }), + ); + }); + + const primarySections = container.querySelector('[data-testid="agent-output-sections"]'); + expect(primarySections).not.toBeNull(); + const primaryText = primarySections?.textContent ?? ""; + expect(primaryText).toContain("Caption"); + expect(primaryText).toContain("Hashtags"); + expect(primaryText).toContain("CTA"); + expect(primaryText.indexOf("Caption")).toBeLessThan(primaryText.indexOf("Hashtags")); + expect(primaryText.indexOf("Hashtags")).toBeLessThan(primaryText.indexOf("CTA")); + expect(primaryText).not.toContain("Format note"); + expect(primaryText).not.toContain("Assumptions"); + + const secondarySections = container.querySelector('[data-testid="agent-output-secondary-sections"]'); + expect(secondarySections).not.toBeNull(); + expect(secondarySections?.textContent).toContain("Format note"); + expect(secondarySections?.textContent).toContain("Assumptions"); }); it("renders parseable json body in a pretty-printed code block", async () => { diff --git a/tests/canvas-connection-policy.test.ts b/tests/canvas-connection-policy.test.ts index 8026470..aff8965 100644 --- a/tests/canvas-connection-policy.test.ts +++ b/tests/canvas-connection-policy.test.ts @@ -223,4 +223,16 @@ describe("canvas connection policy", () => { getCanvasConnectionValidationMessage("agent-output-source-invalid"), ).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes."); }); + + it("treats legacy mixer handles 'null' and empty string as base occupancy", () => { + expect( + validateCanvasConnectionPolicy({ + sourceType: "asset", + targetType: "mixer", + targetIncomingCount: 1, + targetHandle: "base", + targetIncomingHandles: ["null", ""], + }), + ).toBe("mixer-handle-incoming-limit"); + }); }); diff --git a/tests/canvas-connection-validation.test.ts b/tests/canvas-connection-validation.test.ts new file mode 100644 index 0000000..d42182b --- /dev/null +++ b/tests/canvas-connection-validation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { validateCanvasEdgeSplit } from "@/components/canvas/canvas-connection-validation"; + +describe("validateCanvasEdgeSplit", () => { + it("uses middle-node target handle for first split leg", () => { + const reason = validateCanvasEdgeSplit({ + nodes: [ + { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "node-target", type: "compare", position: { x: 400, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-source-target", + source: "node-source", + target: "node-target", + targetHandle: "left", + }, + ], + splitEdge: { + id: "edge-source-target", + source: "node-source", + target: "node-target", + targetHandle: "left", + }, + middleNode: { id: "node-middle", type: "mixer", position: { x: 200, y: 0 }, data: {} }, + }); + + expect(reason).toBeNull(); + }); +}); diff --git a/tests/convex/agent-orchestration-contract.test.ts b/tests/convex/agent-orchestration-contract.test.ts index ee466e5..c243206 100644 --- a/tests/convex/agent-orchestration-contract.test.ts +++ b/tests/convex/agent-orchestration-contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { __testables } from "@/convex/agents"; +import { __testables as openrouterTestables } from "@/convex/openrouter"; describe("agent orchestration contract helpers", () => { it("builds skeleton output data with rich execution-plan metadata", () => { @@ -59,7 +60,8 @@ describe("agent orchestration contract helpers", () => { { id: "hook", label: "Hook", content: "Lead with proof." }, { id: "cta", label: "CTA", content: "Invite comments." }, ], - metadata: { audience: "SaaS founders" }, + metadata: { tonalitaet: "freundlich", audience: "SaaS founders" }, + metadataLabels: { tonalitaet: "tonalität", audience: "audience" }, qualityChecks: [], body: "", }, @@ -69,6 +71,7 @@ describe("agent orchestration contract helpers", () => { expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments."); expect(data.previewText).toBe("Lead with proof."); expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]); + expect(data.metadataLabels).toEqual({ tonalitaet: "tonalität", audience: "audience" }); }); it("requires rich execution-step fields in analyze schema", () => { @@ -87,6 +90,25 @@ describe("agent orchestration contract helpers", () => { ); }); + it("builds provider-safe execute schema without dynamic metadata maps", () => { + const schema = __testables.buildExecuteSchema(["step-1"]); + const diagnostics = openrouterTestables.getStructuredSchemaDiagnostics({ + schema, + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + }); + + const stepOne = (((schema.properties as Record).stepOutputs as Record) + .properties as Record)["step-1"] as Record; + + expect(stepOne.required).toContain("metadataEntries"); + expect(stepOne.required).not.toContain("metadata"); + expect(diagnostics.hasAnyOf).toBe(false); + expect(diagnostics.hasDynamicAdditionalProperties).toBe(false); + }); + it("resolves persisted summaries consistently across analyze and execute", () => { const promptSummary = __testables.resolveExecutionPlanSummary({ executionPlanSummary: "", diff --git a/tests/convex/ai-errors.test.ts b/tests/convex/ai-errors.test.ts index 81b7adf..10cd2e4 100644 --- a/tests/convex/ai-errors.test.ts +++ b/tests/convex/ai-errors.test.ts @@ -60,6 +60,19 @@ describe("ai error helpers", () => { ).toBe("Provider: OpenRouter API error 503: Upstream timeout"); }); + it("formats structured-output http error with extracted provider details from JSON payload", () => { + expect( + formatTerminalStatusMessage( + new ConvexError({ + code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR", + status: 502, + message: + '{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}', + }), + ), + ).toBe("Provider: OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]"); + }); + it("formats structured-output http error without falling back to raw code", () => { expect( formatTerminalStatusMessage( diff --git a/tests/convex/edges-create.test.ts b/tests/convex/edges-create.test.ts new file mode 100644 index 0000000..38f7b65 --- /dev/null +++ b/tests/convex/edges-create.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/convex/helpers", () => ({ + requireAuth: vi.fn(), +})); + +import type { Id } from "@/convex/_generated/dataModel"; +import { create, swapMixerInputs } from "@/convex/edges"; +import { requireAuth } from "@/convex/helpers"; + +type MockCanvas = { + _id: Id<"canvases">; + ownerId: string; + updatedAt: number; +}; + +type MockNode = { + _id: Id<"nodes">; + canvasId: Id<"canvases">; + type: string; +}; + +type MockEdge = { + _id: Id<"edges">; + canvasId: Id<"canvases">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; +}; + +function createMockCtx(args?: { + canvases?: MockCanvas[]; + nodes?: MockNode[]; + edges?: MockEdge[]; +}) { + const canvases = new Map((args?.canvases ?? []).map((canvas) => [canvas._id, { ...canvas }])); + const nodes = new Map((args?.nodes ?? []).map((node) => [node._id, { ...node }])); + const edges = new Map((args?.edges ?? []).map((edge) => [edge._id, { ...edge }])); + const deletes: Id<"edges">[] = []; + let insertedEdgeCount = 0; + + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (canvases.has(id as Id<"canvases">)) { + return canvases.get(id as Id<"canvases">) ?? null; + } + if (nodes.has(id as Id<"nodes">)) { + return nodes.get(id as Id<"nodes">) ?? null; + } + if (edges.has(id as Id<"edges">)) { + return edges.get(id as Id<"edges">) ?? null; + } + return null; + }), + query: vi.fn((table: "mutationRequests" | "edges") => { + if (table === "mutationRequests") { + return { + withIndex: vi.fn(() => ({ + first: vi.fn(async () => null), + })), + }; + } + + if (table === "edges") { + return { + withIndex: vi.fn( + ( + _index: "by_target", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + apply(queryBuilder); + + const targetNodeId = clauses.find((clause) => clause.field === "targetNodeId") + ?.value as Id<"nodes"> | undefined; + const incoming = Array.from(edges.values()).filter( + (edge) => edge.targetNodeId === targetNodeId, + ); + + return { + take: vi.fn(async (count: number) => incoming.slice(0, count)), + }; + }, + ), + }; + } + + throw new Error(`Unexpected query table: ${table}`); + }), + insert: vi.fn(async (table: "edges" | "mutationRequests", value: Record) => { + if (table === "mutationRequests") { + return "mutation_request_1"; + } + insertedEdgeCount += 1; + const edgeId = `edge-new-${insertedEdgeCount}` as Id<"edges">; + edges.set(edgeId, { + _id: edgeId, + canvasId: value.canvasId as Id<"canvases">, + sourceNodeId: value.sourceNodeId as Id<"nodes">, + targetNodeId: value.targetNodeId as Id<"nodes">, + sourceHandle: value.sourceHandle as string | undefined, + targetHandle: value.targetHandle as string | undefined, + }); + return edgeId; + }), + patch: vi.fn(async (id: string, patch: Record) => { + if (canvases.has(id as Id<"canvases">)) { + const canvas = canvases.get(id as Id<"canvases">); + if (!canvas) { + throw new Error("Canvas missing"); + } + canvases.set(id as Id<"canvases">, { ...canvas, ...patch }); + return; + } + + if (edges.has(id as Id<"edges">)) { + const edge = edges.get(id as Id<"edges">); + if (!edge) { + throw new Error("Edge missing"); + } + edges.set(id as Id<"edges">, { ...edge, ...patch }); + return; + } + + throw new Error("Record missing"); + }), + delete: vi.fn(async (edgeId: Id<"edges">) => { + deletes.push(edgeId); + edges.delete(edgeId); + }), + }, + }; + + return { + ctx, + listEdges: () => Array.from(edges.values()), + listCanvases: () => Array.from(canvases.values()), + deletes, + }; +} + +describe("edges.create", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("treats edgeIdToIgnore as replacement and removes ignored edge", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never); + const canvasId = "canvas-1" as Id<"canvases">; + const targetNodeId = "node-target" as Id<"nodes">; + const oldEdgeId = "edge-old" as Id<"edges">; + const mock = createMockCtx({ + canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }], + nodes: [ + { _id: "node-source-1" as Id<"nodes">, canvasId, type: "image" }, + { _id: "node-source-2" as Id<"nodes">, canvasId, type: "image" }, + { _id: targetNodeId, canvasId, type: "mixer" }, + ], + edges: [ + { + _id: oldEdgeId, + canvasId, + sourceNodeId: "node-source-1" as Id<"nodes">, + targetNodeId, + targetHandle: "base", + }, + ], + }); + + await (create as unknown as { + _handler: ( + ctx: unknown, + args: { + canvasId: Id<"canvases">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + targetHandle?: string; + edgeIdToIgnore?: Id<"edges">; + }, + ) => Promise>; + })._handler(mock.ctx, { + canvasId, + sourceNodeId: "node-source-2" as Id<"nodes">, + targetNodeId, + targetHandle: "base", + edgeIdToIgnore: oldEdgeId, + }); + + const incomingToTarget = mock + .listEdges() + .filter((edge) => edge.targetNodeId === targetNodeId && edge.targetHandle === "base"); + + expect(incomingToTarget).toHaveLength(1); + expect(incomingToTarget[0]?._id).not.toBe(oldEdgeId); + expect(mock.deletes).toEqual([oldEdgeId]); + }); +}); + +describe("edges.swapMixerInputs", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("swaps handles between two mixer input edges and updates canvas timestamp", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never); + const canvasId = "canvas-1" as Id<"canvases">; + const mixerNodeId = "node-mixer" as Id<"nodes">; + const mock = createMockCtx({ + canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }], + nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }], + edges: [ + { + _id: "edge-base" as Id<"edges">, + canvasId, + sourceNodeId: "source-1" as Id<"nodes">, + targetNodeId: mixerNodeId, + targetHandle: "base", + }, + { + _id: "edge-overlay" as Id<"edges">, + canvasId, + sourceNodeId: "source-2" as Id<"nodes">, + targetNodeId: mixerNodeId, + targetHandle: "overlay", + }, + ], + }); + + await (swapMixerInputs as unknown as { + _handler: ( + ctx: unknown, + args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + }, + ) => Promise; + })._handler(mock.ctx, { + canvasId, + edgeId: "edge-base" as Id<"edges">, + otherEdgeId: "edge-overlay" as Id<"edges">, + }); + + const swappedEdges = mock.listEdges(); + expect(swappedEdges.find((edge) => edge._id === ("edge-base" as Id<"edges">))?.targetHandle).toBe( + "overlay", + ); + expect( + swappedEdges.find((edge) => edge._id === ("edge-overlay" as Id<"edges">))?.targetHandle, + ).toBe("base"); + expect(mock.listCanvases()[0]?.updatedAt).toBeGreaterThan(1); + }); + + it("fails when one of the edge IDs does not exist", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never); + const canvasId = "canvas-1" as Id<"canvases">; + const mock = createMockCtx({ + canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }], + edges: [ + { + _id: "edge-base" as Id<"edges">, + canvasId, + sourceNodeId: "source-1" as Id<"nodes">, + targetNodeId: "node-mixer" as Id<"nodes">, + targetHandle: "base", + }, + ], + }); + + await expect( + (swapMixerInputs as unknown as { + _handler: ( + ctx: unknown, + args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + }, + ) => Promise; + })._handler(mock.ctx, { + canvasId, + edgeId: "edge-base" as Id<"edges">, + otherEdgeId: "edge-missing" as Id<"edges">, + }), + ).rejects.toThrow("Edge not found"); + }); + + it("fails when edges are not exactly one base and one overlay handle", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never); + const canvasId = "canvas-1" as Id<"canvases">; + const mixerNodeId = "node-mixer" as Id<"nodes">; + const mock = createMockCtx({ + canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }], + nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }], + edges: [ + { + _id: "edge-1" as Id<"edges">, + canvasId, + sourceNodeId: "source-1" as Id<"nodes">, + targetNodeId: mixerNodeId, + targetHandle: "base", + }, + { + _id: "edge-2" as Id<"edges">, + canvasId, + sourceNodeId: "source-2" as Id<"nodes">, + targetNodeId: mixerNodeId, + targetHandle: "base", + }, + ], + }); + + await expect( + (swapMixerInputs as unknown as { + _handler: ( + ctx: unknown, + args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + }, + ) => Promise; + })._handler(mock.ctx, { + canvasId, + edgeId: "edge-1" as Id<"edges">, + otherEdgeId: "edge-2" as Id<"edges">, + }), + ).rejects.toThrow("Mixer swap requires one base and one overlay edge"); + }); + + it("fails when edges do not belong to the same mixer target on the same canvas", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never); + const canvasId = "canvas-1" as Id<"canvases">; + const mock = createMockCtx({ + canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }], + nodes: [ + { _id: "node-mixer-a" as Id<"nodes">, canvasId, type: "mixer" }, + { _id: "node-mixer-b" as Id<"nodes">, canvasId, type: "mixer" }, + ], + edges: [ + { + _id: "edge-base" as Id<"edges">, + canvasId, + sourceNodeId: "source-1" as Id<"nodes">, + targetNodeId: "node-mixer-a" as Id<"nodes">, + targetHandle: "base", + }, + { + _id: "edge-overlay" as Id<"edges">, + canvasId, + sourceNodeId: "source-2" as Id<"nodes">, + targetNodeId: "node-mixer-b" as Id<"nodes">, + targetHandle: "overlay", + }, + ], + }); + + await expect( + (swapMixerInputs as unknown as { + _handler: ( + ctx: unknown, + args: { + canvasId: Id<"canvases">; + edgeId: Id<"edges">; + otherEdgeId: Id<"edges">; + }, + ) => Promise; + })._handler(mock.ctx, { + canvasId, + edgeId: "edge-base" as Id<"edges">, + otherEdgeId: "edge-overlay" as Id<"edges">, + }), + ).rejects.toThrow("Edges must target the same mixer node"); + }); +}); diff --git a/tests/convex/openrouter-structured-output.test.ts b/tests/convex/openrouter-structured-output.test.ts index 8c1301a..8cfa7ba 100644 --- a/tests/convex/openrouter-structured-output.test.ts +++ b/tests/convex/openrouter-structured-output.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter"; +import { + __testables, + generateStructuredObjectViaOpenRouter, +} from "@/convex/openrouter"; type MockResponseInit = { ok: boolean; @@ -285,4 +288,65 @@ describe("generateStructuredObjectViaOpenRouter", () => { }, }); }); + + it("extracts provider details from OpenRouter JSON error payload", async () => { + fetchMock.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 502, + text: '{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}', + }), + ); + + await expect( + generateStructuredObjectViaOpenRouter("test-api-key", { + model: "openai/gpt-5-mini", + messages: [{ role: "user", content: "hello" }], + schemaName: "test_schema", + schema: { type: "object" }, + }), + ).rejects.toMatchObject({ + data: { + code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR", + status: 502, + providerCode: "provider_error", + providerType: "upstream_error", + providerMessage: "Provider returned error", + message: "OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]", + }, + }); + }); + + it("detects schema features that are likely to matter for structured-output debugging", () => { + const diagnostics = __testables.getStructuredSchemaDiagnostics({ + schema: { + type: "object", + required: ["summary", "metadata"], + properties: { + summary: { type: "string" }, + metadata: { + type: "object", + additionalProperties: { + anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], + }, + }, + }, + }, + messages: [ + { role: "system", content: "system prompt" }, + { role: "user", content: "user prompt" }, + ], + }); + + expect(diagnostics).toMatchObject({ + topLevelType: "object", + topLevelRequiredCount: 2, + topLevelPropertyCount: 2, + messageCount: 2, + messageLengths: [13, 11], + hasAnyOf: true, + hasDynamicAdditionalProperties: true, + hasPatternProperties: false, + }); + }); }); diff --git a/tests/lib/agent-prompting.test.ts b/tests/lib/agent-prompting.test.ts index 2143cbc..df476c7 100644 --- a/tests/lib/agent-prompting.test.ts +++ b/tests/lib/agent-prompting.test.ts @@ -136,6 +136,10 @@ describe("agent prompting helpers", () => { const user = messages[1]?.content ?? ""; expect(system).toContain("execution rules"); + expect(system).toContain("deliverable-first rules"); + expect(system).toContain("Prioritize publishable, user-facing deliverables"); + expect(system).toContain("Do not produce reasoning-dominant output"); + expect(system).toContain("For Campaign Distributor steps, output channel-ready publishable copy first"); expect(system).toContain("channel-notes"); expect(system).toContain("German (de-DE)"); expect(user).toContain("Execution plan summary: Ship launch content"); diff --git a/tests/lib/agent-structured-output.test.ts b/tests/lib/agent-structured-output.test.ts index e14e991..e33008e 100644 --- a/tests/lib/agent-structured-output.test.ts +++ b/tests/lib/agent-structured-output.test.ts @@ -47,6 +47,10 @@ describe("normalizeAgentStructuredOutput", () => { language: "en", tags: ["launch", "saas"], }, + metadataLabels: { + language: "language", + tags: "tags", + }, qualityChecks: ["concise", "channel-fit"], body: "Legacy flat content", }); @@ -145,4 +149,38 @@ describe("normalizeAgentStructuredOutput", () => { "Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.", ); }); + + it("slugifies non-ascii metadata keys and preserves original labels", () => { + const normalized = normalizeAgentStructuredOutput( + { + sections: [ + { + id: "caption", + label: "Caption", + content: "Publish-ready caption.", + }, + ], + metadataEntries: [ + { key: "tonalität", values: ["freundlich"] }, + { key: "hashtags", values: ["dogs", "berner-sennenhund"] }, + { key: "empty", values: [] }, + { key: " ", values: ["ignored"] }, + ], + }, + { + title: "Fallback Title", + channel: "fallback-channel", + artifactType: "fallback-artifact", + }, + ); + + expect(normalized.metadata).toEqual({ + tonalitaet: "freundlich", + hashtags: ["dogs", "berner-sennenhund"], + }); + expect(normalized.metadataLabels).toEqual({ + tonalitaet: "tonalität", + hashtags: "hashtags", + }); + }); }); diff --git a/tests/lib/canvas-agent-config.test.ts b/tests/lib/canvas-agent-config.test.ts index d6ba8bf..1e6b846 100644 --- a/tests/lib/canvas-agent-config.test.ts +++ b/tests/lib/canvas-agent-config.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { nodeTypes } from "@/components/canvas/node-types"; import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; -import { NODE_CATALOG, isNodePaletteEnabled } from "@/lib/canvas-node-catalog"; +import { + NODE_CATALOG, + NODE_CATEGORY_META, + catalogEntriesByCategory, + isNodePaletteEnabled, +} from "@/lib/canvas-node-catalog"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; describe("canvas agent config", () => { @@ -22,6 +27,27 @@ describe("canvas agent config", () => { expect(entry && isNodePaletteEnabled(entry)).toBe(true); }); + it("moves agent nodes into an Agents category", () => { + expect(NODE_CATEGORY_META.agents.label).toBe("Agents"); + + const byCategory = catalogEntriesByCategory(); + const agentsEntries = byCategory.get("agents") ?? []; + const aiOutputEntries = byCategory.get("ai-output") ?? []; + + expect(agentsEntries.map((entry) => entry.type)).toEqual(["agent", "agent-output"]); + expect(agentsEntries[0]).toMatchObject({ + label: "Campaign Orchestrator", + category: "agents", + }); + + expect(aiOutputEntries.map((entry) => entry.type)).toEqual([ + "prompt", + "video-prompt", + "ai-text", + ]); + expect(NODE_CATALOG.find((entry) => entry.type === "ai-video")?.category).toBe("source"); + }); + it("keeps the agent input-only in MVP", () => { expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in"); expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined(); diff --git a/tests/lib/canvas-mixer-preview.test.ts b/tests/lib/canvas-mixer-preview.test.ts new file mode 100644 index 0000000..ef8fc89 --- /dev/null +++ b/tests/lib/canvas-mixer-preview.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import { buildGraphSnapshot } from "@/lib/canvas-render-preview"; +import { resolveMixerPreviewFromGraph } from "@/lib/canvas-mixer-preview"; + +describe("resolveMixerPreviewFromGraph", () => { + it("resolves base and overlay URLs by target handle", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-base", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "asset-source", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "render-overlay", + type: "render", + data: {}, + }, + { + id: "mixer-1", + type: "mixer", + data: { blendMode: "screen", opacity: 70, offsetX: 12, offsetY: -8 }, + }, + ], + [ + { source: "asset-source", target: "render-overlay" }, + { source: "image-base", target: "mixer-1", targetHandle: "base" }, + { source: "render-overlay", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "ready", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "screen", + opacity: 70, + offsetX: 12, + offsetY: -8, + }); + }); + + it("prefers render output URL over upstream preview source when available", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-base", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "image-upstream", + type: "image", + data: { url: "https://cdn.example.com/upstream.png" }, + }, + { + id: "render-overlay", + type: "render", + data: { + lastUploadUrl: "https://cdn.example.com/render-output.png", + }, + }, + { + id: "mixer-1", + type: "mixer", + data: {}, + }, + ], + [ + { source: "image-upstream", target: "render-overlay" }, + { source: "image-base", target: "mixer-1", targetHandle: "base" }, + { source: "render-overlay", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "ready", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/render-output.png", + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }); + }); + + it("returns partial when one input is missing", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-base", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: {}, + }, + ], + [{ source: "image-base", target: "mixer-1", targetHandle: "base" }], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "partial", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: undefined, + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + }); + }); + + it("normalizes blend mode and clamps numeric values", () => { + const graph = buildGraphSnapshot( + [ + { + id: "base-ai", + type: "ai-image", + data: { url: "https://cdn.example.com/base-ai.png" }, + }, + { + id: "overlay-asset", + type: "asset", + data: { url: "https://cdn.example.com/overlay-asset.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + blendMode: "unknown", + opacity: 180, + offsetX: 9999, + offsetY: "-9999", + }, + }, + ], + [ + { source: "base-ai", target: "mixer-1", targetHandle: "base" }, + { source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "ready", + baseUrl: "https://cdn.example.com/base-ai.png", + overlayUrl: "https://cdn.example.com/overlay-asset.png", + blendMode: "normal", + opacity: 100, + offsetX: 2048, + offsetY: -2048, + }); + }); + + it("returns error when multiple edges target the same mixer handle", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-a", + type: "image", + data: { url: "https://cdn.example.com/a.png" }, + }, + { + id: "image-b", + type: "image", + data: { url: "https://cdn.example.com/b.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: {}, + }, + ], + [ + { source: "image-a", target: "mixer-1", targetHandle: "base" }, + { source: "image-b", target: "mixer-1", targetHandle: "base" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "error", + baseUrl: undefined, + overlayUrl: undefined, + blendMode: "normal", + opacity: 100, + offsetX: 0, + offsetY: 0, + error: "duplicate-handle-edge", + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b7d57a2..80ab732 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "components/canvas/__tests__/canvas-connection-drop-target.test.tsx", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/compare-node.test.tsx", + "components/canvas/__tests__/mixer-node.test.tsx", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-connections.test.tsx",