feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -60,12 +60,16 @@ function getInitials(nameOrEmail: string) {
return normalized.slice(0, 2).toUpperCase(); 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") { if (typeof width === "number" && typeof height === "number") {
return `${width} x ${height}px`; return `${width} x ${height}px`;
} }
return "Größe unbekannt"; return unknownSizeLabel;
} }
function getMediaItemKey(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string { function getMediaItemKey(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string {
@@ -88,32 +92,48 @@ function getMediaItemKey(item: NonNullable<ReturnType<typeof useDashboardSnapsho
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`; return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
} }
function getMediaItemMeta(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string { function getMediaItemMeta(
item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number],
labels: {
unknownSize: string;
videoFile: string;
},
): string {
if (item.kind === "video") { 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<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string { function getMediaItemLabel(
item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number],
labels: {
untitledImage: string;
untitledVideo: string;
untitledAsset: string;
},
): string {
if (item.filename) { if (item.filename) {
return item.filename; return item.filename;
} }
if (item.kind === "video") { if (item.kind === "video") {
return "Unbenanntes Video"; return labels.untitledVideo;
} }
if (item.kind === "asset") { if (item.kind === "asset") {
return "Unbenanntes Asset"; return labels.untitledAsset;
} }
return "Unbenanntes Bild"; return labels.untitledImage;
} }
export function DashboardPageClient() { export function DashboardPageClient() {
const t = useTranslations("toasts"); const t = useTranslations("toasts");
const tMediaCommon = useTranslations("mediaLibrary.common");
const tMediaDashboard = useTranslations("mediaLibrary.dashboard");
const tMediaDialog = useTranslations("mediaLibrary.dialog");
const router = useRouter(); const router = useRouter();
const welcomeToastSentRef = useRef(false); const welcomeToastSentRef = useRef(false);
const { theme = "system", setTheme } = useTheme(); const { theme = "system", setTheme } = useTheme();
@@ -183,7 +203,7 @@ export function DashboardPageClient() {
} }
setMediaPreviewUrlMap({}); setMediaPreviewUrlMap({});
setMediaPreviewError( setMediaPreviewError(
error instanceof Error ? error.message : "Vorschau konnte nicht geladen werden.", error instanceof Error ? error.message : tMediaDialog("urlResolveError"),
); );
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
@@ -197,7 +217,7 @@ export function DashboardPageClient() {
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
}, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls]); }, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls, tMediaDialog]);
const handleSignOut = async () => { const handleSignOut = async () => {
toast.info(t("auth.signedOut")); toast.info(t("auth.signedOut"));
@@ -373,7 +393,7 @@ export function DashboardPageClient() {
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<ImageIcon className="size-3.5 text-muted-foreground" /> <ImageIcon className="size-3.5 text-muted-foreground" />
Mediathek {tMediaDashboard("sectionTitle")}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -383,30 +403,36 @@ export function DashboardPageClient() {
onClick={() => setIsMediaLibraryDialogOpen(true)} onClick={() => setIsMediaLibraryDialogOpen(true)}
disabled={!hasClientMounted || isSessionPending || !session?.user} disabled={!hasClientMounted || isSessionPending || !session?.user}
> >
Ganze Mediathek öffnen {tMediaDashboard("openAll")}
</Button> </Button>
</div> </div>
{dashboardSnapshot === undefined ? ( {dashboardSnapshot === undefined ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Mediathek wird geladen... {tMediaDashboard("loading")}
</div> </div>
) : mediaPreviewError ? ( ) : mediaPreviewError ? (
<div className="rounded-xl border border-dashed bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3"> <div className="rounded-xl border border-dashed bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Mediathek-Vorschau konnte nicht geladen werden. {mediaPreviewError} {tMediaDashboard("previewError", { error: mediaPreviewError })}
</div> </div>
) : !mediaPreview || mediaPreview.length === 0 ? ( ) : !mediaPreview || mediaPreview.length === 0 ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Noch keine Medien vorhanden. Sobald du Bilder hochlädst oder generierst, werden {tMediaDashboard("empty")}
sie hier angezeigt.
</div> </div>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-4">
{(mediaPreview ?? []).map((item) => { {(mediaPreview ?? []).map((item) => {
const itemKey = getMediaItemKey(item); const itemKey = getMediaItemKey(item);
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap); const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
const itemLabel = getMediaItemLabel(item); const itemLabel = getMediaItemLabel(item, {
const itemMeta = getMediaItemMeta(item); untitledImage: tMediaCommon("untitledImage"),
untitledVideo: tMediaCommon("untitledVideo"),
untitledAsset: tMediaCommon("untitledAsset"),
});
const itemMeta = getMediaItemMeta(item, {
unknownSize: tMediaCommon("unknownSize"),
videoFile: tMediaCommon("videoFile"),
});
return ( return (
<article key={itemKey} className="overflow-hidden rounded-xl border bg-card"> <article key={itemKey} className="overflow-hidden rounded-xl border bg-card">
@@ -460,8 +486,8 @@ export function DashboardPageClient() {
<MediaLibraryDialog <MediaLibraryDialog
open={isMediaLibraryDialogOpen} open={isMediaLibraryDialogOpen}
onOpenChange={setIsMediaLibraryDialogOpen} onOpenChange={setIsMediaLibraryDialogOpen}
title="Mediathek" title={tMediaDialog("title")}
description="Alle deine Medien aus LemonSpace in einer zentralen Vorschau." description={tMediaDashboard("dialogDescription")}
/> />
</div> </div>
); );

View File

@@ -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.
<!-- AGENT_PROMPT_SEGMENT:role:start -->
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 210 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.
<!-- AGENT_PROMPT_SEGMENT:role:end -->
<!-- AGENT_PROMPT_SEGMENT:style-rules:start -->
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.
<!-- AGENT_PROMPT_SEGMENT:style-rules:end -->
<!-- AGENT_PROMPT_SEGMENT:decision-framework:start -->
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.
<!-- AGENT_PROMPT_SEGMENT:decision-framework:end -->
<!-- AGENT_PROMPT_SEGMENT:experiment-rules:start -->
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.
<!-- AGENT_PROMPT_SEGMENT:experiment-rules:end -->
## 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<string, string | string[]>`
- `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."

View File

@@ -49,11 +49,12 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
| Kategorie | Nodes | Beschreibung | | Kategorie | Nodes | Beschreibung |
|-----------|-------|-------------| |-----------|-------|-------------|
| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color` | Input-Quellen für den Workflow | | **source** (Quelle) | `image`, `text`, `video`, `asset`, `color`, `ai-video` | Input-Quellen für den Workflow |
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte | | **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 | | **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen |
| **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments | | **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 | | **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente |
### Node-Typen im Detail ### 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` | | `prompt` | 1 | ✅ | ai-output | source: `prompt-out`, target: `image-in` |
| `video-prompt` | 2 | ✅ | ai-output | source: `video-prompt-out`, target: `video-prompt-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-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` |
| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` | | `ai-video` | 2 | ✅ (systemOutput) | source | source: `video-out`, target: `video-in` |
| `agent` | 2 | ✅ | control | target: `agent-in`, source (default) | | `agent` | 2 | ✅ | agents | target: `agent-in`, source (default) |
| `agent-output` | 2 | ✅ (systemOutput) | ai-output | target: `agent-output-in` | | `agent-output` | 2 | ✅ (systemOutput) | agents | target: `agent-output-in` |
| `crop` | 2 | 🔲 | transform | 🔲 | | `crop` | 2 | 🔲 | transform | 🔲 |
| `bg-remove` | 2 | 🔲 | transform | 🔲 | | `bg-remove` | 2 | 🔲 | transform | 🔲 |
| `upscale` | 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` | | `frame` | 1 | ✅ | layout | source: `frame-out`, target: `frame-in` |
| `note` | 1 | ✅ | layout | source (default), target (default) | | `note` | 1 | ✅ | layout | source (default), target (default) |
| `compare` | 1 | ✅ | layout | source: `compare-out`, targets: `left`, `right` | | `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. > `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 agent: 360 × 320
group: 400 × 300 frame: 400 × 300 group: 400 × 300 frame: 400 × 300
note: 208 × 100 compare: 500 × 380 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 ## 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. - **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`. - **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. - **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. - **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. - **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`. - **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`.

View File

@@ -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 type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { import {
computeEdgeInsertLayout, computeEdgeInsertLayout,
computeEdgeInsertReflowPlan, computeEdgeInsertReflowPlan,
getSingleCharacterHotkey,
withResolvedCompareData, withResolvedCompareData,
} from "../canvas-helpers"; } from "../canvas-helpers";
import { 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", () => { describe("computeEdgeInsertLayout", () => {
it("shifts source and target along a horizontal axis when spacing is too tight", () => { it("shifts source and target along a horizontal axis when spacing is too tight", () => {
const source = createNode({ const source = createNode({

View File

@@ -166,4 +166,99 @@ describe("CompareNode render preview inputs", () => {
preferPreview: true, 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,
},
});
});
}); });

View File

@@ -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 }) => (
<div data-testid={`handle-${id ?? "default"}`} data-handle-id={id} data-handle-type={type} />
),
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 }) => <div>{children}</div>,
}));
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<React.ComponentProps<typeof MixerNode>>) {
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<typeof MixerNode>;
}
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<React.ComponentProps<typeof MixerNode>>;
}) {
const nodes = args?.nodes ?? [{ id: "mixer-1", type: "mixer", data: {} }];
const edges = args?.edges ?? [];
await act(async () => {
root?.render(
<CanvasGraphProvider nodes={nodes} edges={edges}>
<MixerNode {...buildMixerNodeProps(args?.props)} />
</CanvasGraphProvider>,
);
});
}
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();
});
});

View File

@@ -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 { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; 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">; const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
@@ -45,7 +41,10 @@ type HookHarnessProps = {
helperResult: DroppedConnectionTarget | null; helperResult: DroppedConnectionTarget | null;
runCreateEdgeMutation?: ReturnType<typeof vi.fn>; runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>; runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
runRemoveEdgeMutation?: ReturnType<typeof vi.fn>;
runSwapMixerInputsMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>; showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
setEdgesMock?: ReturnType<typeof vi.fn>;
nodes?: RFNode[]; nodes?: RFNode[];
edges?: RFEdge[]; edges?: RFEdge[];
}; };
@@ -54,7 +53,10 @@ function HookHarness({
helperResult, helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined), runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
runRemoveEdgeMutation = vi.fn(async () => undefined),
runSwapMixerInputsMutation = vi.fn(async () => undefined),
showConnectionRejectedToast = vi.fn(), showConnectionRejectedToast = vi.fn(),
setEdgesMock,
nodes: providedNodes, nodes: providedNodes,
edges: providedEdges, edges: providedEdges,
}: HookHarnessProps) { }: HookHarnessProps) {
@@ -71,7 +73,7 @@ function HookHarness({
const isReconnectDragActiveRef = useRef(false); const isReconnectDragActiveRef = useRef(false);
const pendingConnectionCreatesRef = useRef(new Set<string>()); const pendingConnectionCreatesRef = useRef(new Set<string>());
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>()); const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
const setEdges = vi.fn(); const setEdges = setEdgesMock ?? vi.fn();
const setEdgeSyncNonce = vi.fn(); const setEdgeSyncNonce = vi.fn();
useEffect(() => { useEffect(() => {
@@ -102,7 +104,8 @@ function HookHarness({
syncPendingMoveForClientRequest: vi.fn(async () => undefined), syncPendingMoveForClientRequest: vi.fn(async () => undefined),
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation, runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation: vi.fn(async () => undefined), runRemoveEdgeMutation,
runSwapMixerInputsMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"), runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"), runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
showConnectionRejectedToast, showConnectionRejectedToast,
@@ -132,6 +135,47 @@ describe("useCanvasConnections", () => {
container = null; 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 () => { it("creates an edge when a body drop lands on another node", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined); const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn(); const showConnectionRejectedToast = vi.fn();
@@ -490,6 +534,320 @@ describe("useCanvasConnections", () => {
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); 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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "video", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "overlay",
}}
nodes={[
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "render", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
{ id: "node-asset", type: "asset", position: { x: -180, y: 180 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
{
id: "edge-existing-overlay",
source: "node-asset",
target: "node-target",
targetHandle: "overlay",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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 () => { it("ignores onConnectEnd when no connect drag is active", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined); const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn(); const showConnectionRejectedToast = vi.fn();
@@ -535,4 +893,364 @@ describe("useCanvasConnections", () => {
expect(showConnectionRejectedToast).not.toHaveBeenCalled(); expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); 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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
runRemoveEdgeMutation={runRemoveEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
runRemoveEdgeMutation={runRemoveEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
runRemoveEdgeMutation={runRemoveEdgeMutation}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
setEdgesMock={setEdgesMock}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={initialEdges}
/>,
);
});
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(
<HookHarness
helperResult={null}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
},
{
id: "edge-overlay",
source: "node-source-overlay",
target: "node-mixer",
targetHandle: "overlay",
},
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
},
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-render", type: "render", position: { x: 300, y: 0 }, data: {} },
]}
edges={[
{
id: "edge-1",
source: "node-image",
target: "node-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");
});
}); });

View File

@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; 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 { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
import { import {
Command, Command,
@@ -98,7 +99,7 @@ export function CanvasCommandPalette() {
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (!e.metaKey && !e.ctrlKey) return; if (!e.metaKey && !e.ctrlKey) return;
if (e.key.toLowerCase() !== "k") return; if (getSingleCharacterHotkey(e) !== "k") return;
e.preventDefault(); e.preventDefault();
setOpen((prev) => !prev); setOpen((prev) => !prev);
}; };

View File

@@ -4,6 +4,7 @@ import {
validateCanvasConnectionPolicy, validateCanvasConnectionPolicy,
type CanvasConnectionValidationReason, type CanvasConnectionValidationReason,
} from "@/lib/canvas-connection-policy"; } from "@/lib/canvas-connection-policy";
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { isOptimisticEdgeId } from "./canvas-helpers"; import { isOptimisticEdgeId } from "./canvas-helpers";
@@ -27,6 +28,7 @@ export function validateCanvasConnection(
sourceType: sourceNode.type ?? "", sourceType: sourceNode.type ?? "",
targetType: targetNode.type ?? "", targetType: targetNode.type ?? "",
targetNodeId: connection.target, targetNodeId: connection.target,
targetHandle: connection.targetHandle,
edges, edges,
edgeToReplaceId, edgeToReplaceId,
includeOptimisticEdges: options?.includeOptimisticEdges, includeOptimisticEdges: options?.includeOptimisticEdges,
@@ -37,22 +39,25 @@ export function validateCanvasConnectionByType(args: {
sourceType: string; sourceType: string;
targetType: string; targetType: string;
targetNodeId: string; targetNodeId: string;
targetHandle?: string | null;
edges: RFEdge[]; edges: RFEdge[];
edgeToReplaceId?: string; edgeToReplaceId?: string;
includeOptimisticEdges?: boolean; includeOptimisticEdges?: boolean;
}): CanvasConnectionValidationReason | null { }): CanvasConnectionValidationReason | null {
const targetIncomingCount = args.edges.filter( const targetIncomingEdges = args.edges.filter(
(edge) => (edge) =>
edge.className !== "temp" && edge.className !== "temp" &&
(args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) && (args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) &&
edge.target === args.targetNodeId && edge.target === args.targetNodeId &&
edge.id !== args.edgeToReplaceId, edge.id !== args.edgeToReplaceId,
).length; );
return validateCanvasConnectionPolicy({ return validateCanvasConnectionPolicy({
sourceType: args.sourceType, sourceType: args.sourceType,
targetType: args.targetType, 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"; return "unknown-node";
} }
const middleNodeHandles = NODE_HANDLE_MAP[args.middleNode.type ?? ""];
return ( return (
validateCanvasConnectionByType({ validateCanvasConnectionByType({
sourceType: sourceNode.type ?? "", sourceType: sourceNode.type ?? "",
targetType: args.middleNode.type ?? "", targetType: args.middleNode.type ?? "",
targetNodeId: args.middleNode.id, targetNodeId: args.middleNode.id,
targetHandle: middleNodeHandles?.target,
edges: args.edges, edges: args.edges,
}) ?? }) ??
validateCanvasConnectionByType({ validateCanvasConnectionByType({
sourceType: args.middleNode.type ?? "", sourceType: args.middleNode.type ?? "",
targetType: targetNode.type ?? "", targetType: targetNode.type ?? "",
targetNodeId: targetNode.id, targetNodeId: targetNode.id,
targetHandle: args.splitEdge.targetHandle,
edges: args.edges, edges: args.edges,
edgeToReplaceId: args.splitEdge.id, edgeToReplaceId: args.splitEdge.id,
}) })

View File

@@ -759,6 +759,18 @@ export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
return target.closest("input, textarea, select, [contenteditable=true]") !== null; 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 { export function isEdgeCuttable(edge: RFEdge): boolean {
if (edge.className === "temp") return false; if (edge.className === "temp") return false;
if (isOptimisticEdgeId(edge.id)) return false; if (isOptimisticEdgeId(edge.id)) return false;

View File

@@ -11,6 +11,7 @@ import {
Image, Image,
Package, Package,
Palette, Palette,
Layers,
Sparkles, Sparkles,
StickyNote, StickyNote,
Sun, Sun,
@@ -43,6 +44,7 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
"light-adjust": Sun, "light-adjust": Sun,
"detail-adjust": Focus, "detail-adjust": Focus,
render: ImageDown, render: ImageDown,
mixer: Layers,
}; };
const NODE_SEARCH_KEYWORDS: Partial< const NODE_SEARCH_KEYWORDS: Partial<

View File

@@ -16,12 +16,28 @@ type UseCanvasReconnectHandlersParams = {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
}) => Promise<unknown>;
runSwapMixerInputsMutation?: (args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
}) => Promise<unknown>; }) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
validateConnection?: ( validateConnection?: (
oldEdge: RFEdge, oldEdge: RFEdge,
newConnection: Connection, newConnection: Connection,
) => string | null; ) => 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; onInvalidConnection?: (message: string) => void;
}; };
@@ -31,18 +47,29 @@ export function useCanvasReconnectHandlers({
isReconnectDragActiveRef, isReconnectDragActiveRef,
setEdges, setEdges,
runCreateEdgeMutation, runCreateEdgeMutation,
runSwapMixerInputsMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
validateConnection, validateConnection,
resolveMixerSwapReconnect,
onInvalidConnection, onInvalidConnection,
}: UseCanvasReconnectHandlersParams): { }: UseCanvasReconnectHandlersParams): {
onReconnectStart: () => void; onReconnectStart: () => void;
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void; onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
} { } {
const pendingReconnectRef = useRef<{ const pendingReconnectRef = useRef<
| {
kind: "replace";
oldEdge: RFEdge; oldEdge: RFEdge;
newConnection: Connection; newConnection: Connection;
} | null>(null); }
| {
kind: "swap";
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
}
| null
>(null);
const onReconnectStart = useCallback(() => { const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false; edgeReconnectSuccessful.current = false;
@@ -54,6 +81,38 @@ export function useCanvasReconnectHandlers({
(oldEdge: RFEdge, newConnection: Connection) => { (oldEdge: RFEdge, newConnection: Connection) => {
const validationError = validateConnection?.(oldEdge, newConnection) ?? null; const validationError = validateConnection?.(oldEdge, newConnection) ?? null;
if (validationError) { 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; edgeReconnectSuccessful.current = true;
pendingReconnectRef.current = null; pendingReconnectRef.current = null;
onInvalidConnection?.(validationError); onInvalidConnection?.(validationError);
@@ -61,10 +120,20 @@ export function useCanvasReconnectHandlers({
} }
edgeReconnectSuccessful.current = true; edgeReconnectSuccessful.current = true;
pendingReconnectRef.current = { oldEdge, newConnection }; pendingReconnectRef.current = {
kind: "replace",
oldEdge,
newConnection,
};
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges)); setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
}, },
[edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection], [
edgeReconnectSuccessful,
onInvalidConnection,
resolveMixerSwapReconnect,
setEdges,
validateConnection,
],
); );
const onReconnectEnd = useCallback( const onReconnectEnd = useCallback(
@@ -95,18 +164,16 @@ export function useCanvasReconnectHandlers({
const pendingReconnect = pendingReconnectRef.current; const pendingReconnect = pendingReconnectRef.current;
pendingReconnectRef.current = null; pendingReconnectRef.current = null;
if ( if (pendingReconnect?.kind === "replace" && pendingReconnect.newConnection.source && pendingReconnect.newConnection.target) {
pendingReconnect &&
pendingReconnect.newConnection.source &&
pendingReconnect.newConnection.target
) {
void runCreateEdgeMutation({ void runCreateEdgeMutation({
canvasId, canvasId,
sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">, sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">,
targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">, targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">,
sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined, sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined,
targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined, targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined,
}).catch((error) => { edgeIdToIgnore: pendingReconnect.oldEdge.id as Id<"edges">,
})
.catch((error) => {
console.error("[Canvas edge reconnect failed] create edge", { console.error("[Canvas edge reconnect failed] create edge", {
oldEdgeId: pendingReconnect.oldEdge.id, oldEdgeId: pendingReconnect.oldEdge.id,
source: pendingReconnect.newConnection.source, source: pendingReconnect.newConnection.source,
@@ -114,13 +181,18 @@ export function useCanvasReconnectHandlers({
error: String(error), error: String(error),
}); });
}); });
}
if (pendingReconnect.oldEdge.className !== "temp") { if (pendingReconnect?.kind === "swap") {
void runRemoveEdgeMutation({ if (runSwapMixerInputsMutation) {
edgeId: pendingReconnect.oldEdge.id as Id<"edges">, void runSwapMixerInputsMutation({
canvasId,
edgeId: pendingReconnect.edgeId,
otherEdgeId: pendingReconnect.otherEdgeId,
}).catch((error) => { }).catch((error) => {
console.error("[Canvas edge reconnect failed] remove old edge", { console.error("[Canvas edge reconnect failed] swap mixer inputs", {
oldEdgeId: pendingReconnect.oldEdge.id, edgeId: pendingReconnect.edgeId,
otherEdgeId: pendingReconnect.otherEdgeId,
error: String(error), error: String(error),
}); });
}); });
@@ -138,6 +210,7 @@ export function useCanvasReconnectHandlers({
isReconnectDragActiveRef, isReconnectDragActiveRef,
runCreateEdgeMutation, runCreateEdgeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
runSwapMixerInputsMutation,
setEdges, setEdges,
], ],
); );

View File

@@ -12,6 +12,7 @@ import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar"; import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar";
import { import {
collectCuttableEdgesAlongScreenSegment, collectCuttableEdgesAlongScreenSegment,
getSingleCharacterHotkey,
getIntersectedEdgeId, getIntersectedEdgeId,
isEdgeCuttable, isEdgeCuttable,
isEditableKeyboardTarget, isEditableKeyboardTarget,
@@ -50,8 +51,7 @@ export function useCanvasScissors({
return; return;
} }
if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.metaKey || event.ctrlKey || event.altKey) return;
const isScissorHotkey = const isScissorHotkey = getSingleCharacterHotkey(event) === "k";
event.key.length === 1 && event.key.toLowerCase() === "k";
if (!isScissorHotkey) return; if (!isScissorHotkey) return;
if (isEditableKeyboardTarget(event.target)) return; if (isEditableKeyboardTarget(event.target)) return;

View File

@@ -56,6 +56,7 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import { import {
CANVAS_MIN_ZOOM, CANVAS_MIN_ZOOM,
DEFAULT_EDGE_OPTIONS, DEFAULT_EDGE_OPTIONS,
getSingleCharacterHotkey,
getMiniMapNodeColor, getMiniMapNodeColor,
getMiniMapNodeStrokeColor, getMiniMapNodeStrokeColor,
getPendingRemovedEdgeIdsFromLocalOps, getPendingRemovedEdgeIdsFromLocalOps,
@@ -100,6 +101,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia); const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
const runSwapMixerInputsMutation = useMutation(api.edges.swapMixerInputs);
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>()); const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
string | null string | null
@@ -237,7 +239,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableKeyboardTarget(e.target)) return; if (isEditableKeyboardTarget(e.target)) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : ""; const key = getSingleCharacterHotkey(e);
if (key === "v") { if (key === "v") {
e.preventDefault(); e.preventDefault();
handleNavToolChange("select"); handleNavToolChange("select");
@@ -342,6 +344,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation, runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
runSwapMixerInputsMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast, showConnectionRejectedToast,

View File

@@ -18,6 +18,7 @@ import RenderNode from "./nodes/render-node";
import CropNode from "./nodes/crop-node"; import CropNode from "./nodes/crop-node";
import AgentNode from "./nodes/agent-node"; import AgentNode from "./nodes/agent-node";
import AgentOutputNode from "./nodes/agent-output-node"; import AgentOutputNode from "./nodes/agent-output-node";
import MixerNode from "./nodes/mixer-node";
/** /**
* Node-Type-Map für React Flow. * Node-Type-Map für React Flow.
@@ -46,5 +47,6 @@ export const nodeTypes = {
crop: CropNode, crop: CropNode,
render: RenderNode, render: RenderNode,
agent: AgentNode, agent: AgentNode,
mixer: MixerNode,
"agent-output": AgentOutputNode, "agent-output": AgentOutputNode,
} as const; } as const;

View File

@@ -20,6 +20,7 @@ type AgentOutputNodeData = {
content?: string; content?: string;
}>; }>;
metadata?: Record<string, string | string[] | unknown>; metadata?: Record<string, string | string[] | unknown>;
metadataLabels?: Record<string, string | unknown>;
qualityChecks?: string[]; qualityChecks?: string[];
outputType?: string; outputType?: string;
body?: string; body?: string;
@@ -102,6 +103,18 @@ function normalizeMetadata(raw: AgentOutputNodeData["metadata"]) {
return entries; 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[] { function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): string[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return []; return [];
@@ -113,6 +126,66 @@ function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): stri
.filter((value) => value !== ""); .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<AgentOutputNodeType>) { export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
const t = useTranslations("agentOutputNode"); const t = useTranslations("agentOutputNode");
const nodeData = data as AgentOutputNodeData; const nodeData = data as AgentOutputNodeData;
@@ -140,14 +213,23 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
const body = nodeData.body ?? ""; const body = nodeData.body ?? "";
const artifactType = nodeData.artifactType ?? nodeData.outputType ?? ""; const artifactType = nodeData.artifactType ?? nodeData.outputType ?? "";
const sections = normalizeSections(nodeData.sections); const sections = normalizeSections(nodeData.sections);
const { primary: primarySections, secondary: secondarySections } = partitionSections(
sections,
artifactType,
);
const metadataEntries = normalizeMetadata(nodeData.metadata); const metadataEntries = normalizeMetadata(nodeData.metadata);
const metadataLabels = nodeData.metadataLabels;
const qualityChecks = normalizeQualityChecks(nodeData.qualityChecks); const qualityChecks = normalizeQualityChecks(nodeData.qualityChecks);
const previewText = const previewText =
typeof nodeData.previewText === "string" && nodeData.previewText.trim() !== "" typeof nodeData.previewText === "string" && nodeData.previewText.trim() !== ""
? nodeData.previewText.trim() ? nodeData.previewText.trim()
: sections[0]?.content ?? ""; : primarySections[0]?.content ?? sections[0]?.content ?? "";
const hasStructuredOutput = const hasStructuredOutput =
sections.length > 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || previewText !== ""; sections.length > 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); const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body);
return ( return (
@@ -186,24 +268,6 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
) : null} ) : null}
</header> </header>
<section
data-testid="agent-output-meta-strip"
className="grid grid-cols-2 gap-2 rounded-md border border-border/70 bg-muted/30 px-2 py-1.5"
>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
{nodeData.channel ?? t("emptyValue")}
</p>
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("artifactTypeLabel")}</p>
<p className="truncate text-xs font-medium text-foreground/90" title={artifactType}>
{artifactType || t("emptyValue")}
</p>
</div>
</section>
{isSkeleton ? ( {isSkeleton ? (
<section className="space-y-1"> <section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
@@ -218,11 +282,60 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
</section> </section>
) : hasStructuredOutput ? ( ) : hasStructuredOutput ? (
<> <>
{sections.length > 0 ? ( {previewText !== "" ? (
<section data-testid="agent-output-preview" className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("previewLabel")}</p>
<div className="max-h-40 overflow-auto rounded-md border border-border/70 bg-background/70 p-3 text-[13px] leading-relaxed text-foreground/90">
<p className="whitespace-pre-wrap break-words">{previewText}</p>
</div>
</section>
) : null}
{primarySections.length > 0 ? (
<section data-testid="agent-output-sections" className="space-y-1.5"> <section data-testid="agent-output-sections" className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{sections.map((section) => ( {primarySections.map((section) => (
<div key={section.id} className="rounded-md border border-border/70 bg-background/70 p-2">
<p className="text-[11px] font-semibold text-foreground/90">{section.label}</p>
<p className="whitespace-pre-wrap break-words text-[12px] leading-relaxed text-foreground/90">
{section.content}
</p>
</div>
))}
</div>
</section>
) : null}
{hasDetailsContent ? (
<details data-testid="agent-output-details" className="rounded-md border border-border/70 bg-muted/30 px-2 py-1.5">
<summary className="cursor-pointer text-[11px] font-semibold text-foreground/80">{t("detailsLabel")}</summary>
<div className="mt-2 space-y-2">
{hasMetaValues ? (
<section
data-testid="agent-output-meta-strip"
className="grid grid-cols-2 gap-2 rounded-md border border-border/70 bg-background/70 px-2 py-1.5"
>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
{nodeData.channel ?? t("emptyValue")}
</p>
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("artifactTypeLabel")}</p>
<p className="truncate text-xs font-medium text-foreground/90" title={artifactType}>
{artifactType || t("emptyValue")}
</p>
</div>
</section>
) : null}
{secondarySections.length > 0 ? (
<section data-testid="agent-output-secondary-sections" className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p>
<div className="space-y-1.5">
{secondarySections.map((section) => (
<div key={section.id} className="rounded-md border border-border/70 bg-background/70 p-2"> <div key={section.id} className="rounded-md border border-border/70 bg-background/70 p-2">
<p className="text-[11px] font-semibold text-foreground/90">{section.label}</p> <p className="text-[11px] font-semibold text-foreground/90">{section.label}</p>
<p className="whitespace-pre-wrap break-words text-[12px] leading-relaxed text-foreground/90"> <p className="whitespace-pre-wrap break-words text-[12px] leading-relaxed text-foreground/90">
@@ -240,7 +353,7 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<div className="space-y-1 text-[12px] text-foreground/90"> <div className="space-y-1 text-[12px] text-foreground/90">
{metadataEntries.map(([key, value]) => ( {metadataEntries.map(([key, value]) => (
<p key={key} className="break-words"> <p key={key} className="break-words">
<span className="font-semibold">{key}</span>: {value} <span className="font-semibold">{resolveMetadataLabel(key, metadataLabels)}</span>: {value}
</p> </p>
))} ))}
</div> </div>
@@ -262,13 +375,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
</div> </div>
</section> </section>
) : null} ) : null}
<section data-testid="agent-output-preview" className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("previewLabel")}</p>
<div className="max-h-40 overflow-auto rounded-md border border-border/70 bg-background/70 p-3 text-[13px] leading-relaxed text-foreground/90">
<p className="whitespace-pre-wrap break-words">{previewText || t("previewFallback")}</p>
</div> </div>
</section> </details>
) : null}
</> </>
) : formattedJsonBody ? ( ) : formattedJsonBody ? (
<section className="space-y-1"> <section className="space-y-1">

View File

@@ -11,6 +11,10 @@ import {
resolveRenderPreviewInputFromGraph, resolveRenderPreviewInputFromGraph,
type RenderPreviewInput, type RenderPreviewInput,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import {
resolveMixerPreviewFromGraph,
type MixerPreviewState,
} from "@/lib/canvas-mixer-preview";
interface CompareNodeData { interface CompareNodeData {
leftUrl?: string; leftUrl?: string;
@@ -25,6 +29,7 @@ type CompareSideState = {
finalUrl?: string; finalUrl?: string;
label?: string; label?: string;
previewInput?: RenderPreviewInput; previewInput?: RenderPreviewInput;
mixerPreviewState?: MixerPreviewState;
isStaleRenderOutput: boolean; isStaleRenderOutput: boolean;
}; };
@@ -59,6 +64,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
const label = finalLabel ?? sourceLabel ?? defaultLabel; const label = finalLabel ?? sourceLabel ?? defaultLabel;
let previewInput: RenderPreviewInput | undefined; let previewInput: RenderPreviewInput | undefined;
let mixerPreviewState: MixerPreviewState | undefined;
let isStaleRenderOutput = false; let isStaleRenderOutput = false;
if (sourceNode && sourceNode.type === "render") { if (sourceNode && sourceNode.type === "render") {
@@ -97,11 +103,36 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
} }
} }
if (finalUrl) { if (sourceNode && sourceNode.type === "mixer") {
return { finalUrl, label, previewInput, isStaleRenderOutput }; 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 { return {
@@ -117,8 +148,16 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
graph, graph,
]); ]);
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput); const hasLeft = Boolean(
const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput); resolvedSides.left.finalUrl ||
resolvedSides.left.previewInput ||
resolvedSides.left.mixerPreviewState,
);
const hasRight = Boolean(
resolvedSides.right.finalUrl ||
resolvedSides.right.previewInput ||
resolvedSides.right.mixerPreviewState,
);
const hasConnectedRenderInput = useMemo( const hasConnectedRenderInput = useMemo(
() => () =>
incomingEdges.some((edge) => { incomingEdges.some((edge) => {
@@ -273,6 +312,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
finalUrl={resolvedSides.right.finalUrl} finalUrl={resolvedSides.right.finalUrl}
label={resolvedSides.right.label} label={resolvedSides.right.label}
previewInput={resolvedSides.right.previewInput} previewInput={resolvedSides.right.previewInput}
mixerPreviewState={resolvedSides.right.mixerPreviewState}
nodeWidth={previewNodeWidth} nodeWidth={previewNodeWidth}
preferPreview={effectiveDisplayMode === "preview"} preferPreview={effectiveDisplayMode === "preview"}
/> />
@@ -283,6 +323,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
finalUrl={resolvedSides.left.finalUrl} finalUrl={resolvedSides.left.finalUrl}
label={resolvedSides.left.label} label={resolvedSides.left.label}
previewInput={resolvedSides.left.previewInput} previewInput={resolvedSides.left.previewInput}
mixerPreviewState={resolvedSides.left.mixerPreviewState}
nodeWidth={previewNodeWidth} nodeWidth={previewNodeWidth}
clipWidthPercent={sliderX} clipWidthPercent={sliderX}
preferPreview={effectiveDisplayMode === "preview"} preferPreview={effectiveDisplayMode === "preview"}

View File

@@ -6,6 +6,7 @@ import {
shouldFastPathPreviewPipeline, shouldFastPathPreviewPipeline,
type RenderPreviewInput, type RenderPreviewInput,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import type { MixerPreviewState } from "@/lib/canvas-mixer-preview";
const EMPTY_STEPS: RenderPreviewInput["steps"] = []; const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
@@ -13,6 +14,7 @@ type CompareSurfaceProps = {
finalUrl?: string; finalUrl?: string;
label?: string; label?: string;
previewInput?: RenderPreviewInput; previewInput?: RenderPreviewInput;
mixerPreviewState?: MixerPreviewState;
nodeWidth: number; nodeWidth: number;
clipWidthPercent?: number; clipWidthPercent?: number;
preferPreview?: boolean; preferPreview?: boolean;
@@ -22,6 +24,7 @@ export default function CompareSurface({
finalUrl, finalUrl,
label, label,
previewInput, previewInput,
mixerPreviewState,
nodeWidth, nodeWidth,
clipWidthPercent, clipWidthPercent,
preferPreview, preferPreview,
@@ -52,6 +55,7 @@ export default function CompareSurface({
}); });
const hasPreview = Boolean(usePreview && previewInput); const hasPreview = Boolean(usePreview && previewInput);
const hasMixerPreview = mixerPreviewState?.status === "ready";
const clipStyle = const clipStyle =
typeof clipWidthPercent === "number" typeof clipWidthPercent === "number"
? { ? {
@@ -75,6 +79,28 @@ export default function CompareSurface({
ref={canvasRef} ref={canvasRef}
className="absolute inset-0 h-full w-full object-contain" className="absolute inset-0 h-full w-full object-contain"
/> />
) : hasMixerPreview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={mixerPreviewState.baseUrl}
alt={label ?? "Comparison image"}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={mixerPreviewState.overlayUrl}
alt={label ?? "Comparison image"}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
style={{
mixBlendMode: mixerPreviewState.blendMode,
opacity: mixerPreviewState.opacity / 100,
transform: `translate(${mixerPreviewState.offsetX}px, ${mixerPreviewState.offsetY}px)`,
}}
/>
</>
) : null} ) : null}
{hasPreview ? ( {hasPreview ? (

View File

@@ -71,6 +71,7 @@ export default function ImageNode({
height, height,
}: NodeProps<ImageNode>) { }: NodeProps<ImageNode>) {
const t = useTranslations('toasts'); const t = useTranslations('toasts');
const tMedia = useTranslations("mediaLibrary.imageNode");
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia); const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
@@ -377,7 +378,7 @@ export default function ImageNode({
} }
if (item.kind !== "image" || !item.storageId) { 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; 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(() => { const handleClick = useCallback(() => {
@@ -586,7 +587,9 @@ export default function ImageNode({
disabled={isNodeLoading || !isNodeStable} 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" 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")}
</button> </button>
</div> </div>
)} )}
@@ -650,7 +653,7 @@ export default function ImageNode({
onOpenChange={setIsMediaLibraryOpen} onOpenChange={setIsMediaLibraryOpen}
onPick={handlePickFromMediaLibrary} onPick={handlePickFromMediaLibrary}
kindFilter="image" kindFilter="image"
pickCtaLabel="Uebernehmen" pickCtaLabel={tMedia("pickCta")}
/> />
</> </>
); );

View File

@@ -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<string, unknown>;
const updateData = (patch: Partial<ReturnType<typeof normalizeMixerPreviewData>>) => {
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...currentData,
...patch,
},
});
};
const onBlendModeChange = (event: ChangeEvent<HTMLSelectElement>) => {
setHasImageLoadError(false);
updateData({ blendMode: event.target.value as MixerBlendMode });
};
const onNumberChange = (field: "opacity" | "offsetX" | "offsetY") => (
event: FormEvent<HTMLInputElement>,
) => {
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 (
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
<Handle
type="target"
position={Position.Left}
id="base"
style={{ top: "35%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
/>
<Handle
type="target"
position={Position.Left}
id="overlay"
style={{ top: "58%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
/>
<Handle
type="source"
position={Position.Right}
id="mixer-out"
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
/>
<div className="grid h-full w-full grid-rows-[auto_minmax(0,1fr)_auto]">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
Mixer
</div>
<div className="relative min-h-[140px] overflow-hidden bg-muted/40">
{showReadyPreview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewState.baseUrl}
alt="Mixer base"
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
onError={() => setHasImageLoadError(true)}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewState.overlayUrl}
alt="Mixer overlay"
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
onError={() => setHasImageLoadError(true)}
style={{
mixBlendMode: previewState.blendMode,
opacity: previewState.opacity / 100,
transform: `translate(${previewState.offsetX}px, ${previewState.offsetY}px)`,
}}
/>
</>
) : null}
{previewState.status === "empty" && !showPreviewError ? (
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-muted-foreground">
Connect base and overlay images
</div>
) : null}
{previewState.status === "partial" && !showPreviewError ? (
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-muted-foreground">
Waiting for second input
</div>
) : null}
{showPreviewError ? (
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-red-600">
Preview unavailable
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border p-2 text-[11px]">
<label className="col-span-2 flex flex-col gap-1 text-muted-foreground">
<span>Blend mode</span>
<select
name="blendMode"
value={normalizedData.blendMode}
onChange={onBlendModeChange}
className="nodrag h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
>
{BLEND_MODE_OPTIONS.map((mode) => (
<option key={mode} value={mode}>
{mode}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
<span>Opacity</span>
<input
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
type="number"
name="opacity"
min={0}
max={100}
step={1}
value={normalizedData.opacity}
onInput={onNumberChange("opacity")}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
<span>Offset X</span>
<input
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
type="number"
name="offsetX"
step={1}
value={normalizedData.offsetX}
onInput={onNumberChange("offsetX")}
/>
</label>
<label className="col-span-2 flex flex-col gap-1 text-muted-foreground">
<span>Offset Y</span>
<input
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
type="number"
name="offsetY"
step={1}
value={normalizedData.offsetY}
onInput={onNumberChange("offsetY")}
/>
</label>
</div>
</div>
</BaseNodeWrapper>
);
}

View File

@@ -50,6 +50,7 @@ type UseCanvasConnectionsParams = {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
}) => Promise<unknown>; }) => Promise<unknown>;
runSplitEdgeAtExistingNodeMutation: (args: { runSplitEdgeAtExistingNodeMutation: (args: {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
@@ -61,6 +62,11 @@ type UseCanvasConnectionsParams = {
newNodeTargetHandle?: string; newNodeTargetHandle?: string;
}) => Promise<unknown>; }) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
runSwapMixerInputsMutation: (args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
}) => Promise<unknown>;
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: { runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
type: CanvasNodeType; type: CanvasNodeType;
@@ -113,6 +119,7 @@ export function useCanvasConnections({
runRemoveEdgeMutation, runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly,
runSwapMixerInputsMutation,
showConnectionRejectedToast, showConnectionRejectedToast,
}: UseCanvasConnectionsParams) { }: UseCanvasConnectionsParams) {
const [connectionDropMenu, setConnectionDropMenu] = const [connectionDropMenu, setConnectionDropMenu] =
@@ -178,6 +185,70 @@ export function useCanvasConnections({
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast], [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<OnConnectEnd>( const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => { (event, connectionState) => {
if (!isConnectDragActiveRef.current) { if (!isConnectDragActiveRef.current) {
@@ -438,6 +509,7 @@ export function useCanvasConnections({
sourceType: fromNode.type ?? "", sourceType: fromNode.type ?? "",
targetType: template.type, targetType: template.type,
targetNodeId: `__pending_${template.type}_${Date.now()}`, targetNodeId: `__pending_${template.type}_${Date.now()}`,
targetHandle: handles?.target,
edges: edgesRef.current, edges: edgesRef.current,
}); });
if (validationError) { if (validationError) {
@@ -469,6 +541,7 @@ export function useCanvasConnections({
sourceType: template.type, sourceType: template.type,
targetType: fromNode.type ?? "", targetType: fromNode.type ?? "",
targetNodeId: fromNode.id, targetNodeId: fromNode.id,
targetHandle: ctx.fromHandleId,
edges: edgesRef.current, edges: edgesRef.current,
}); });
if (validationError) { if (validationError) {
@@ -518,8 +591,10 @@ export function useCanvasConnections({
setEdges, setEdges,
runCreateEdgeMutation, runCreateEdgeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
runSwapMixerInputsMutation,
validateConnection: (oldEdge, nextConnection) => validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id), validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
resolveMixerSwapReconnect,
onInvalidConnection: (reason) => { onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason); showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
}, },

View File

@@ -7,8 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
useAuthQuery: vi.fn(), useAuthQuery: vi.fn(),
resolveUrls: vi.fn(async () => ({})), resolveUrls: vi.fn(async () => ({})),
useTranslations: vi.fn(),
})); }));
const translations = {
previous: "Zurueck",
next: "Weiter",
pageOf: "Seite {page} von {totalPages}",
} as const;
vi.mock("convex/react", () => ({ vi.mock("convex/react", () => ({
useMutation: () => mocks.resolveUrls, useMutation: () => mocks.resolveUrls,
})); }));
@@ -17,6 +24,10 @@ vi.mock("@/hooks/use-auth-query", () => ({
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args), useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
})); }));
vi.mock("next-intl", () => ({
useTranslations: (...args: unknown[]) => mocks.useTranslations(...args),
}));
vi.mock("@/components/ui/dialog", () => ({ vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div>{children}</div> : null, open ? <div>{children}</div> : null,
@@ -49,7 +60,23 @@ describe("MediaLibraryDialog", () => {
beforeEach(() => { beforeEach(() => {
mocks.useAuthQuery.mockReset(); mocks.useAuthQuery.mockReset();
mocks.resolveUrls.mockReset(); mocks.resolveUrls.mockReset();
mocks.useTranslations.mockReset();
mocks.resolveUrls.mockImplementation(async () => ({})); mocks.resolveUrls.mockImplementation(async () => ({}));
const translate = (
key: keyof typeof translations,
values?: Record<string, string | number>,
) => {
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"); container = document.createElement("div");
document.body.appendChild(container); 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({ mocks.useAuthQuery.mockReturnValue({
items: makeItems(10), items: makeItems(10),
page: 1, page: 1,
@@ -102,9 +129,9 @@ describe("MediaLibraryDialog", () => {
const cards = document.querySelectorAll("img[alt^='Item 1-']"); const cards = document.querySelectorAll("img[alt^='Item 1-']");
expect(cards).toHaveLength(8); expect(cards).toHaveLength(8);
expect(document.body.textContent).toContain("Previous"); expect(document.body.textContent).toContain("Zurueck");
expect(document.body.textContent).toContain("Page 1 of 3"); expect(document.body.textContent).toContain("Seite 1 von 3");
expect(document.body.textContent).toContain("Next"); expect(document.body.textContent).toContain("Weiter");
}); });
it("updates query args when clicking next and previous", async () => { it("updates query args when clicking next and previous", async () => {
@@ -135,7 +162,7 @@ describe("MediaLibraryDialog", () => {
}); });
const nextButton = Array.from(document.querySelectorAll("button")).find( const nextButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Next", (button) => button.textContent?.trim() === "Weiter",
); );
if (!(nextButton instanceof HTMLButtonElement)) { if (!(nextButton instanceof HTMLButtonElement)) {
throw new Error("Next button not found"); throw new Error("Next button not found");
@@ -149,7 +176,7 @@ describe("MediaLibraryDialog", () => {
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 })); expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
const previousButton = Array.from(document.querySelectorAll("button")).find( const previousButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Previous", (button) => button.textContent?.trim() === "Zurueck",
); );
if (!(previousButton instanceof HTMLButtonElement)) { if (!(previousButton instanceof HTMLButtonElement)) {
throw new Error("Previous button not found"); throw new Error("Previous button not found");

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react"; import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -73,15 +74,18 @@ function formatDimensions(width: number | undefined, height: number | undefined)
return `${width} x ${height}px`; return `${width} x ${height}px`;
} }
function formatMediaMeta(item: MediaLibraryItem): string { function formatMediaMeta(
item: MediaLibraryItem,
tCommon: ReturnType<typeof useTranslations>,
): string {
if (item.kind === "video") { if (item.kind === "video") {
if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) { if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) {
return `${Math.max(1, Math.round(item.durationSeconds))}s`; 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 { function getItemKey(item: MediaLibraryItem): string {
@@ -104,32 +108,37 @@ function getItemKey(item: MediaLibraryItem): string {
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`; return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
} }
function getItemLabel(item: MediaLibraryItem): string { function getItemLabel(
item: MediaLibraryItem,
tCommon: ReturnType<typeof useTranslations>,
): string {
if (item.filename) { if (item.filename) {
return item.filename; return item.filename;
} }
if (item.kind === "video") { if (item.kind === "video") {
return "Unbenanntes Video"; return tCommon("untitledVideo");
} }
if (item.kind === "asset") { if (item.kind === "asset") {
return "Unbenanntes Asset"; return tCommon("untitledAsset");
} }
return "Unbenanntes Bild"; return tCommon("untitledImage");
} }
export function MediaLibraryDialog({ export function MediaLibraryDialog({
open, open,
onOpenChange, onOpenChange,
onPick, onPick,
title = "Mediathek", title,
description, description,
pageSize = DEFAULT_PAGE_SIZE, pageSize = DEFAULT_PAGE_SIZE,
kindFilter, kindFilter,
pickCtaLabel = "Auswaehlen", pickCtaLabel,
}: MediaLibraryDialogProps) { }: MediaLibraryDialogProps) {
const tDialog = useTranslations("mediaLibrary.dialog");
const tCommon = useTranslations("mediaLibrary.common");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const normalizedPageSize = useMemo(() => { const normalizedPageSize = useMemo(() => {
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) { if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
@@ -203,7 +212,7 @@ export function MediaLibraryDialog({
return; return;
} }
setUrlMap({}); setUrlMap({});
setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden."); setUrlError(error instanceof Error ? error.message : tDialog("urlResolveError"));
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
setIsResolvingUrls(false); setIsResolvingUrls(false);
@@ -216,7 +225,7 @@ export function MediaLibraryDialog({
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
}, [metadata, open, resolveUrls]); }, [metadata, open, resolveUrls, tDialog]);
const items: MediaLibraryItem[] = useMemo(() => { const items: MediaLibraryItem[] = useMemo(() => {
if (!metadata) { if (!metadata) {
@@ -229,16 +238,25 @@ export function MediaLibraryDialog({
})); }));
}, [metadata, urlMap]); }, [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 isMetadataLoading = open && metadata === undefined;
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls); const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
const isPreviewMode = typeof onPick !== "function"; const isPreviewMode = typeof onPick !== "function";
const effectiveTitle = title ?? tDialog("title");
const effectivePickCtaLabel = pickCtaLabel ?? tDialog("pick");
const effectiveDescription = const effectiveDescription =
description ?? description ??
(kindFilter === "image" (kindFilter === "image"
? "Waehle ein Bild aus deiner LemonSpace-Mediathek." ? tDialog("descriptionImage")
: "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen."); : kindFilter === "video"
? tDialog("descriptionVideo")
: kindFilter === "asset"
? tDialog("descriptionAsset")
: tDialog("descriptionDefault"));
async function handlePick(item: MediaLibraryItem): Promise<void> { async function handlePick(item: MediaLibraryItem): Promise<void> {
if (!onPick || pendingPickItemKey) { if (!onPick || pendingPickItemKey) {
@@ -257,7 +275,7 @@ export function MediaLibraryDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton> <DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{effectiveTitle}</DialogTitle>
<DialogDescription>{effectiveDescription}</DialogDescription> <DialogDescription>{effectiveDescription}</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -277,15 +295,15 @@ export function MediaLibraryDialog({
) : urlError ? ( ) : urlError ? (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"> <div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm font-medium">Mediathek konnte nicht geladen werden</p> <p className="text-sm font-medium">{tDialog("errorTitle")}</p>
<p className="max-w-md text-xs text-muted-foreground">{urlError}</p> <p className="max-w-md text-xs text-muted-foreground">{urlError}</p>
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"> <div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">Keine Medien vorhanden</p> <p className="text-sm font-medium">{tDialog("emptyTitle")}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier. {tDialog("emptyDescription")}
</p> </p>
</div> </div>
) : ( ) : (
@@ -293,8 +311,8 @@ export function MediaLibraryDialog({
{visibleItems.map((item) => { {visibleItems.map((item) => {
const itemKey = getItemKey(item); const itemKey = getItemKey(item);
const isPickingThis = pendingPickItemKey === itemKey; const isPickingThis = pendingPickItemKey === itemKey;
const itemLabel = getItemLabel(item); const itemLabel = getItemLabel(item, tCommon);
const metaLabel = formatMediaMeta(item); const metaLabel = formatMediaMeta(item, tCommon);
return ( return (
<div <div
@@ -340,7 +358,7 @@ export function MediaLibraryDialog({
</p> </p>
{isPreviewMode ? ( {isPreviewMode ? (
<p className="mt-auto text-[11px] text-muted-foreground">Nur Vorschau</p> <p className="mt-auto text-[11px] text-muted-foreground">{tDialog("previewOnly")}</p>
) : ( ) : (
<Button <Button
type="button" type="button"
@@ -352,10 +370,10 @@ export function MediaLibraryDialog({
{isPickingThis ? ( {isPickingThis ? (
<> <>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Wird uebernommen... {tDialog("pickLoading")}
</> </>
) : ( ) : (
pickCtaLabel effectivePickCtaLabel
)} )}
</Button> </Button>
)} )}
@@ -375,10 +393,10 @@ export function MediaLibraryDialog({
onClick={() => setPage((current) => Math.max(1, current - 1))} onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Previous {tDialog("previous")}
</Button> </Button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Page {metadata.page} of {metadata.totalPages} {tDialog("pageOf", { page: metadata.page, totalPages: metadata.totalPages })}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@@ -386,7 +404,7 @@ export function MediaLibraryDialog({
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))} onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
disabled={page >= metadata.totalPages} disabled={page >= metadata.totalPages}
> >
Next {tDialog("next")}
</Button> </Button>
</div> </div>
) : null} ) : null}

View File

@@ -58,6 +58,7 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
| `video-prompt` | `content`, `modelId`, `durationSeconds` | KI-Video-Steuer-Node (Eingabe) | | `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) | | `ai-video` | `storageId`, `prompt`, `model`, `modelLabel`, `durationSeconds`, `creditCost`, `generatedAt`, `taskId` (transient) | Generiertes KI-Video (System-Output) |
| `compare` | `leftNodeId`, `rightNodeId`, `sliderPosition` | Vergleichs-Node | | `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 | | `frame` | `label`, `exportWidth`, `exportHeight`, `backgroundColor` | Artboard |
| `group` | `label`, `collapsed` | Container-Node | | `group` | `label`, `collapsed` | Container-Node |
| `note` | `content`, `color` | Anmerkung | | `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 - Ziel: `ai-image`, `ai-video`, `compare` → Target-Ports
- `video-prompt``ai-video` ✅ (einzige gültige Kombination für Video-Flow) - `video-prompt``ai-video` ✅ (einzige gültige Kombination für Video-Flow)
- `ai-video` als Source für andere Nodes → ❌ (nur Compare) - `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 - 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`) ## Storage (`storage.ts`)

View File

@@ -11,7 +11,13 @@ import { api, internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { generateStructuredObjectViaOpenRouter } from "./openrouter"; import { generateStructuredObjectViaOpenRouter } from "./openrouter";
import { getNodeDataRecord } from "./ai_node_data"; import { getNodeDataRecord } from "./ai_node_data";
import { formatTerminalStatusMessage } from "./ai_errors"; import {
errorMessage,
formatTerminalStatusMessage,
getErrorCode,
getErrorSource,
getProviderStatus,
} from "./ai_errors";
import { import {
areClarificationAnswersComplete, areClarificationAnswersComplete,
buildPreflightClarificationQuestions, buildPreflightClarificationQuestions,
@@ -119,14 +125,17 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
}, },
}; };
const metadataValueSchema: Record<string, unknown> = { const metadataEntrySchema: Record<string, unknown> = {
anyOf: [ type: "object",
{ type: "string" }, additionalProperties: false,
{ required: ["key", "values"],
properties: {
key: { type: "string" },
values: {
type: "array", type: "array",
items: { type: "string" }, items: { type: "string" },
}, },
], },
}; };
const stepOutputProperties: Record<string, unknown> = {}; const stepOutputProperties: Record<string, unknown> = {};
@@ -140,7 +149,7 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
"artifactType", "artifactType",
"previewText", "previewText",
"sections", "sections",
"metadata", "metadataEntries",
"qualityChecks", "qualityChecks",
], ],
properties: { properties: {
@@ -152,9 +161,9 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
type: "array", type: "array",
items: sectionSchema, items: sectionSchema,
}, },
metadata: { metadataEntries: {
type: "object", type: "array",
additionalProperties: metadataValueSchema, items: metadataEntrySchema,
}, },
qualityChecks: { qualityChecks: {
type: "array", type: "array",
@@ -297,6 +306,7 @@ type InternalApiShape = {
previewText: string; previewText: string;
sections: AgentOutputSection[]; sections: AgentOutputSection[];
metadata: Record<string, string | string[]>; metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
body: string; body: string;
}, },
unknown unknown
@@ -351,6 +361,18 @@ function trimText(value: unknown): string {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
function logAgentFailure(stage: string, context: Record<string, unknown>, 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 { function normalizeAnswerMap(raw: unknown): AgentClarificationAnswerMap {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {}; return {};
@@ -519,6 +541,7 @@ function buildSkeletonOutputData(input: {
previewText: buildSkeletonPreviewPlaceholder(input.step.title), previewText: buildSkeletonPreviewPlaceholder(input.step.title),
sections: [], sections: [],
metadata: {}, metadata: {},
metadataLabels: {},
body: "", body: "",
...(definitionVersion ? { definitionVersion } : {}), ...(definitionVersion ? { definitionVersion } : {}),
}; };
@@ -535,6 +558,7 @@ function buildCompletedOutputData(input: {
previewText: string; previewText: string;
sections: AgentOutputSection[]; sections: AgentOutputSection[];
metadata: Record<string, string | string[]>; metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
qualityChecks: string[]; qualityChecks: string[];
body: string; body: string;
}; };
@@ -563,6 +587,10 @@ function buildCompletedOutputData(input: {
sections: normalizedSections, sections: normalizedSections,
metadata: metadata:
input.output.metadata && typeof input.output.metadata === "object" ? input.output.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({ body: deriveLegacyBodyFallback({
title: trimText(input.output.title) || trimText(input.step.title), title: trimText(input.output.title) || trimText(input.step.title),
previewText: normalizedPreviewText, previewText: normalizedPreviewText,
@@ -976,6 +1004,7 @@ export const completeExecutionStepOutput = internalMutation({
}), }),
), ),
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))), metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
metadataLabels: v.record(v.string(), v.string()),
body: v.string(), body: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -1018,6 +1047,7 @@ export const completeExecutionStepOutput = internalMutation({
previewText: args.previewText, previewText: args.previewText,
sections: args.sections, sections: args.sections,
metadata: args.metadata, metadata: args.metadata,
metadataLabels: args.metadataLabels,
qualityChecks: args.qualityChecks, qualityChecks: args.qualityChecks,
body: args.body, body: args.body,
}, },
@@ -1254,6 +1284,7 @@ export const analyzeAgent = internalAction({
shouldDecrementConcurrency: args.shouldDecrementConcurrency, shouldDecrementConcurrency: args.shouldDecrementConcurrency,
}); });
} catch (error) { } catch (error) {
logAgentFailure("analyzeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
await releaseInternalReservationBestEffort(ctx, args.reservationId); await releaseInternalReservationBestEffort(ctx, args.reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, { await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId, nodeId: args.nodeId,
@@ -1306,6 +1337,17 @@ export const executeAgent = internalAction({
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id)); 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<{ const execution = await generateStructuredObjectViaOpenRouter<{
summary: string; summary: string;
stepOutputs: Record<string, AgentStructuredOutputDraft>; stepOutputs: Record<string, AgentStructuredOutputDraft>;
@@ -1375,6 +1417,7 @@ export const executeAgent = internalAction({
previewText: normalized.previewText, previewText: normalized.previewText,
sections: normalized.sections, sections: normalized.sections,
metadata: normalized.metadata, metadata: normalized.metadata,
metadataLabels: normalized.metadataLabels,
body: normalized.body, body: normalized.body,
}); });
} }
@@ -1393,6 +1436,7 @@ export const executeAgent = internalAction({
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId); await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
} catch (error) { } catch (error) {
logAgentFailure("executeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
await releaseInternalReservationBestEffort(ctx, args.reservationId); await releaseInternalReservationBestEffort(ctx, args.reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, { await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId, nodeId: args.nodeId,
@@ -1404,6 +1448,7 @@ export const executeAgent = internalAction({
}); });
export const __testables = { export const __testables = {
buildExecuteSchema,
buildSkeletonOutputData, buildSkeletonOutputData,
buildCompletedOutputData, buildCompletedOutputData,
getAnalyzeExecutionStepRequiredFields, getAnalyzeExecutionStepRequiredFields,
@@ -1485,6 +1530,7 @@ export const runAgent = action({
scheduled = true; scheduled = true;
return { queued: true, nodeId: args.nodeId }; return { queued: true, nodeId: args.nodeId };
} catch (error) { } catch (error) {
logAgentFailure("runAgent", { nodeId: args.nodeId, modelId: selectedModel.id }, error);
await releasePublicReservationBestEffort(ctx, reservationId); await releasePublicReservationBestEffort(ctx, reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, { await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId, nodeId: args.nodeId,
@@ -1572,6 +1618,7 @@ export const resumeAgent = action({
return { queued: true, nodeId: args.nodeId }; return { queued: true, nodeId: args.nodeId };
} catch (error) { } catch (error) {
logAgentFailure("resumeAgent", { nodeId: args.nodeId, modelId }, error);
await releasePublicReservationBestEffort(ctx, reservationId ?? null); await releasePublicReservationBestEffort(ctx, reservationId ?? null);
await ctx.runMutation(internalApi.agents.setAgentError, { await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId, nodeId: args.nodeId,

View File

@@ -14,6 +14,47 @@ interface ErrorData {
[key: string]: unknown; [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<string, unknown>;
const errorBlock =
record.error && typeof record.error === "object" && !Array.isArray(record.error)
? (record.error as Record<string, unknown>)
: 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 { export function getErrorCode(error: unknown): string | undefined {
if (error instanceof ConvexError) { if (error instanceof ConvexError) {
const data = error.data as ErrorData; const data = error.data as ErrorData;
@@ -166,10 +207,31 @@ export function formatTerminalStatusMessage(error: unknown): string {
typeof convexData?.status === "number" && Number.isFinite(convexData.status) typeof convexData?.status === "number" && Number.isFinite(convexData.status)
? convexData.status ? convexData.status
: null; : 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 = const message =
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR" code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
? convexDataMessage || ? structuredProviderMessageFromData
? `${convexDataStatus !== null ? `OpenRouter ${convexDataStatus}: ` : ""}${structuredProviderMessageFromData}${structuredProviderSuffix}`
: convexDataMessage ||
(convexDataStatus !== null (convexDataStatus !== null
? `HTTP ${convexDataStatus}` ? `HTTP ${convexDataStatus}`
: "Anfrage fehlgeschlagen") : "Anfrage fehlgeschlagen")

View File

@@ -8,37 +8,41 @@ import {
} from "../lib/canvas-connection-policy"; } from "../lib/canvas-connection-policy";
const PERFORMANCE_LOG_THRESHOLD_MS = 250; 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, ctx: MutationCtx,
args: { args: {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<number> { ): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
const incomingEdgesQuery = ctx.db const incomingEdgesQuery = ctx.db
.query("edges") .query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
const checkStartedAt = Date.now(); const checkStartedAt = Date.now();
const incomingEdges = await ( const incomingEdges = await incomingEdgesQuery.take(3);
args.edgeIdToIgnore
? incomingEdgesQuery.take(2)
: incomingEdgesQuery.first()
);
const checkDurationMs = Date.now() - checkStartedAt; const checkDurationMs = Date.now() - checkStartedAt;
const incomingCount = Array.isArray(incomingEdges) const filteredIncomingEdges = incomingEdges.filter(
? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length (edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore,
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore );
? 1 const incomingCount = filteredIncomingEdges.length;
: 0;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges) const inspected = incomingEdges.length;
? incomingEdges.length
: incomingEdges === null
? 0
: 1;
console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", { console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", {
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
@@ -48,7 +52,10 @@ async function countIncomingEdges(
}); });
} }
return incomingCount; return {
count: incomingCount,
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
};
} }
async function assertConnectionPolicy( async function assertConnectionPolicy(
@@ -56,6 +63,7 @@ async function assertConnectionPolicy(
args: { args: {
sourceNodeId: Id<"nodes">; sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<void> { ): Promise<void> {
@@ -65,7 +73,7 @@ async function assertConnectionPolicy(
throw new Error("Source or target node not found"); throw new Error("Source or target node not found");
} }
const targetIncomingCount = await countIncomingEdges(ctx, { const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore, edgeIdToIgnore: args.edgeIdToIgnore,
}); });
@@ -73,7 +81,9 @@ async function assertConnectionPolicy(
const reason = validateCanvasConnectionPolicy({ const reason = validateCanvasConnectionPolicy({
sourceType: sourceNode.type, sourceType: sourceNode.type,
targetType: targetNode.type, targetType: targetNode.type,
targetIncomingCount, targetIncomingCount: targetIncoming.count,
targetHandle: args.targetHandle,
targetIncomingHandles: targetIncoming.targetHandles,
}); });
if (reason) { if (reason) {
@@ -83,7 +93,7 @@ async function assertConnectionPolicy(
edgeIdToIgnore: args.edgeIdToIgnore, edgeIdToIgnore: args.edgeIdToIgnore,
sourceType: sourceNode.type, sourceType: sourceNode.type,
targetType: targetNode.type, targetType: targetNode.type,
targetIncomingCount, targetIncomingCount: targetIncoming.count,
reason, reason,
}); });
throw new Error(getCanvasConnectionValidationMessage(reason)); throw new Error(getCanvasConnectionValidationMessage(reason));
@@ -151,6 +161,7 @@ export const create = mutation({
targetNodeId: v.id("nodes"), targetNodeId: v.id("nodes"),
sourceHandle: v.optional(v.string()), sourceHandle: v.optional(v.string()),
targetHandle: v.optional(v.string()), targetHandle: v.optional(v.string()),
edgeIdToIgnore: v.optional(v.id("edges")),
clientRequestId: v.optional(v.string()), clientRequestId: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -207,9 +218,23 @@ export const create = mutation({
throw new Error("Cannot connect a node to itself"); 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, { await assertConnectionPolicy(ctx, {
sourceNodeId: args.sourceNodeId, sourceNodeId: args.sourceNodeId,
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
targetHandle: args.targetHandle,
edgeIdToIgnore: args.edgeIdToIgnore,
}); });
const edgeId = await ctx.db.insert("edges", { const edgeId = await ctx.db.insert("edges", {
@@ -220,6 +245,10 @@ export const create = mutation({
targetHandle: args.targetHandle, targetHandle: args.targetHandle,
}); });
if (edgeToIgnore) {
await ctx.db.delete(edgeToIgnore._id);
}
console.info("[canvas.updatedAt] touch", { console.info("[canvas.updatedAt] touch", {
canvasId: args.canvasId, canvasId: args.canvasId,
source: "edges.create", 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. * Edge löschen.
*/ */

View File

@@ -418,34 +418,27 @@ function normalizeNodeDataForWrite(
return preserveNodeFavorite(data, data); return preserveNodeFavorite(data, data);
} }
async function countIncomingEdges( async function getIncomingEdgePolicyContext(
ctx: MutationCtx, ctx: MutationCtx,
args: { args: {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<number> { ): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
const incomingEdgesQuery = ctx.db const incomingEdgesQuery = ctx.db
.query("edges") .query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
const checkStartedAt = Date.now(); const checkStartedAt = Date.now();
const incomingEdges = await ( const incomingEdges = await incomingEdgesQuery.take(3);
args.edgeIdToIgnore ? incomingEdgesQuery.take(2) : incomingEdgesQuery.first()
);
const checkDurationMs = Date.now() - checkStartedAt; const checkDurationMs = Date.now() - checkStartedAt;
const incomingCount = Array.isArray(incomingEdges) const filteredIncomingEdges = incomingEdges.filter(
? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length (edge) => edge._id !== args.edgeIdToIgnore,
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore );
? 1 const incomingCount = filteredIncomingEdges.length;
: 0;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges) const inspected = incomingEdges.length;
? incomingEdges.length
: incomingEdges === null
? 0
: 1;
console.warn("[nodes.countIncomingEdges] slow incoming edge check", { console.warn("[nodes.countIncomingEdges] slow incoming edge check", {
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
@@ -455,7 +448,10 @@ async function countIncomingEdges(
}); });
} }
return incomingCount; return {
count: incomingCount,
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
};
} }
async function assertConnectionPolicyForTypes( async function assertConnectionPolicyForTypes(
@@ -464,16 +460,21 @@ async function assertConnectionPolicyForTypes(
sourceType: Doc<"nodes">["type"]; sourceType: Doc<"nodes">["type"];
targetType: Doc<"nodes">["type"]; targetType: Doc<"nodes">["type"];
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<void> { ): Promise<void> {
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
});
const reason = validateCanvasConnectionPolicy({ const reason = validateCanvasConnectionPolicy({
sourceType: args.sourceType, sourceType: args.sourceType,
targetType: args.targetType, targetType: args.targetType,
targetIncomingCount: await countIncomingEdges(ctx, { targetIncomingCount: targetIncoming.count,
targetNodeId: args.targetNodeId, targetHandle: args.targetHandle,
edgeIdToIgnore: args.edgeIdToIgnore, targetIncomingHandles: targetIncoming.targetHandles,
}),
}); });
if (reason) { if (reason) {
@@ -870,6 +871,8 @@ export const createWithEdgeSplit = mutation({
sourceType: sourceNode.type, sourceType: sourceNode.type,
targetType: args.type, targetType: args.type,
targetIncomingCount: 0, targetIncomingCount: 0,
targetHandle: args.newNodeTargetHandle,
targetIncomingHandles: [],
}); });
if (firstEdgeReason) { if (firstEdgeReason) {
throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason)); throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason));
@@ -879,6 +882,7 @@ export const createWithEdgeSplit = mutation({
sourceType: args.type, sourceType: args.type,
targetType: targetNode.type, targetType: targetNode.type,
targetNodeId: edge.targetNodeId, targetNodeId: edge.targetNodeId,
targetHandle: args.splitTargetHandle,
edgeIdToIgnore: args.splitEdgeId, edgeIdToIgnore: args.splitEdgeId,
}); });
@@ -1008,6 +1012,7 @@ export const splitEdgeAtExistingNode = mutation({
sourceType: sourceNode.type, sourceType: sourceNode.type,
targetType: middle.type, targetType: middle.type,
targetNodeId: args.middleNodeId, targetNodeId: args.middleNodeId,
targetHandle: args.newNodeTargetHandle,
}); });
await ctx.db.insert("edges", { await ctx.db.insert("edges", {
@@ -1022,6 +1027,7 @@ export const splitEdgeAtExistingNode = mutation({
sourceType: middle.type, sourceType: middle.type,
targetType: targetNode.type, targetType: targetNode.type,
targetNodeId: edge.targetNodeId, targetNodeId: edge.targetNodeId,
targetHandle: args.splitTargetHandle,
edgeIdToIgnore: args.splitEdgeId, edgeIdToIgnore: args.splitEdgeId,
}); });
@@ -1098,6 +1104,8 @@ export const createWithEdgeFromSource = mutation({
sourceType: source.type, sourceType: source.type,
targetType: args.type, targetType: args.type,
targetIncomingCount: 0, targetIncomingCount: 0,
targetHandle: args.targetHandle,
targetIncomingHandles: [],
}); });
if (fromSourceReason) { if (fromSourceReason) {
throw new Error(getCanvasConnectionValidationMessage(fromSourceReason)); throw new Error(getCanvasConnectionValidationMessage(fromSourceReason));
@@ -1188,6 +1196,7 @@ export const createWithEdgeToTarget = mutation({
sourceType: args.type, sourceType: args.type,
targetType: target.type, targetType: target.type,
targetNodeId, targetNodeId,
targetHandle: args.targetHandle,
}); });
const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const normalizedData = normalizeNodeDataForWrite(args.type, args.data);

View File

@@ -151,6 +151,161 @@ function parseStructuredJsonFromMessageContent(contentText: string):
return { ok: false }; 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<string, unknown>) => 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<string, unknown>;
visitor(record);
for (const nested of Object.values(record)) {
walkStructuredSchema(nested, visitor);
}
}
function getStructuredSchemaDiagnostics(args: {
schema: Record<string, unknown>;
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<string, unknown>)
: 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<string, unknown>;
const errorBlock =
record.error && typeof record.error === "object" && !Array.isArray(record.error)
? (record.error as Record<string, unknown>)
: 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<T>( export async function generateStructuredObjectViaOpenRouter<T>(
apiKey: string, apiKey: string,
args: { args: {
@@ -163,6 +318,17 @@ export async function generateStructuredObjectViaOpenRouter<T>(
schema: Record<string, unknown>; schema: Record<string, unknown>;
}, },
): Promise<T> { ): Promise<T> {
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`, { const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -188,10 +354,25 @@ export async function generateStructuredObjectViaOpenRouter<T>(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); 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({ throw new ConvexError({
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR", code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
status: response.status, 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<T>(
return parsedContent.value as T; return parsedContent.value as T;
} }
export const __testables = {
getStructuredSchemaDiagnostics,
summarizeStructuredOpenRouterError,
};
export interface OpenRouterModel { export interface OpenRouterModel {
id: string; id: string;
name: string; name: string;

View File

@@ -107,7 +107,8 @@ isNodePaletteEnabled // true wenn: implementiert + kein systemOutput + Template
**Kategorien:** **Kategorien:**
- `source` — Quelle (image, text, video, asset, color) - `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) - `transform` — Transformation (crop, bg-remove, upscale)
- `image-edit` — Bildbearbeitung (adjustments) - `image-edit` — Bildbearbeitung (adjustments)
- `control` — Steuerung & Flow - `control` — Steuerung & Flow

View File

@@ -215,6 +215,24 @@ function formatExecutionRequirements(plan: AgentExecutionPlan): string {
.join("\n"); .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: { export function buildExecuteMessages(input: {
definition: AgentDefinition; definition: AgentDefinition;
locale: AgentLocale; locale: AgentLocale;
@@ -235,6 +253,7 @@ export function buildExecuteMessages(input: {
"Use the following compiled prompt segments:", "Use the following compiled prompt segments:",
formatPromptSegments(segments), formatPromptSegments(segments),
`execution rules:\n- ${input.definition.executionRules.join("\n- ")}`, `execution rules:\n- ${input.definition.executionRules.join("\n- ")}`,
formatDeliverableFirstInstructions(input.definition),
"Return one output payload per execution step keyed by step id.", "Return one output payload per execution step keyed by step id.",
].join("\n\n"), ].join("\n\n"),
}, },

View File

@@ -26,16 +26,23 @@ export type AgentStructuredOutput = {
previewText: string; previewText: string;
sections: AgentOutputSection[]; sections: AgentOutputSection[];
metadata: Record<string, string | string[]>; metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
qualityChecks: string[]; qualityChecks: string[];
body: string; body: string;
}; };
export type AgentStructuredMetadataEntry = {
key: string;
values: string[];
};
export type AgentStructuredOutputDraft = Partial< export type AgentStructuredOutputDraft = Partial<
AgentStructuredOutput & { Omit<AgentStructuredOutput, "sections" | "metadata">
sections: Array<Partial<AgentOutputSection> | null>; > & {
metadata: Record<string, unknown>; sections?: unknown[];
} metadata?: Record<string, unknown>;
>; metadataEntries?: unknown[];
};
export type AgentExecutionStep = { export type AgentExecutionStep = {
id: string; id: string;
@@ -178,6 +185,93 @@ function normalizeStructuredMetadata(raw: unknown): Record<string, string | stri
return metadata; return metadata;
} }
function normalizeStructuredMetadataEntries(raw: unknown): Record<string, string | string[]> {
if (!Array.isArray(raw)) {
return {};
}
const metadata: Record<string, string | string[]> = {};
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const record = item as Record<string, unknown>;
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<string, string | string[]>): {
metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
} {
const metadata: Record<string, string | string[]> = {};
const metadataLabels: Record<string, string> = {};
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 { function derivePreviewTextFromSections(sections: AgentOutputSection[]): string {
return sections[0]?.content ?? ""; return sections[0]?.content ?? "";
} }
@@ -365,7 +459,12 @@ export function normalizeAgentStructuredOutput(
trimString(draft.artifactType) || trimString(fallback.artifactType) || SAFE_FALLBACK_OUTPUT_TYPE; trimString(draft.artifactType) || trimString(fallback.artifactType) || SAFE_FALLBACK_OUTPUT_TYPE;
const sections = normalizeOutputSections(draft.sections); const sections = normalizeOutputSections(draft.sections);
const previewText = trimString(draft.previewText) || derivePreviewTextFromSections(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 qualityChecks = normalizeStringArray(draft.qualityChecks);
const body = const body =
trimString(draft.body) || trimString(draft.body) ||
@@ -382,6 +481,7 @@ export function normalizeAgentStructuredOutput(
previewText, previewText,
sections, sections,
metadata, metadata,
metadataLabels,
qualityChecks, qualityChecks,
body, body,
}; };

View File

@@ -50,6 +50,23 @@ const AGENT_ALLOWED_SOURCE_TYPES = new Set<string>([
"ai-video", "ai-video",
]); ]);
const MIXER_ALLOWED_SOURCE_TYPES = new Set<string>([
"image",
"asset",
"ai-image",
"render",
]);
const MIXER_TARGET_HANDLES = new Set<string>(["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<string>(["prompt", "ai-image"]); const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set<string>(["prompt", "ai-image"]);
export type CanvasConnectionValidationReason = export type CanvasConnectionValidationReason =
@@ -66,14 +83,52 @@ export type CanvasConnectionValidationReason =
| "adjustment-target-forbidden" | "adjustment-target-forbidden"
| "render-source-invalid" | "render-source-invalid"
| "agent-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: { export function validateCanvasConnectionPolicy(args: {
sourceType: string; sourceType: string;
targetType: string; targetType: string;
targetIncomingCount: number; targetIncomingCount: number;
targetHandle?: string | null;
targetIncomingHandles?: Array<string | null | undefined>;
}): CanvasConnectionValidationReason | null { }): 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") { if (targetType === "agent-output" && sourceType !== "agent") {
return "agent-output-source-invalid"; 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."; return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
case "agent-output-source-invalid": case "agent-output-source-invalid":
return "Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes."; 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: default:
return "Verbindung ist fuer diese Node-Typen nicht erlaubt."; return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
} }

216
lib/canvas-mixer-preview.ts Normal file
View File

@@ -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<MixerBlendMode>([
"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<string, unknown>;
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<string, unknown>;
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),
});
}

View File

@@ -9,6 +9,7 @@ import type { CanvasNodeType } from "@/lib/canvas-node-types";
export type NodeCategoryId = export type NodeCategoryId =
| "source" | "source"
| "ai-output" | "ai-output"
| "agents"
| "transform" | "transform"
| "image-edit" | "image-edit"
| "control" | "control"
@@ -20,10 +21,11 @@ export const NODE_CATEGORY_META: Record<
> = { > = {
source: { label: "Quelle", order: 0 }, source: { label: "Quelle", order: 0 },
"ai-output": { label: "KI-Ausgabe", order: 1 }, "ai-output": { label: "KI-Ausgabe", order: 1 },
transform: { label: "Transformation", order: 2 }, agents: { label: "Agents", order: 2 },
"image-edit": { label: "Bildbearbeitung", order: 3 }, transform: { label: "Transformation", order: 3 },
control: { label: "Steuerung & Flow", order: 4 }, "image-edit": { label: "Bildbearbeitung", order: 4 },
layout: { label: "Canvas & Layout", order: 5 }, control: { label: "Steuerung & Flow", order: 5 },
layout: { label: "Canvas & Layout", order: 6 },
}; };
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = ( export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
@@ -85,6 +87,14 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
category: "source", category: "source",
phase: 2, phase: 2,
}), }),
entry({
type: "ai-video",
label: "KI-Video-Ausgabe",
category: "source",
phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
entry({ entry({
type: "asset", type: "asset",
label: "Asset (Stock)", label: "Asset (Stock)",
@@ -112,18 +122,17 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
systemOutput: true, systemOutput: true,
disabledHint: "Wird von der KI erzeugt", disabledHint: "Wird von der KI erzeugt",
}), }),
// Agents
entry({ entry({
type: "ai-video", type: "agent",
label: "KI-Video-Ausgabe", label: "Campaign Orchestrator",
category: "ai-output", category: "agents",
phase: 2, phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}), }),
entry({ entry({
type: "agent-output", type: "agent-output",
label: "Agent-Ausgabe", label: "Agent-Ausgabe",
category: "ai-output", category: "agents",
phase: 2, phase: 2,
implemented: true, implemented: true,
systemOutput: true, systemOutput: true,
@@ -216,19 +225,11 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
implemented: false, implemented: false,
disabledHint: "Folgt in Phase 2", disabledHint: "Folgt in Phase 2",
}), }),
entry({
type: "agent",
label: "Agent",
category: "control",
phase: 2,
}),
entry({ entry({
type: "mixer", type: "mixer",
label: "Mixer / Merge", label: "Mixer / Merge",
category: "control", category: "control",
phase: 3, phase: 1,
implemented: false,
disabledHint: "Folgt in Phase 3",
}), }),
entry({ entry({
type: "switch", type: "switch",

View File

@@ -43,6 +43,18 @@ export const CANVAS_NODE_TEMPLATES = [
templateId: "campaign-distributor", templateId: "campaign-distributor",
}, },
}, },
{
type: "mixer",
label: "Mixer / Merge",
width: 360,
height: 320,
defaultData: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
},
{ {
type: "note", type: "note",
label: "Notiz", label: "Notiz",

View File

@@ -72,6 +72,7 @@ export type CanvasSyncOpPayloadByType = {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
clientRequestId: string; clientRequestId: string;
}; };
removeEdge: { removeEdge: {
@@ -477,6 +478,10 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
typeof payload.targetHandle === "string" typeof payload.targetHandle === "string"
? payload.targetHandle ? payload.targetHandle
: undefined, : undefined,
edgeIdToIgnore:
typeof payload.edgeIdToIgnore === "string"
? (payload.edgeIdToIgnore as Id<"edges">)
: undefined,
clientRequestId: payload.clientRequestId, clientRequestId: payload.clientRequestId,
}, },
enqueuedAt, enqueuedAt,

View File

@@ -231,6 +231,7 @@ export const NODE_HANDLE_MAP: Record<
crop: { source: undefined, target: undefined }, crop: { source: undefined, target: undefined },
render: { source: undefined, target: undefined }, render: { source: undefined, target: undefined },
agent: { target: "agent-in" }, agent: { target: "agent-in" },
mixer: { source: "mixer-out", target: "base" },
"agent-output": { target: "agent-output-in" }, "agent-output": { target: "agent-output-in" },
}; };
@@ -292,6 +293,16 @@ export const NODE_DEFAULTS: Record<
outputNodeIds: [], outputNodeIds: [],
}, },
}, },
mixer: {
width: 360,
height: 320,
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
},
"agent-output": { "agent-output": {
width: 360, width: 360,
height: 260, height: 260,

View File

@@ -223,6 +223,7 @@
"sectionsLabel": "Abschnitte", "sectionsLabel": "Abschnitte",
"metadataLabel": "Metadaten", "metadataLabel": "Metadaten",
"qualityChecksLabel": "Qualitaetschecks", "qualityChecksLabel": "Qualitaetschecks",
"detailsLabel": "Details",
"previewLabel": "Vorschau", "previewLabel": "Vorschau",
"previewFallback": "Keine Vorschau verfuegbar", "previewFallback": "Keine Vorschau verfuegbar",
"emptyValue": "-", "emptyValue": "-",
@@ -275,6 +276,46 @@
"openrouterDataUriCreationFailed": "OpenRouter: Bild konnte nicht verarbeitet werden.", "openrouterDataUriCreationFailed": "OpenRouter: Bild konnte nicht verarbeitet werden.",
"openrouterDataUriMissingBase64": "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": { "toasts": {
"canvas": { "canvas": {
"imageUploaded": "Bild hochgeladen", "imageUploaded": "Bild hochgeladen",

View File

@@ -223,6 +223,7 @@
"sectionsLabel": "Sections", "sectionsLabel": "Sections",
"metadataLabel": "Metadata", "metadataLabel": "Metadata",
"qualityChecksLabel": "Quality checks", "qualityChecksLabel": "Quality checks",
"detailsLabel": "Details",
"previewLabel": "Preview", "previewLabel": "Preview",
"previewFallback": "No preview available", "previewFallback": "No preview available",
"emptyValue": "-", "emptyValue": "-",
@@ -275,6 +276,46 @@
"openrouterDataUriCreationFailed": "OpenRouter: Could not process image.", "openrouterDataUriCreationFailed": "OpenRouter: Could not process image.",
"openrouterDataUriMissingBase64": "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": { "toasts": {
"canvas": { "canvas": {
"imageUploaded": "Image uploaded", "imageUploaded": "Image uploaded",

View File

@@ -32,6 +32,7 @@ const translations: Record<string, string> = {
"agentOutputNode.sectionsLabel": "Sections", "agentOutputNode.sectionsLabel": "Sections",
"agentOutputNode.metadataLabel": "Metadata", "agentOutputNode.metadataLabel": "Metadata",
"agentOutputNode.qualityChecksLabel": "Quality checks", "agentOutputNode.qualityChecksLabel": "Quality checks",
"agentOutputNode.detailsLabel": "Details",
"agentOutputNode.previewLabel": "Preview", "agentOutputNode.previewLabel": "Preview",
"agentOutputNode.previewFallback": "No preview available", "agentOutputNode.previewFallback": "No preview available",
"agentOutputNode.emptyValue": "-", "agentOutputNode.emptyValue": "-",
@@ -76,7 +77,7 @@ describe("AgentOutputNode", () => {
root = null; 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"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(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-metadata"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull(); expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-preview"]')).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<string, unknown>,
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 () => { it("renders parseable json body in a pretty-printed code block", async () => {

View File

@@ -223,4 +223,16 @@ describe("canvas connection policy", () => {
getCanvasConnectionValidationMessage("agent-output-source-invalid"), getCanvasConnectionValidationMessage("agent-output-source-invalid"),
).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes."); ).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");
});
}); });

View File

@@ -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();
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { __testables } from "@/convex/agents"; import { __testables } from "@/convex/agents";
import { __testables as openrouterTestables } from "@/convex/openrouter";
describe("agent orchestration contract helpers", () => { describe("agent orchestration contract helpers", () => {
it("builds skeleton output data with rich execution-plan metadata", () => { 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: "hook", label: "Hook", content: "Lead with proof." },
{ id: "cta", label: "CTA", content: "Invite comments." }, { id: "cta", label: "CTA", content: "Invite comments." },
], ],
metadata: { audience: "SaaS founders" }, metadata: { tonalitaet: "freundlich", audience: "SaaS founders" },
metadataLabels: { tonalitaet: "tonalität", audience: "audience" },
qualityChecks: [], qualityChecks: [],
body: "", body: "",
}, },
@@ -69,6 +71,7 @@ describe("agent orchestration contract helpers", () => {
expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments."); expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments.");
expect(data.previewText).toBe("Lead with proof."); expect(data.previewText).toBe("Lead with proof.");
expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]); 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", () => { 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<string, unknown>).stepOutputs as Record<string, unknown>)
.properties as Record<string, unknown>)["step-1"] as Record<string, unknown>;
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", () => { it("resolves persisted summaries consistently across analyze and execute", () => {
const promptSummary = __testables.resolveExecutionPlanSummary({ const promptSummary = __testables.resolveExecutionPlanSummary({
executionPlanSummary: "", executionPlanSummary: "",

View File

@@ -60,6 +60,19 @@ describe("ai error helpers", () => {
).toBe("Provider: OpenRouter API error 503: Upstream timeout"); ).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", () => { it("formats structured-output http error without falling back to raw code", () => {
expect( expect(
formatTerminalStatusMessage( formatTerminalStatusMessage(

View File

@@ -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<string, unknown>) => {
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<string, unknown>) => {
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<Id<"edges">>;
})._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<void>;
})._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<void>;
})._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<void>;
})._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<void>;
})._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");
});
});

View File

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter"; import {
__testables,
generateStructuredObjectViaOpenRouter,
} from "@/convex/openrouter";
type MockResponseInit = { type MockResponseInit = {
ok: boolean; 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,
});
});
}); });

View File

@@ -136,6 +136,10 @@ describe("agent prompting helpers", () => {
const user = messages[1]?.content ?? ""; const user = messages[1]?.content ?? "";
expect(system).toContain("execution rules"); 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("channel-notes");
expect(system).toContain("German (de-DE)"); expect(system).toContain("German (de-DE)");
expect(user).toContain("Execution plan summary: Ship launch content"); expect(user).toContain("Execution plan summary: Ship launch content");

View File

@@ -47,6 +47,10 @@ describe("normalizeAgentStructuredOutput", () => {
language: "en", language: "en",
tags: ["launch", "saas"], tags: ["launch", "saas"],
}, },
metadataLabels: {
language: "language",
tags: "tags",
},
qualityChecks: ["concise", "channel-fit"], qualityChecks: ["concise", "channel-fit"],
body: "Legacy flat content", body: "Legacy flat content",
}); });
@@ -145,4 +149,38 @@ describe("normalizeAgentStructuredOutput", () => {
"Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.", "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",
});
});
}); });

View File

@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
import { nodeTypes } from "@/components/canvas/node-types"; import { nodeTypes } from "@/components/canvas/node-types";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; 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"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
describe("canvas agent config", () => { describe("canvas agent config", () => {
@@ -22,6 +27,27 @@ describe("canvas agent config", () => {
expect(entry && isNodePaletteEnabled(entry)).toBe(true); 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", () => { it("keeps the agent input-only in MVP", () => {
expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in"); expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in");
expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined(); expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined();

View File

@@ -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",
});
});
});

View File

@@ -17,6 +17,7 @@ export default defineConfig({
"components/canvas/__tests__/canvas-connection-drop-target.test.tsx", "components/canvas/__tests__/canvas-connection-drop-target.test.tsx",
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
"components/canvas/__tests__/compare-node.test.tsx", "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-flow-reconciliation.test.ts",
"components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-drop.test.tsx",
"components/canvas/__tests__/use-canvas-connections.test.tsx", "components/canvas/__tests__/use-canvas-connections.test.tsx",