feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
@@ -60,12 +60,16 @@ function getInitials(nameOrEmail: string) {
|
||||
return normalized.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDimensions(width: number | undefined, height: number | undefined): string {
|
||||
function formatDimensions(
|
||||
width: number | undefined,
|
||||
height: number | undefined,
|
||||
unknownSizeLabel: string,
|
||||
): string {
|
||||
if (typeof width === "number" && typeof height === "number") {
|
||||
return `${width} x ${height}px`;
|
||||
}
|
||||
|
||||
return "Größe unbekannt";
|
||||
return unknownSizeLabel;
|
||||
}
|
||||
|
||||
function getMediaItemKey(item: NonNullable<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"}`;
|
||||
}
|
||||
|
||||
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") {
|
||||
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) {
|
||||
return item.filename;
|
||||
}
|
||||
|
||||
if (item.kind === "video") {
|
||||
return "Unbenanntes Video";
|
||||
return labels.untitledVideo;
|
||||
}
|
||||
|
||||
if (item.kind === "asset") {
|
||||
return "Unbenanntes Asset";
|
||||
return labels.untitledAsset;
|
||||
}
|
||||
|
||||
return "Unbenanntes Bild";
|
||||
return labels.untitledImage;
|
||||
}
|
||||
|
||||
export function DashboardPageClient() {
|
||||
const t = useTranslations("toasts");
|
||||
const tMediaCommon = useTranslations("mediaLibrary.common");
|
||||
const tMediaDashboard = useTranslations("mediaLibrary.dashboard");
|
||||
const tMediaDialog = useTranslations("mediaLibrary.dialog");
|
||||
const router = useRouter();
|
||||
const welcomeToastSentRef = useRef(false);
|
||||
const { theme = "system", setTheme } = useTheme();
|
||||
@@ -183,7 +203,7 @@ export function DashboardPageClient() {
|
||||
}
|
||||
setMediaPreviewUrlMap({});
|
||||
setMediaPreviewError(
|
||||
error instanceof Error ? error.message : "Vorschau konnte nicht geladen werden.",
|
||||
error instanceof Error ? error.message : tMediaDialog("urlResolveError"),
|
||||
);
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
@@ -197,7 +217,7 @@ export function DashboardPageClient() {
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls]);
|
||||
}, [dashboardSnapshot, mediaPreviewStorageIds, resolveMediaPreviewUrls, tMediaDialog]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
toast.info(t("auth.signedOut"));
|
||||
@@ -373,7 +393,7 @@ export function DashboardPageClient() {
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ImageIcon className="size-3.5 text-muted-foreground" />
|
||||
Mediathek
|
||||
{tMediaDashboard("sectionTitle")}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -383,30 +403,36 @@ export function DashboardPageClient() {
|
||||
onClick={() => setIsMediaLibraryDialogOpen(true)}
|
||||
disabled={!hasClientMounted || isSessionPending || !session?.user}
|
||||
>
|
||||
Ganze Mediathek öffnen
|
||||
{tMediaDashboard("openAll")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dashboardSnapshot === undefined ? (
|
||||
<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>
|
||||
) : mediaPreviewError ? (
|
||||
<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>
|
||||
) : !mediaPreview || mediaPreview.length === 0 ? (
|
||||
<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
|
||||
sie hier angezeigt.
|
||||
{tMediaDashboard("empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{(mediaPreview ?? []).map((item) => {
|
||||
const itemKey = getMediaItemKey(item);
|
||||
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
|
||||
const itemLabel = getMediaItemLabel(item);
|
||||
const itemMeta = getMediaItemMeta(item);
|
||||
const itemLabel = getMediaItemLabel(item, {
|
||||
untitledImage: tMediaCommon("untitledImage"),
|
||||
untitledVideo: tMediaCommon("untitledVideo"),
|
||||
untitledAsset: tMediaCommon("untitledAsset"),
|
||||
});
|
||||
const itemMeta = getMediaItemMeta(item, {
|
||||
unknownSize: tMediaCommon("unknownSize"),
|
||||
videoFile: tMediaCommon("videoFile"),
|
||||
});
|
||||
|
||||
return (
|
||||
<article key={itemKey} className="overflow-hidden rounded-xl border bg-card">
|
||||
@@ -460,8 +486,8 @@ export function DashboardPageClient() {
|
||||
<MediaLibraryDialog
|
||||
open={isMediaLibraryDialogOpen}
|
||||
onOpenChange={setIsMediaLibraryDialogOpen}
|
||||
title="Mediathek"
|
||||
description="Alle deine Medien aus LemonSpace in einer zentralen Vorschau."
|
||||
title={tMediaDialog("title")}
|
||||
description={tMediaDashboard("dialogDescription")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
133
components/agents/growth-hacker.md
Normal file
133
components/agents/growth-hacker.md
Normal 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 2–10 person design and marketing teams. Monetisation runs on a credit system (Free 50 Cr → Starter €8 → Pro €59 → Max €119). The North Star Metric is weekly active workspaces with at least one exported canvas output. Key growth levers are: free-tier credit exhaustion as natural upgrade trigger, canvas outputs users want to share organically, and self-hosting community effects (GitHub, Hacker News, r/selfhosted). Never recommend forced virality, aggressive upsell modals, or growth that destroys COGS margin.
|
||||
<!-- 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."
|
||||
@@ -49,11 +49,12 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
|
||||
| Kategorie | Nodes | Beschreibung |
|
||||
|-----------|-------|-------------|
|
||||
| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color` | Input-Quellen für den Workflow |
|
||||
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte |
|
||||
| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color`, `ai-video` | Input-Quellen für den Workflow |
|
||||
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text` | KI-generierte Inhalte |
|
||||
| **agents** (Agents) | `agent`, `agent-output` | Agent-Orchestrierung und Agent-Outputs |
|
||||
| **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen |
|
||||
| **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments |
|
||||
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `agent` | Kontrollfluss-Elemente |
|
||||
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `mixer` | Kontrollfluss-Elemente |
|
||||
| **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente |
|
||||
|
||||
### Node-Typen im Detail
|
||||
@@ -67,9 +68,9 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
| `prompt` | 1 | ✅ | ai-output | source: `prompt-out`, target: `image-in` |
|
||||
| `video-prompt` | 2 | ✅ | ai-output | source: `video-prompt-out`, target: `video-prompt-in` |
|
||||
| `ai-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` |
|
||||
| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` |
|
||||
| `agent` | 2 | ✅ | control | target: `agent-in`, source (default) |
|
||||
| `agent-output` | 2 | ✅ (systemOutput) | ai-output | target: `agent-output-in` |
|
||||
| `ai-video` | 2 | ✅ (systemOutput) | source | source: `video-out`, target: `video-in` |
|
||||
| `agent` | 2 | ✅ | agents | target: `agent-in`, source (default) |
|
||||
| `agent-output` | 2 | ✅ (systemOutput) | agents | target: `agent-output-in` |
|
||||
| `crop` | 2 | 🔲 | transform | 🔲 |
|
||||
| `bg-remove` | 2 | 🔲 | transform | 🔲 |
|
||||
| `upscale` | 2 | 🔲 | transform | 🔲 |
|
||||
@@ -81,6 +82,7 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
| `frame` | 1 | ✅ | layout | source: `frame-out`, target: `frame-in` |
|
||||
| `note` | 1 | ✅ | layout | source (default), target (default) |
|
||||
| `compare` | 1 | ✅ | layout | source: `compare-out`, targets: `left`, `right` |
|
||||
| `mixer` | 1 | ✅ | control | source: `mixer-out`, targets: `base`, `overlay` |
|
||||
|
||||
> `implemented: false` (🔲) bedeutet Phase-2/3 Node, der noch nicht implementiert ist. **Hinweis:** Phase-2/3 Nodes müssen im Schema (`convex/node_type_validator.ts`) vordeklariert werden, damit das System nicht bei jeder Phasenübergang neu migriert werden muss. Die UI filtert Nodes nach Phase.
|
||||
|
||||
@@ -119,10 +121,30 @@ video-prompt: 288 × 220 ai-video: 360 × 280
|
||||
agent: 360 × 320
|
||||
group: 400 × 300 frame: 400 × 300
|
||||
note: 208 × 100 compare: 500 × 380
|
||||
render: 300 × 420 mixer: 360 × 320
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mixer V1 (Merge Node)
|
||||
|
||||
`mixer` ist in V1 ein bewusst enger 2-Layer-Blend-Node.
|
||||
|
||||
- **Handles:** genau zwei Inputs links (`base`, `overlay`) und ein Output rechts (`mixer-out`).
|
||||
- **Erlaubte Inputs:** `image`, `asset`, `ai-image`, `render`.
|
||||
- **Connection-Limits:** maximal 2 eingehende Kanten insgesamt, davon pro Handle genau 1.
|
||||
- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `offsetX`, `offsetY`.
|
||||
- **Output-Semantik:** pseudo-image (clientseitig aus Graph + Controls aufgeloest), kein persistiertes Asset, kein Storage-Write.
|
||||
- **UI/Interaction:** nur Inline-Formcontrols im Node; keine Drag-Manipulation im Preview, keine Rotation/Skalierung/Masks.
|
||||
|
||||
### Compare-Integration (V1)
|
||||
|
||||
- `compare` versteht `mixer`-Outputs ueber `lib/canvas-mixer-preview.ts`.
|
||||
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Offset).
|
||||
- Scope bleibt eng: keine pauschale pseudo-image-Unterstuetzung fuer alle Consumer in V1.
|
||||
|
||||
---
|
||||
|
||||
## Node-Status-Modell
|
||||
|
||||
```
|
||||
@@ -291,6 +313,8 @@ useCanvasData (use-canvas-data.ts)
|
||||
- **Optimistic IDs:** Temporäre Nodes/Edges erhalten IDs mit `optimistic_` / `optimistic_edge_`-Prefix, werden durch echte Convex-IDs ersetzt, sobald die Mutation abgeschlossen ist.
|
||||
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
|
||||
- **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt.
|
||||
- **Mixer-Connection-Policy:** `mixer` akzeptiert nur `image|asset|ai-image|render`; Ziel-Handles sind nur `base` und `overlay`, pro Handle maximal eine eingehende Kante, insgesamt maximal zwei.
|
||||
- **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Downstream-Nodes muessen den pseudo-image-Resolver nutzen (aktuell gezielt fuer `compare`).
|
||||
- **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen.
|
||||
- **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren.
|
||||
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import {
|
||||
computeEdgeInsertLayout,
|
||||
computeEdgeInsertReflowPlan,
|
||||
getSingleCharacterHotkey,
|
||||
withResolvedCompareData,
|
||||
} from "../canvas-helpers";
|
||||
import {
|
||||
@@ -315,6 +316,24 @@ describe("canvas preview graph helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSingleCharacterHotkey", () => {
|
||||
it("returns a lowercase printable hotkey for single-character keys", () => {
|
||||
expect(getSingleCharacterHotkey({ key: "K", type: "keydown" })).toBe("k");
|
||||
expect(getSingleCharacterHotkey({ key: "v", type: "keydown" })).toBe("v");
|
||||
expect(getSingleCharacterHotkey({ key: "Escape", type: "keydown" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string and logs when the event has no string key", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
expect(getSingleCharacterHotkey({ type: "keydown" } as { key?: string; type: string })).toBe("");
|
||||
expect(warnSpy).toHaveBeenCalledWith("[Canvas] keyboard event missing string key", {
|
||||
eventType: "keydown",
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEdgeInsertLayout", () => {
|
||||
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
|
||||
const source = createNode({
|
||||
|
||||
@@ -166,4 +166,99 @@ describe("CompareNode render preview inputs", () => {
|
||||
preferPreview: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => {
|
||||
storeState.nodes = [
|
||||
{
|
||||
id: "base-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-image",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
blendMode: "multiply",
|
||||
opacity: 62,
|
||||
offsetX: 12,
|
||||
offsetY: -4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "right-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/right.png" },
|
||||
},
|
||||
];
|
||||
storeState.edges = [
|
||||
{
|
||||
id: "edge-base-mixer",
|
||||
source: "base-image",
|
||||
target: "mixer-1",
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
id: "edge-overlay-mixer",
|
||||
source: "overlay-image",
|
||||
target: "mixer-1",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
{
|
||||
id: "edge-mixer-compare",
|
||||
source: "mixer-1",
|
||||
target: "compare-1",
|
||||
targetHandle: "left",
|
||||
},
|
||||
{
|
||||
id: "edge-image-compare",
|
||||
source: "right-image",
|
||||
target: "compare-1",
|
||||
targetHandle: "right",
|
||||
},
|
||||
];
|
||||
|
||||
renderCompareNode({
|
||||
id: "compare-1",
|
||||
data: {
|
||||
leftUrl: "https://cdn.example.com/base.png",
|
||||
rightUrl: "https://cdn.example.com/right.png",
|
||||
},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "compare",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 500,
|
||||
height: 380,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
});
|
||||
|
||||
expect(compareSurfaceSpy).toHaveBeenCalledTimes(2);
|
||||
const mixerCall = compareSurfaceSpy.mock.calls.find(
|
||||
([props]) =>
|
||||
Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState),
|
||||
);
|
||||
expect(mixerCall?.[0]).toMatchObject({
|
||||
finalUrl: undefined,
|
||||
mixerPreviewState: {
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay.png",
|
||||
blendMode: "multiply",
|
||||
opacity: 62,
|
||||
offsetX: 12,
|
||||
offsetY: -4,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
229
components/canvas/__tests__/mixer-node.test.tsx
Normal file
229
components/canvas/__tests__/mixer-node.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,12 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-reconnect", () => ({
|
||||
useCanvasReconnectHandlers: () => ({
|
||||
onReconnectStart: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onReconnectEnd: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
@@ -45,7 +41,10 @@ type HookHarnessProps = {
|
||||
helperResult: DroppedConnectionTarget | null;
|
||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
|
||||
runRemoveEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
runSwapMixerInputsMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
};
|
||||
@@ -54,7 +53,10 @@ function HookHarness({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
runRemoveEdgeMutation = vi.fn(async () => undefined),
|
||||
runSwapMixerInputsMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
setEdgesMock,
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
@@ -71,7 +73,7 @@ function HookHarness({
|
||||
const isReconnectDragActiveRef = useRef(false);
|
||||
const pendingConnectionCreatesRef = useRef(new Set<string>());
|
||||
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
||||
const setEdges = vi.fn();
|
||||
const setEdges = setEdgesMock ?? vi.fn();
|
||||
const setEdgeSyncNonce = vi.fn();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +104,8 @@ function HookHarness({
|
||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||
showConnectionRejectedToast,
|
||||
@@ -132,6 +135,47 @@ describe("useCanvasConnections", () => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("exposes mixer metadata required for placement and connection defaults", () => {
|
||||
const mixerCatalogEntry = NODE_CATALOG.find((entry) => entry.type === "mixer");
|
||||
const mixerTemplate = CANVAS_NODE_TEMPLATES.find(
|
||||
(template) => (template.type as string) === "mixer",
|
||||
);
|
||||
|
||||
expect(nodeTypes).toHaveProperty("mixer");
|
||||
expect(mixerCatalogEntry).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "mixer",
|
||||
category: "control",
|
||||
implemented: true,
|
||||
}),
|
||||
);
|
||||
expect(mixerTemplate).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "mixer",
|
||||
defaultData: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(NODE_HANDLE_MAP.mixer).toEqual({
|
||||
source: "mixer-out",
|
||||
target: "base",
|
||||
});
|
||||
expect(NODE_DEFAULTS.mixer).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an edge when a body drop lands on another node", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
@@ -490,6 +534,320 @@ describe("useCanvasConnections", () => {
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("allows image-like sources to connect to mixer", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<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 () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
@@ -535,4 +893,364 @@ describe("useCanvasConnections", () => {
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("passes edgeIdToIgnore during reconnect replacement without client-side old-edge delete", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runRemoveEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||
import { getSingleCharacterHotkey } from "@/components/canvas/canvas-helpers";
|
||||
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
||||
import {
|
||||
Command,
|
||||
@@ -98,7 +99,7 @@ export function CanvasCommandPalette() {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (e.key.toLowerCase() !== "k") return;
|
||||
if (getSingleCharacterHotkey(e) !== "k") return;
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
validateCanvasConnectionPolicy,
|
||||
type CanvasConnectionValidationReason,
|
||||
} from "@/lib/canvas-connection-policy";
|
||||
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||
|
||||
@@ -27,6 +28,7 @@ export function validateCanvasConnection(
|
||||
sourceType: sourceNode.type ?? "",
|
||||
targetType: targetNode.type ?? "",
|
||||
targetNodeId: connection.target,
|
||||
targetHandle: connection.targetHandle,
|
||||
edges,
|
||||
edgeToReplaceId,
|
||||
includeOptimisticEdges: options?.includeOptimisticEdges,
|
||||
@@ -37,22 +39,25 @@ export function validateCanvasConnectionByType(args: {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
targetNodeId: string;
|
||||
targetHandle?: string | null;
|
||||
edges: RFEdge[];
|
||||
edgeToReplaceId?: string;
|
||||
includeOptimisticEdges?: boolean;
|
||||
}): CanvasConnectionValidationReason | null {
|
||||
const targetIncomingCount = args.edges.filter(
|
||||
const targetIncomingEdges = args.edges.filter(
|
||||
(edge) =>
|
||||
edge.className !== "temp" &&
|
||||
(args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) &&
|
||||
edge.target === args.targetNodeId &&
|
||||
edge.id !== args.edgeToReplaceId,
|
||||
).length;
|
||||
);
|
||||
|
||||
return validateCanvasConnectionPolicy({
|
||||
sourceType: args.sourceType,
|
||||
targetType: args.targetType,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncomingEdges.length,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncomingEdges.map((edge) => edge.targetHandle),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,17 +74,21 @@ export function validateCanvasEdgeSplit(args: {
|
||||
return "unknown-node";
|
||||
}
|
||||
|
||||
const middleNodeHandles = NODE_HANDLE_MAP[args.middleNode.type ?? ""];
|
||||
|
||||
return (
|
||||
validateCanvasConnectionByType({
|
||||
sourceType: sourceNode.type ?? "",
|
||||
targetType: args.middleNode.type ?? "",
|
||||
targetNodeId: args.middleNode.id,
|
||||
targetHandle: middleNodeHandles?.target,
|
||||
edges: args.edges,
|
||||
}) ??
|
||||
validateCanvasConnectionByType({
|
||||
sourceType: args.middleNode.type ?? "",
|
||||
targetType: targetNode.type ?? "",
|
||||
targetNodeId: targetNode.id,
|
||||
targetHandle: args.splitEdge.targetHandle,
|
||||
edges: args.edges,
|
||||
edgeToReplaceId: args.splitEdge.id,
|
||||
})
|
||||
|
||||
@@ -759,6 +759,18 @@ export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||
return target.closest("input, textarea, select, [contenteditable=true]") !== null;
|
||||
}
|
||||
|
||||
export function getSingleCharacterHotkey(event: { key?: string; type: string }): string {
|
||||
if (typeof event.key !== "string") {
|
||||
console.warn("[Canvas] keyboard event missing string key", {
|
||||
eventType: event.type,
|
||||
key: event.key,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return event.key.length === 1 ? event.key.toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isEdgeCuttable(edge: RFEdge): boolean {
|
||||
if (edge.className === "temp") return false;
|
||||
if (isOptimisticEdgeId(edge.id)) return false;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Image,
|
||||
Package,
|
||||
Palette,
|
||||
Layers,
|
||||
Sparkles,
|
||||
StickyNote,
|
||||
Sun,
|
||||
@@ -43,6 +44,7 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
||||
"light-adjust": Sun,
|
||||
"detail-adjust": Focus,
|
||||
render: ImageDown,
|
||||
mixer: Layers,
|
||||
};
|
||||
|
||||
const NODE_SEARCH_KEYWORDS: Partial<
|
||||
|
||||
@@ -16,12 +16,28 @@ type UseCanvasReconnectHandlersParams = {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runSwapMixerInputsMutation?: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||
validateConnection?: (
|
||||
oldEdge: RFEdge,
|
||||
newConnection: Connection,
|
||||
) => string | null;
|
||||
resolveMixerSwapReconnect?: (
|
||||
oldEdge: RFEdge,
|
||||
newConnection: Connection,
|
||||
validationError: string,
|
||||
) => {
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
nextEdgeHandle: "base" | "overlay";
|
||||
nextOtherEdgeHandle: "base" | "overlay";
|
||||
} | null;
|
||||
onInvalidConnection?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -31,18 +47,29 @@ export function useCanvasReconnectHandlers({
|
||||
isReconnectDragActiveRef,
|
||||
setEdges,
|
||||
runCreateEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runRemoveEdgeMutation,
|
||||
validateConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection,
|
||||
}: UseCanvasReconnectHandlersParams): {
|
||||
onReconnectStart: () => void;
|
||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
|
||||
} {
|
||||
const pendingReconnectRef = useRef<{
|
||||
const pendingReconnectRef = useRef<
|
||||
| {
|
||||
kind: "replace";
|
||||
oldEdge: RFEdge;
|
||||
newConnection: Connection;
|
||||
} | null>(null);
|
||||
}
|
||||
| {
|
||||
kind: "swap";
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const onReconnectStart = useCallback(() => {
|
||||
edgeReconnectSuccessful.current = false;
|
||||
@@ -54,6 +81,38 @@ export function useCanvasReconnectHandlers({
|
||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||
const validationError = validateConnection?.(oldEdge, newConnection) ?? null;
|
||||
if (validationError) {
|
||||
const swapReconnect = resolveMixerSwapReconnect?.(
|
||||
oldEdge,
|
||||
newConnection,
|
||||
validationError,
|
||||
);
|
||||
if (swapReconnect) {
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = {
|
||||
kind: "swap",
|
||||
edgeId: swapReconnect.edgeId,
|
||||
otherEdgeId: swapReconnect.otherEdgeId,
|
||||
};
|
||||
setEdges((currentEdges) =>
|
||||
currentEdges.map((candidate) => {
|
||||
if (candidate.id === swapReconnect.edgeId) {
|
||||
return {
|
||||
...candidate,
|
||||
targetHandle: swapReconnect.nextEdgeHandle,
|
||||
};
|
||||
}
|
||||
if (candidate.id === swapReconnect.otherEdgeId) {
|
||||
return {
|
||||
...candidate,
|
||||
targetHandle: swapReconnect.nextOtherEdgeHandle,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = null;
|
||||
onInvalidConnection?.(validationError);
|
||||
@@ -61,10 +120,20 @@ export function useCanvasReconnectHandlers({
|
||||
}
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = { oldEdge, newConnection };
|
||||
pendingReconnectRef.current = {
|
||||
kind: "replace",
|
||||
oldEdge,
|
||||
newConnection,
|
||||
};
|
||||
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
||||
},
|
||||
[edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection],
|
||||
[
|
||||
edgeReconnectSuccessful,
|
||||
onInvalidConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
setEdges,
|
||||
validateConnection,
|
||||
],
|
||||
);
|
||||
|
||||
const onReconnectEnd = useCallback(
|
||||
@@ -95,18 +164,16 @@ export function useCanvasReconnectHandlers({
|
||||
|
||||
const pendingReconnect = pendingReconnectRef.current;
|
||||
pendingReconnectRef.current = null;
|
||||
if (
|
||||
pendingReconnect &&
|
||||
pendingReconnect.newConnection.source &&
|
||||
pendingReconnect.newConnection.target
|
||||
) {
|
||||
if (pendingReconnect?.kind === "replace" && pendingReconnect.newConnection.source && pendingReconnect.newConnection.target) {
|
||||
void runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">,
|
||||
targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">,
|
||||
sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined,
|
||||
targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined,
|
||||
}).catch((error) => {
|
||||
edgeIdToIgnore: pendingReconnect.oldEdge.id as Id<"edges">,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] create edge", {
|
||||
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||
source: pendingReconnect.newConnection.source,
|
||||
@@ -114,13 +181,18 @@ export function useCanvasReconnectHandlers({
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingReconnect.oldEdge.className !== "temp") {
|
||||
void runRemoveEdgeMutation({
|
||||
edgeId: pendingReconnect.oldEdge.id as Id<"edges">,
|
||||
if (pendingReconnect?.kind === "swap") {
|
||||
if (runSwapMixerInputsMutation) {
|
||||
void runSwapMixerInputsMutation({
|
||||
canvasId,
|
||||
edgeId: pendingReconnect.edgeId,
|
||||
otherEdgeId: pendingReconnect.otherEdgeId,
|
||||
}).catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] remove old edge", {
|
||||
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||
console.error("[Canvas edge reconnect failed] swap mixer inputs", {
|
||||
edgeId: pendingReconnect.edgeId,
|
||||
otherEdgeId: pendingReconnect.otherEdgeId,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
@@ -138,6 +210,7 @@ export function useCanvasReconnectHandlers({
|
||||
isReconnectDragActiveRef,
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
setEdges,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar";
|
||||
import {
|
||||
collectCuttableEdgesAlongScreenSegment,
|
||||
getSingleCharacterHotkey,
|
||||
getIntersectedEdgeId,
|
||||
isEdgeCuttable,
|
||||
isEditableKeyboardTarget,
|
||||
@@ -50,8 +51,7 @@ export function useCanvasScissors({
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const isScissorHotkey =
|
||||
event.key.length === 1 && event.key.toLowerCase() === "k";
|
||||
const isScissorHotkey = getSingleCharacterHotkey(event) === "k";
|
||||
if (!isScissorHotkey) return;
|
||||
if (isEditableKeyboardTarget(event.target)) return;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||
import {
|
||||
CANVAS_MIN_ZOOM,
|
||||
DEFAULT_EDGE_OPTIONS,
|
||||
getSingleCharacterHotkey,
|
||||
getMiniMapNodeColor,
|
||||
getMiniMapNodeStrokeColor,
|
||||
getPendingRemovedEdgeIdsFromLocalOps,
|
||||
@@ -100,6 +101,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const runSwapMixerInputsMutation = useMutation(api.edges.swapMixerInputs);
|
||||
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
|
||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||
string | null
|
||||
@@ -237,7 +239,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (isEditableKeyboardTarget(e.target)) return;
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : "";
|
||||
const key = getSingleCharacterHotkey(e);
|
||||
if (key === "v") {
|
||||
e.preventDefault();
|
||||
handleNavToolChange("select");
|
||||
@@ -342,6 +344,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
showConnectionRejectedToast,
|
||||
|
||||
@@ -18,6 +18,7 @@ import RenderNode from "./nodes/render-node";
|
||||
import CropNode from "./nodes/crop-node";
|
||||
import AgentNode from "./nodes/agent-node";
|
||||
import AgentOutputNode from "./nodes/agent-output-node";
|
||||
import MixerNode from "./nodes/mixer-node";
|
||||
|
||||
/**
|
||||
* Node-Type-Map für React Flow.
|
||||
@@ -46,5 +47,6 @@ export const nodeTypes = {
|
||||
crop: CropNode,
|
||||
render: RenderNode,
|
||||
agent: AgentNode,
|
||||
mixer: MixerNode,
|
||||
"agent-output": AgentOutputNode,
|
||||
} as const;
|
||||
|
||||
@@ -20,6 +20,7 @@ type AgentOutputNodeData = {
|
||||
content?: string;
|
||||
}>;
|
||||
metadata?: Record<string, string | string[] | unknown>;
|
||||
metadataLabels?: Record<string, string | unknown>;
|
||||
qualityChecks?: string[];
|
||||
outputType?: string;
|
||||
body?: string;
|
||||
@@ -102,6 +103,18 @@ function normalizeMetadata(raw: AgentOutputNodeData["metadata"]) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveMetadataLabel(
|
||||
key: string,
|
||||
rawLabels: AgentOutputNodeData["metadataLabels"],
|
||||
): string {
|
||||
if (!rawLabels || typeof rawLabels !== "object" || Array.isArray(rawLabels)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
const candidate = rawLabels[key];
|
||||
return typeof candidate === "string" && candidate.trim() !== "" ? candidate.trim() : key;
|
||||
}
|
||||
|
||||
function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
@@ -113,6 +126,66 @@ function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): stri
|
||||
.filter((value) => value !== "");
|
||||
}
|
||||
|
||||
function normalizeSectionToken(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function partitionSections(
|
||||
sections: Array<{ id: string; label: string; content: string }>,
|
||||
artifactType: string,
|
||||
) {
|
||||
const artifactToken = normalizeSectionToken(artifactType);
|
||||
const priorityTokens = artifactToken === "socialcaptionpack" ? ["caption", "hashtags", "cta"] : [];
|
||||
const isSecondaryNote = (label: string) => {
|
||||
const token = normalizeSectionToken(label);
|
||||
return token.includes("formatnote") || token.includes("assumption");
|
||||
};
|
||||
|
||||
const primaryWithIndex: Array<{ section: (typeof sections)[number]; index: number }> = [];
|
||||
const secondary: Array<{ id: string; label: string; content: string }> = [];
|
||||
|
||||
sections.forEach((section, index) => {
|
||||
if (isSecondaryNote(section.label)) {
|
||||
secondary.push(section);
|
||||
return;
|
||||
}
|
||||
primaryWithIndex.push({ section, index });
|
||||
});
|
||||
|
||||
if (priorityTokens.length === 0) {
|
||||
return {
|
||||
primary: primaryWithIndex.map((entry) => entry.section),
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
const priorityIndexByToken = new Map(priorityTokens.map((token, index) => [token, index]));
|
||||
const primary = [...primaryWithIndex]
|
||||
.sort((left, right) => {
|
||||
const leftToken = normalizeSectionToken(left.section.label);
|
||||
const rightToken = normalizeSectionToken(right.section.label);
|
||||
const leftPriority = priorityIndexByToken.get(leftToken);
|
||||
const rightPriority = priorityIndexByToken.get(rightToken);
|
||||
|
||||
if (leftPriority !== undefined && rightPriority !== undefined) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
if (leftPriority !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (rightPriority !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((entry) => entry.section);
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
|
||||
const t = useTranslations("agentOutputNode");
|
||||
const nodeData = data as AgentOutputNodeData;
|
||||
@@ -140,14 +213,23 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
const body = nodeData.body ?? "";
|
||||
const artifactType = nodeData.artifactType ?? nodeData.outputType ?? "";
|
||||
const sections = normalizeSections(nodeData.sections);
|
||||
const { primary: primarySections, secondary: secondarySections } = partitionSections(
|
||||
sections,
|
||||
artifactType,
|
||||
);
|
||||
const metadataEntries = normalizeMetadata(nodeData.metadata);
|
||||
const metadataLabels = nodeData.metadataLabels;
|
||||
const qualityChecks = normalizeQualityChecks(nodeData.qualityChecks);
|
||||
const previewText =
|
||||
typeof nodeData.previewText === "string" && nodeData.previewText.trim() !== ""
|
||||
? nodeData.previewText.trim()
|
||||
: sections[0]?.content ?? "";
|
||||
: primarySections[0]?.content ?? sections[0]?.content ?? "";
|
||||
const hasStructuredOutput =
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -186,24 +268,6 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
) : null}
|
||||
</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 ? (
|
||||
<section className="space-y-1">
|
||||
<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>
|
||||
) : 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">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p>
|
||||
<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">
|
||||
<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">
|
||||
@@ -240,7 +353,7 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
<div className="space-y-1 text-[12px] text-foreground/90">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<p key={key} className="break-words">
|
||||
<span className="font-semibold">{key}</span>: {value}
|
||||
<span className="font-semibold">{resolveMetadataLabel(key, metadataLabels)}</span>: {value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -262,13 +375,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
</div>
|
||||
</section>
|
||||
) : 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>
|
||||
</section>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
) : formattedJsonBody ? (
|
||||
<section className="space-y-1">
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
resolveMixerPreviewFromGraph,
|
||||
type MixerPreviewState,
|
||||
} from "@/lib/canvas-mixer-preview";
|
||||
|
||||
interface CompareNodeData {
|
||||
leftUrl?: string;
|
||||
@@ -25,6 +29,7 @@ type CompareSideState = {
|
||||
finalUrl?: string;
|
||||
label?: string;
|
||||
previewInput?: RenderPreviewInput;
|
||||
mixerPreviewState?: MixerPreviewState;
|
||||
isStaleRenderOutput: boolean;
|
||||
};
|
||||
|
||||
@@ -59,6 +64,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
const label = finalLabel ?? sourceLabel ?? defaultLabel;
|
||||
|
||||
let previewInput: RenderPreviewInput | undefined;
|
||||
let mixerPreviewState: MixerPreviewState | undefined;
|
||||
let isStaleRenderOutput = false;
|
||||
|
||||
if (sourceNode && sourceNode.type === "render") {
|
||||
@@ -97,11 +103,36 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUrl) {
|
||||
return { finalUrl, label, previewInput, isStaleRenderOutput };
|
||||
if (sourceNode && sourceNode.type === "mixer") {
|
||||
const mixerPreview = resolveMixerPreviewFromGraph({
|
||||
nodeId: sourceNode.id,
|
||||
graph,
|
||||
});
|
||||
|
||||
if (mixerPreview.status === "ready") {
|
||||
mixerPreviewState = mixerPreview;
|
||||
}
|
||||
}
|
||||
|
||||
return { label, previewInput, isStaleRenderOutput };
|
||||
const visibleFinalUrl =
|
||||
sourceNode?.type === "mixer" && mixerPreviewState ? undefined : finalUrl;
|
||||
|
||||
if (visibleFinalUrl) {
|
||||
return {
|
||||
finalUrl: visibleFinalUrl,
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
isStaleRenderOutput,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
isStaleRenderOutput,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -117,8 +148,16 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
graph,
|
||||
]);
|
||||
|
||||
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput);
|
||||
const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput);
|
||||
const hasLeft = Boolean(
|
||||
resolvedSides.left.finalUrl ||
|
||||
resolvedSides.left.previewInput ||
|
||||
resolvedSides.left.mixerPreviewState,
|
||||
);
|
||||
const hasRight = Boolean(
|
||||
resolvedSides.right.finalUrl ||
|
||||
resolvedSides.right.previewInput ||
|
||||
resolvedSides.right.mixerPreviewState,
|
||||
);
|
||||
const hasConnectedRenderInput = useMemo(
|
||||
() =>
|
||||
incomingEdges.some((edge) => {
|
||||
@@ -273,6 +312,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
finalUrl={resolvedSides.right.finalUrl}
|
||||
label={resolvedSides.right.label}
|
||||
previewInput={resolvedSides.right.previewInput}
|
||||
mixerPreviewState={resolvedSides.right.mixerPreviewState}
|
||||
nodeWidth={previewNodeWidth}
|
||||
preferPreview={effectiveDisplayMode === "preview"}
|
||||
/>
|
||||
@@ -283,6 +323,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
finalUrl={resolvedSides.left.finalUrl}
|
||||
label={resolvedSides.left.label}
|
||||
previewInput={resolvedSides.left.previewInput}
|
||||
mixerPreviewState={resolvedSides.left.mixerPreviewState}
|
||||
nodeWidth={previewNodeWidth}
|
||||
clipWidthPercent={sliderX}
|
||||
preferPreview={effectiveDisplayMode === "preview"}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
shouldFastPathPreviewPipeline,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import type { MixerPreviewState } from "@/lib/canvas-mixer-preview";
|
||||
|
||||
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
|
||||
|
||||
@@ -13,6 +14,7 @@ type CompareSurfaceProps = {
|
||||
finalUrl?: string;
|
||||
label?: string;
|
||||
previewInput?: RenderPreviewInput;
|
||||
mixerPreviewState?: MixerPreviewState;
|
||||
nodeWidth: number;
|
||||
clipWidthPercent?: number;
|
||||
preferPreview?: boolean;
|
||||
@@ -22,6 +24,7 @@ export default function CompareSurface({
|
||||
finalUrl,
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
nodeWidth,
|
||||
clipWidthPercent,
|
||||
preferPreview,
|
||||
@@ -52,6 +55,7 @@ export default function CompareSurface({
|
||||
});
|
||||
|
||||
const hasPreview = Boolean(usePreview && previewInput);
|
||||
const hasMixerPreview = mixerPreviewState?.status === "ready";
|
||||
const clipStyle =
|
||||
typeof clipWidthPercent === "number"
|
||||
? {
|
||||
@@ -75,6 +79,28 @@ export default function CompareSurface({
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
) : hasMixerPreview ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<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}
|
||||
|
||||
{hasPreview ? (
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function ImageNode({
|
||||
height,
|
||||
}: NodeProps<ImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const tMedia = useTranslations("mediaLibrary.imageNode");
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||
@@ -377,7 +378,7 @@ export default function ImageNode({
|
||||
}
|
||||
|
||||
if (item.kind !== "image" || !item.storageId) {
|
||||
toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden.");
|
||||
toast.error(t('canvas.uploadFailed'), tMedia("invalidSelection"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +428,7 @@ export default function ImageNode({
|
||||
);
|
||||
}
|
||||
},
|
||||
[data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t],
|
||||
[data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t, tMedia],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -586,7 +587,9 @@ export default function ImageNode({
|
||||
disabled={isNodeLoading || !isNodeStable}
|
||||
className="nodrag mt-3 inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isNodeStable ? "Aus Mediathek" : "Mediathek wird vorbereitet..."}
|
||||
{isNodeStable
|
||||
? tMedia("openButton")
|
||||
: tMedia("preparingButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -650,7 +653,7 @@ export default function ImageNode({
|
||||
onOpenChange={setIsMediaLibraryOpen}
|
||||
onPick={handlePickFromMediaLibrary}
|
||||
kindFilter="image"
|
||||
pickCtaLabel="Uebernehmen"
|
||||
pickCtaLabel={tMedia("pickCta")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
189
components/canvas/nodes/mixer-node.tsx
Normal file
189
components/canvas/nodes/mixer-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type UseCanvasConnectionsParams = {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runSplitEdgeAtExistingNodeMutation: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -61,6 +62,11 @@ type UseCanvasConnectionsParams = {
|
||||
newNodeTargetHandle?: string;
|
||||
}) => Promise<unknown>;
|
||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||
runSwapMixerInputsMutation: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: CanvasNodeType;
|
||||
@@ -113,6 +119,7 @@ export function useCanvasConnections({
|
||||
runRemoveEdgeMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
runSwapMixerInputsMutation,
|
||||
showConnectionRejectedToast,
|
||||
}: UseCanvasConnectionsParams) {
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
@@ -178,6 +185,70 @@ export function useCanvasConnections({
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
||||
);
|
||||
|
||||
const resolveMixerSwapReconnect = useCallback(
|
||||
(oldEdge: RFEdge, newConnection: Connection, validationError: string) => {
|
||||
if (validationError !== "mixer-handle-incoming-limit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!newConnection.target || oldEdge.target !== newConnection.target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNode = nodes.find((node) => node.id === newConnection.target);
|
||||
if (!targetNode || targetNode.type !== "mixer") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizeMixerHandle = (handle: string | null | undefined): "base" | "overlay" | null => {
|
||||
if (handle == null || handle === "" || handle === "null") {
|
||||
return "base";
|
||||
}
|
||||
if (handle === "base" || handle === "overlay") {
|
||||
return handle;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const oldHandle = normalizeMixerHandle(oldEdge.targetHandle);
|
||||
const requestedHandle = normalizeMixerHandle(newConnection.targetHandle);
|
||||
if (!oldHandle || !requestedHandle || oldHandle === requestedHandle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mixerIncomingEdges = edges.filter(
|
||||
(edge) =>
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id) &&
|
||||
edge.target === newConnection.target,
|
||||
);
|
||||
|
||||
if (mixerIncomingEdges.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const otherEdge = mixerIncomingEdges.find(
|
||||
(candidate) => candidate.id !== oldEdge.id,
|
||||
);
|
||||
if (!otherEdge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const otherHandle = normalizeMixerHandle(otherEdge.targetHandle);
|
||||
if (!otherHandle || otherHandle !== requestedHandle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
edgeId: oldEdge.id as Id<"edges">,
|
||||
otherEdgeId: otherEdge.id as Id<"edges">,
|
||||
nextEdgeHandle: requestedHandle,
|
||||
nextOtherEdgeHandle: oldHandle,
|
||||
};
|
||||
},
|
||||
[edges, nodes],
|
||||
);
|
||||
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
@@ -438,6 +509,7 @@ export function useCanvasConnections({
|
||||
sourceType: fromNode.type ?? "",
|
||||
targetType: template.type,
|
||||
targetNodeId: `__pending_${template.type}_${Date.now()}`,
|
||||
targetHandle: handles?.target,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
if (validationError) {
|
||||
@@ -469,6 +541,7 @@ export function useCanvasConnections({
|
||||
sourceType: template.type,
|
||||
targetType: fromNode.type ?? "",
|
||||
targetNodeId: fromNode.id,
|
||||
targetHandle: ctx.fromHandleId,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
if (validationError) {
|
||||
@@ -518,8 +591,10 @@ export function useCanvasConnections({
|
||||
setEdges,
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
validateConnection: (oldEdge, nextConnection) =>
|
||||
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection: (reason) => {
|
||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||
},
|
||||
|
||||
@@ -7,8 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useAuthQuery: vi.fn(),
|
||||
resolveUrls: vi.fn(async () => ({})),
|
||||
useTranslations: vi.fn(),
|
||||
}));
|
||||
|
||||
const translations = {
|
||||
previous: "Zurueck",
|
||||
next: "Weiter",
|
||||
pageOf: "Seite {page} von {totalPages}",
|
||||
} as const;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => mocks.resolveUrls,
|
||||
}));
|
||||
@@ -17,6 +24,10 @@ vi.mock("@/hooks/use-auth-query", () => ({
|
||||
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (...args: unknown[]) => mocks.useTranslations(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
@@ -49,7 +60,23 @@ describe("MediaLibraryDialog", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useAuthQuery.mockReset();
|
||||
mocks.resolveUrls.mockReset();
|
||||
mocks.useTranslations.mockReset();
|
||||
mocks.resolveUrls.mockImplementation(async () => ({}));
|
||||
const translate = (
|
||||
key: keyof typeof translations,
|
||||
values?: Record<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");
|
||||
document.body.appendChild(container);
|
||||
@@ -86,7 +113,7 @@ describe("MediaLibraryDialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders at most 8 cards and shows Freepik-style pagination footer", async () => {
|
||||
it("renders at most 8 cards and shows localized pagination footer", async () => {
|
||||
mocks.useAuthQuery.mockReturnValue({
|
||||
items: makeItems(10),
|
||||
page: 1,
|
||||
@@ -102,9 +129,9 @@ describe("MediaLibraryDialog", () => {
|
||||
const cards = document.querySelectorAll("img[alt^='Item 1-']");
|
||||
expect(cards).toHaveLength(8);
|
||||
|
||||
expect(document.body.textContent).toContain("Previous");
|
||||
expect(document.body.textContent).toContain("Page 1 of 3");
|
||||
expect(document.body.textContent).toContain("Next");
|
||||
expect(document.body.textContent).toContain("Zurueck");
|
||||
expect(document.body.textContent).toContain("Seite 1 von 3");
|
||||
expect(document.body.textContent).toContain("Weiter");
|
||||
});
|
||||
|
||||
it("updates query args when clicking next and previous", async () => {
|
||||
@@ -135,7 +162,7 @@ describe("MediaLibraryDialog", () => {
|
||||
});
|
||||
|
||||
const nextButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Next",
|
||||
(button) => button.textContent?.trim() === "Weiter",
|
||||
);
|
||||
if (!(nextButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Next button not found");
|
||||
@@ -149,7 +176,7 @@ describe("MediaLibraryDialog", () => {
|
||||
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
|
||||
|
||||
const previousButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Previous",
|
||||
(button) => button.textContent?.trim() === "Zurueck",
|
||||
);
|
||||
if (!(previousButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Previous button not found");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -73,15 +74,18 @@ function formatDimensions(width: number | undefined, height: number | undefined)
|
||||
return `${width} x ${height}px`;
|
||||
}
|
||||
|
||||
function formatMediaMeta(item: MediaLibraryItem): string {
|
||||
function formatMediaMeta(
|
||||
item: MediaLibraryItem,
|
||||
tCommon: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (item.kind === "video") {
|
||||
if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) {
|
||||
return `${Math.max(1, Math.round(item.durationSeconds))}s`;
|
||||
}
|
||||
return "Videodatei";
|
||||
return tCommon("videoFile");
|
||||
}
|
||||
|
||||
return formatDimensions(item.width, item.height) ?? "Groesse unbekannt";
|
||||
return formatDimensions(item.width, item.height) ?? tCommon("unknownSize");
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaLibraryItem): string {
|
||||
@@ -104,32 +108,37 @@ function getItemKey(item: MediaLibraryItem): string {
|
||||
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
|
||||
}
|
||||
|
||||
function getItemLabel(item: MediaLibraryItem): string {
|
||||
function getItemLabel(
|
||||
item: MediaLibraryItem,
|
||||
tCommon: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (item.filename) {
|
||||
return item.filename;
|
||||
}
|
||||
|
||||
if (item.kind === "video") {
|
||||
return "Unbenanntes Video";
|
||||
return tCommon("untitledVideo");
|
||||
}
|
||||
|
||||
if (item.kind === "asset") {
|
||||
return "Unbenanntes Asset";
|
||||
return tCommon("untitledAsset");
|
||||
}
|
||||
|
||||
return "Unbenanntes Bild";
|
||||
return tCommon("untitledImage");
|
||||
}
|
||||
|
||||
export function MediaLibraryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPick,
|
||||
title = "Mediathek",
|
||||
title,
|
||||
description,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
kindFilter,
|
||||
pickCtaLabel = "Auswaehlen",
|
||||
pickCtaLabel,
|
||||
}: MediaLibraryDialogProps) {
|
||||
const tDialog = useTranslations("mediaLibrary.dialog");
|
||||
const tCommon = useTranslations("mediaLibrary.common");
|
||||
const [page, setPage] = useState(1);
|
||||
const normalizedPageSize = useMemo(() => {
|
||||
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
|
||||
@@ -203,7 +212,7 @@ export function MediaLibraryDialog({
|
||||
return;
|
||||
}
|
||||
setUrlMap({});
|
||||
setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden.");
|
||||
setUrlError(error instanceof Error ? error.message : tDialog("urlResolveError"));
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsResolvingUrls(false);
|
||||
@@ -216,7 +225,7 @@ export function MediaLibraryDialog({
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [metadata, open, resolveUrls]);
|
||||
}, [metadata, open, resolveUrls, tDialog]);
|
||||
|
||||
const items: MediaLibraryItem[] = useMemo(() => {
|
||||
if (!metadata) {
|
||||
@@ -229,16 +238,25 @@ export function MediaLibraryDialog({
|
||||
}));
|
||||
}, [metadata, urlMap]);
|
||||
|
||||
const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]);
|
||||
const visibleItems = useMemo(
|
||||
() => items.slice(0, normalizedPageSize),
|
||||
[items, normalizedPageSize],
|
||||
);
|
||||
|
||||
const isMetadataLoading = open && metadata === undefined;
|
||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||
const isPreviewMode = typeof onPick !== "function";
|
||||
const effectiveTitle = title ?? tDialog("title");
|
||||
const effectivePickCtaLabel = pickCtaLabel ?? tDialog("pick");
|
||||
const effectiveDescription =
|
||||
description ??
|
||||
(kindFilter === "image"
|
||||
? "Waehle ein Bild aus deiner LemonSpace-Mediathek."
|
||||
: "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.");
|
||||
? tDialog("descriptionImage")
|
||||
: kindFilter === "video"
|
||||
? tDialog("descriptionVideo")
|
||||
: kindFilter === "asset"
|
||||
? tDialog("descriptionAsset")
|
||||
: tDialog("descriptionDefault"));
|
||||
|
||||
async function handlePick(item: MediaLibraryItem): Promise<void> {
|
||||
if (!onPick || pendingPickItemKey) {
|
||||
@@ -257,7 +275,7 @@ export function MediaLibraryDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogTitle>{effectiveTitle}</DialogTitle>
|
||||
<DialogDescription>{effectiveDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -277,15 +295,15 @@ export function MediaLibraryDialog({
|
||||
) : 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">
|
||||
<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>
|
||||
</div>
|
||||
) : 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">
|
||||
<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">
|
||||
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.
|
||||
{tDialog("emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -293,8 +311,8 @@ export function MediaLibraryDialog({
|
||||
{visibleItems.map((item) => {
|
||||
const itemKey = getItemKey(item);
|
||||
const isPickingThis = pendingPickItemKey === itemKey;
|
||||
const itemLabel = getItemLabel(item);
|
||||
const metaLabel = formatMediaMeta(item);
|
||||
const itemLabel = getItemLabel(item, tCommon);
|
||||
const metaLabel = formatMediaMeta(item, tCommon);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -340,7 +358,7 @@ export function MediaLibraryDialog({
|
||||
</p>
|
||||
|
||||
{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
|
||||
type="button"
|
||||
@@ -352,10 +370,10 @@ export function MediaLibraryDialog({
|
||||
{isPickingThis ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Wird uebernommen...
|
||||
{tDialog("pickLoading")}
|
||||
</>
|
||||
) : (
|
||||
pickCtaLabel
|
||||
effectivePickCtaLabel
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -375,10 +393,10 @@ export function MediaLibraryDialog({
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
{tDialog("previous")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {metadata.page} of {metadata.totalPages}
|
||||
{tDialog("pageOf", { page: metadata.page, totalPages: metadata.totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -386,7 +404,7 @@ export function MediaLibraryDialog({
|
||||
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
|
||||
disabled={page >= metadata.totalPages}
|
||||
>
|
||||
Next
|
||||
{tDialog("next")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -58,6 +58,7 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
|
||||
| `video-prompt` | `content`, `modelId`, `durationSeconds` | KI-Video-Steuer-Node (Eingabe) |
|
||||
| `ai-video` | `storageId`, `prompt`, `model`, `modelLabel`, `durationSeconds`, `creditCost`, `generatedAt`, `taskId` (transient) | Generiertes KI-Video (System-Output) |
|
||||
| `compare` | `leftNodeId`, `rightNodeId`, `sliderPosition` | Vergleichs-Node |
|
||||
| `mixer` | `blendMode`, `opacity`, `offsetX`, `offsetY` | V1 Merge-Control-Node mit pseudo-image Output (kein Storage-Write) |
|
||||
| `frame` | `label`, `exportWidth`, `exportHeight`, `backgroundColor` | Artboard |
|
||||
| `group` | `label`, `collapsed` | Container-Node |
|
||||
| `note` | `content`, `color` | Anmerkung |
|
||||
@@ -327,8 +328,17 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
||||
- Ziel: `ai-image`, `ai-video`, `compare` → Target-Ports
|
||||
- `video-prompt` → `ai-video` ✅ (einzige gültige Kombination für Video-Flow)
|
||||
- `ai-video` als Source für andere Nodes → ❌ (nur Compare)
|
||||
- `mixer` akzeptiert nur `image|asset|ai-image|render` als Source-Typ
|
||||
- `mixer` akzeptiert nur Target-Handles `base` und `overlay`
|
||||
- `mixer` erlaubt max. eine eingehende Kante pro Handle und max. zwei insgesamt
|
||||
- Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges
|
||||
|
||||
### Mixer V1: Backend-Scope
|
||||
|
||||
- `mixer` ist ein Control-Node mit pseudo-image Semantik, nicht mit persistiertem Medien-Output.
|
||||
- Keine zusaetzlichen Convex-Tabellen oder Storage-Flows fuer Mixer-Vorschauen.
|
||||
- Validierung laeuft client- und serverseitig ueber dieselbe Policy (`validateCanvasConnectionPolicy`); `edges.ts` delegiert darauf fuer Paritaet.
|
||||
|
||||
---
|
||||
|
||||
## Storage (`storage.ts`)
|
||||
|
||||
@@ -11,7 +11,13 @@ import { api, internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { generateStructuredObjectViaOpenRouter } from "./openrouter";
|
||||
import { getNodeDataRecord } from "./ai_node_data";
|
||||
import { formatTerminalStatusMessage } from "./ai_errors";
|
||||
import {
|
||||
errorMessage,
|
||||
formatTerminalStatusMessage,
|
||||
getErrorCode,
|
||||
getErrorSource,
|
||||
getProviderStatus,
|
||||
} from "./ai_errors";
|
||||
import {
|
||||
areClarificationAnswersComplete,
|
||||
buildPreflightClarificationQuestions,
|
||||
@@ -119,14 +125,17 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||
},
|
||||
};
|
||||
|
||||
const metadataValueSchema: Record<string, unknown> = {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{
|
||||
const metadataEntrySchema: Record<string, unknown> = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["key", "values"],
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
values: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const stepOutputProperties: Record<string, unknown> = {};
|
||||
@@ -140,7 +149,7 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||
"artifactType",
|
||||
"previewText",
|
||||
"sections",
|
||||
"metadata",
|
||||
"metadataEntries",
|
||||
"qualityChecks",
|
||||
],
|
||||
properties: {
|
||||
@@ -152,9 +161,9 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||
type: "array",
|
||||
items: sectionSchema,
|
||||
},
|
||||
metadata: {
|
||||
type: "object",
|
||||
additionalProperties: metadataValueSchema,
|
||||
metadataEntries: {
|
||||
type: "array",
|
||||
items: metadataEntrySchema,
|
||||
},
|
||||
qualityChecks: {
|
||||
type: "array",
|
||||
@@ -297,6 +306,7 @@ type InternalApiShape = {
|
||||
previewText: string;
|
||||
sections: AgentOutputSection[];
|
||||
metadata: Record<string, string | string[]>;
|
||||
metadataLabels: Record<string, string>;
|
||||
body: string;
|
||||
},
|
||||
unknown
|
||||
@@ -351,6 +361,18 @@ function trimText(value: unknown): string {
|
||||
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 {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
@@ -519,6 +541,7 @@ function buildSkeletonOutputData(input: {
|
||||
previewText: buildSkeletonPreviewPlaceholder(input.step.title),
|
||||
sections: [],
|
||||
metadata: {},
|
||||
metadataLabels: {},
|
||||
body: "",
|
||||
...(definitionVersion ? { definitionVersion } : {}),
|
||||
};
|
||||
@@ -535,6 +558,7 @@ function buildCompletedOutputData(input: {
|
||||
previewText: string;
|
||||
sections: AgentOutputSection[];
|
||||
metadata: Record<string, string | string[]>;
|
||||
metadataLabels: Record<string, string>;
|
||||
qualityChecks: string[];
|
||||
body: string;
|
||||
};
|
||||
@@ -563,6 +587,10 @@ function buildCompletedOutputData(input: {
|
||||
sections: normalizedSections,
|
||||
metadata:
|
||||
input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {},
|
||||
metadataLabels:
|
||||
input.output.metadataLabels && typeof input.output.metadataLabels === "object"
|
||||
? input.output.metadataLabels
|
||||
: {},
|
||||
body: deriveLegacyBodyFallback({
|
||||
title: trimText(input.output.title) || trimText(input.step.title),
|
||||
previewText: normalizedPreviewText,
|
||||
@@ -976,6 +1004,7 @@ export const completeExecutionStepOutput = internalMutation({
|
||||
}),
|
||||
),
|
||||
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
||||
metadataLabels: v.record(v.string(), v.string()),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1018,6 +1047,7 @@ export const completeExecutionStepOutput = internalMutation({
|
||||
previewText: args.previewText,
|
||||
sections: args.sections,
|
||||
metadata: args.metadata,
|
||||
metadataLabels: args.metadataLabels,
|
||||
qualityChecks: args.qualityChecks,
|
||||
body: args.body,
|
||||
},
|
||||
@@ -1254,6 +1284,7 @@ export const analyzeAgent = internalAction({
|
||||
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
|
||||
});
|
||||
} catch (error) {
|
||||
logAgentFailure("analyzeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
|
||||
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1306,6 +1337,17 @@ export const executeAgent = internalAction({
|
||||
|
||||
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id));
|
||||
|
||||
console.info("[agents][executeAgent] request context", {
|
||||
nodeId: args.nodeId,
|
||||
modelId: args.modelId,
|
||||
stepCount: executionSteps.length,
|
||||
stepIds: executionSteps.map((step) => step.id),
|
||||
artifactTypes: executionSteps.map((step) => step.artifactType),
|
||||
channels: executionSteps.map((step) => step.channel),
|
||||
incomingContextLength: incomingContext.length,
|
||||
executionPlanSummaryLength: executionPlanSummary.length,
|
||||
});
|
||||
|
||||
const execution = await generateStructuredObjectViaOpenRouter<{
|
||||
summary: string;
|
||||
stepOutputs: Record<string, AgentStructuredOutputDraft>;
|
||||
@@ -1375,6 +1417,7 @@ export const executeAgent = internalAction({
|
||||
previewText: normalized.previewText,
|
||||
sections: normalized.sections,
|
||||
metadata: normalized.metadata,
|
||||
metadataLabels: normalized.metadataLabels,
|
||||
body: normalized.body,
|
||||
});
|
||||
}
|
||||
@@ -1393,6 +1436,7 @@ export const executeAgent = internalAction({
|
||||
|
||||
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
|
||||
} catch (error) {
|
||||
logAgentFailure("executeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
|
||||
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1404,6 +1448,7 @@ export const executeAgent = internalAction({
|
||||
});
|
||||
|
||||
export const __testables = {
|
||||
buildExecuteSchema,
|
||||
buildSkeletonOutputData,
|
||||
buildCompletedOutputData,
|
||||
getAnalyzeExecutionStepRequiredFields,
|
||||
@@ -1485,6 +1530,7 @@ export const runAgent = action({
|
||||
scheduled = true;
|
||||
return { queued: true, nodeId: args.nodeId };
|
||||
} catch (error) {
|
||||
logAgentFailure("runAgent", { nodeId: args.nodeId, modelId: selectedModel.id }, error);
|
||||
await releasePublicReservationBestEffort(ctx, reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1572,6 +1618,7 @@ export const resumeAgent = action({
|
||||
|
||||
return { queued: true, nodeId: args.nodeId };
|
||||
} catch (error) {
|
||||
logAgentFailure("resumeAgent", { nodeId: args.nodeId, modelId }, error);
|
||||
await releasePublicReservationBestEffort(ctx, reservationId ?? null);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
|
||||
@@ -14,6 +14,47 @@ interface ErrorData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function trimText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseStructuredProviderErrorMessage(raw: string): {
|
||||
message: string;
|
||||
code: string;
|
||||
type: string;
|
||||
} | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<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 {
|
||||
if (error instanceof ConvexError) {
|
||||
const data = error.data as ErrorData;
|
||||
@@ -166,10 +207,31 @@ export function formatTerminalStatusMessage(error: unknown): string {
|
||||
typeof convexData?.status === "number" && Number.isFinite(convexData.status)
|
||||
? convexData.status
|
||||
: null;
|
||||
const structuredProviderFromMessage = parseStructuredProviderErrorMessage(convexDataMessage);
|
||||
|
||||
const structuredProviderMessageFromData =
|
||||
trimText(convexData?.providerMessage) ||
|
||||
structuredProviderFromMessage?.message;
|
||||
const structuredProviderCodeFromData =
|
||||
trimText(convexData?.providerCode) || structuredProviderFromMessage?.code;
|
||||
const structuredProviderTypeFromData =
|
||||
trimText(convexData?.providerType) || structuredProviderFromMessage?.type;
|
||||
|
||||
const structuredProviderDecorators = [
|
||||
structuredProviderCodeFromData ? `code=${structuredProviderCodeFromData}` : "",
|
||||
structuredProviderTypeFromData ? `type=${structuredProviderTypeFromData}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
const structuredProviderSuffix =
|
||||
structuredProviderDecorators.length > 0
|
||||
? ` [${structuredProviderDecorators.join(", ")}]`
|
||||
: "";
|
||||
|
||||
const message =
|
||||
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
|
||||
? convexDataMessage ||
|
||||
? structuredProviderMessageFromData
|
||||
? `${convexDataStatus !== null ? `OpenRouter ${convexDataStatus}: ` : ""}${structuredProviderMessageFromData}${structuredProviderSuffix}`
|
||||
: convexDataMessage ||
|
||||
(convexDataStatus !== null
|
||||
? `HTTP ${convexDataStatus}`
|
||||
: "Anfrage fehlgeschlagen")
|
||||
|
||||
119
convex/edges.ts
119
convex/edges.ts
@@ -8,37 +8,41 @@ import {
|
||||
} from "../lib/canvas-connection-policy";
|
||||
|
||||
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
||||
const MIXER_HANDLES = new Set(["base", "overlay"] as const);
|
||||
|
||||
async function countIncomingEdges(
|
||||
function normalizeMixerHandle(handle: string | undefined): "base" | "overlay" | null {
|
||||
if (handle == null || handle === "" || handle === "null") {
|
||||
return "base";
|
||||
}
|
||||
|
||||
if (MIXER_HANDLES.has(handle as "base" | "overlay")) {
|
||||
return handle as "base" | "overlay";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getIncomingEdgePolicyContext(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
targetNodeId: Id<"nodes">;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
|
||||
const incomingEdgesQuery = ctx.db
|
||||
.query("edges")
|
||||
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
|
||||
|
||||
const checkStartedAt = Date.now();
|
||||
const incomingEdges = await (
|
||||
args.edgeIdToIgnore
|
||||
? incomingEdgesQuery.take(2)
|
||||
: incomingEdgesQuery.first()
|
||||
);
|
||||
const incomingEdges = await incomingEdgesQuery.take(3);
|
||||
const checkDurationMs = Date.now() - checkStartedAt;
|
||||
|
||||
const incomingCount = Array.isArray(incomingEdges)
|
||||
? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length
|
||||
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
|
||||
? 1
|
||||
: 0;
|
||||
const filteredIncomingEdges = incomingEdges.filter(
|
||||
(edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore,
|
||||
);
|
||||
const incomingCount = filteredIncomingEdges.length;
|
||||
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||
const inspected = Array.isArray(incomingEdges)
|
||||
? incomingEdges.length
|
||||
: incomingEdges === null
|
||||
? 0
|
||||
: 1;
|
||||
const inspected = incomingEdges.length;
|
||||
|
||||
console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", {
|
||||
targetNodeId: args.targetNodeId,
|
||||
@@ -48,7 +52,10 @@ async function countIncomingEdges(
|
||||
});
|
||||
}
|
||||
|
||||
return incomingCount;
|
||||
return {
|
||||
count: incomingCount,
|
||||
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertConnectionPolicy(
|
||||
@@ -56,6 +63,7 @@ async function assertConnectionPolicy(
|
||||
args: {
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<void> {
|
||||
@@ -65,7 +73,7 @@ async function assertConnectionPolicy(
|
||||
throw new Error("Source or target node not found");
|
||||
}
|
||||
|
||||
const targetIncomingCount = await countIncomingEdges(ctx, {
|
||||
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
@@ -73,7 +81,9 @@ async function assertConnectionPolicy(
|
||||
const reason = validateCanvasConnectionPolicy({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncoming.targetHandles,
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
@@ -83,7 +93,7 @@ async function assertConnectionPolicy(
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
reason,
|
||||
});
|
||||
throw new Error(getCanvasConnectionValidationMessage(reason));
|
||||
@@ -151,6 +161,7 @@ export const create = mutation({
|
||||
targetNodeId: v.id("nodes"),
|
||||
sourceHandle: v.optional(v.string()),
|
||||
targetHandle: v.optional(v.string()),
|
||||
edgeIdToIgnore: v.optional(v.id("edges")),
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -207,9 +218,23 @@ export const create = mutation({
|
||||
throw new Error("Cannot connect a node to itself");
|
||||
}
|
||||
|
||||
const edgeToIgnore = args.edgeIdToIgnore
|
||||
? await ctx.db.get(args.edgeIdToIgnore)
|
||||
: null;
|
||||
if (args.edgeIdToIgnore) {
|
||||
if (!edgeToIgnore) {
|
||||
throw new Error("Edge to ignore not found");
|
||||
}
|
||||
if (edgeToIgnore.canvasId !== args.canvasId) {
|
||||
throw new Error("Edge to ignore must belong to the same canvas");
|
||||
}
|
||||
}
|
||||
|
||||
await assertConnectionPolicy(ctx, {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
targetNodeId: args.targetNodeId,
|
||||
targetHandle: args.targetHandle,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
|
||||
const edgeId = await ctx.db.insert("edges", {
|
||||
@@ -220,6 +245,10 @@ export const create = mutation({
|
||||
targetHandle: args.targetHandle,
|
||||
});
|
||||
|
||||
if (edgeToIgnore) {
|
||||
await ctx.db.delete(edgeToIgnore._id);
|
||||
}
|
||||
|
||||
console.info("[canvas.updatedAt] touch", {
|
||||
canvasId: args.canvasId,
|
||||
source: "edges.create",
|
||||
@@ -242,6 +271,54 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const swapMixerInputs = mutation({
|
||||
args: {
|
||||
canvasId: v.id("canvases"),
|
||||
edgeId: v.id("edges"),
|
||||
otherEdgeId: v.id("edges"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
const canvas = await ctx.db.get(args.canvasId);
|
||||
if (!canvas || canvas.ownerId !== user.userId) {
|
||||
throw new Error("Canvas not found");
|
||||
}
|
||||
|
||||
if (args.edgeId === args.otherEdgeId) {
|
||||
throw new Error("Edge IDs must be different");
|
||||
}
|
||||
|
||||
const edge = await ctx.db.get(args.edgeId);
|
||||
const otherEdge = await ctx.db.get(args.otherEdgeId);
|
||||
if (!edge || !otherEdge) {
|
||||
throw new Error("Edge not found");
|
||||
}
|
||||
|
||||
if (edge.canvasId !== args.canvasId || otherEdge.canvasId !== args.canvasId) {
|
||||
throw new Error("Edges must belong to the same canvas");
|
||||
}
|
||||
|
||||
if (edge.targetNodeId !== otherEdge.targetNodeId) {
|
||||
throw new Error("Edges must target the same mixer node");
|
||||
}
|
||||
|
||||
const mixerNode = await ctx.db.get(edge.targetNodeId);
|
||||
if (!mixerNode || mixerNode.canvasId !== args.canvasId || mixerNode.type !== "mixer") {
|
||||
throw new Error("Mixer node not found");
|
||||
}
|
||||
|
||||
const edgeHandle = normalizeMixerHandle(edge.targetHandle);
|
||||
const otherEdgeHandle = normalizeMixerHandle(otherEdge.targetHandle);
|
||||
if (!edgeHandle || !otherEdgeHandle || edgeHandle === otherEdgeHandle) {
|
||||
throw new Error("Mixer swap requires one base and one overlay edge");
|
||||
}
|
||||
|
||||
await ctx.db.patch(edge._id, { targetHandle: otherEdgeHandle });
|
||||
await ctx.db.patch(otherEdge._id, { targetHandle: edgeHandle });
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge löschen.
|
||||
*/
|
||||
|
||||
@@ -418,34 +418,27 @@ function normalizeNodeDataForWrite(
|
||||
return preserveNodeFavorite(data, data);
|
||||
}
|
||||
|
||||
async function countIncomingEdges(
|
||||
async function getIncomingEdgePolicyContext(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
targetNodeId: Id<"nodes">;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
|
||||
const incomingEdgesQuery = ctx.db
|
||||
.query("edges")
|
||||
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
|
||||
|
||||
const checkStartedAt = Date.now();
|
||||
const incomingEdges = await (
|
||||
args.edgeIdToIgnore ? incomingEdgesQuery.take(2) : incomingEdgesQuery.first()
|
||||
);
|
||||
const incomingEdges = await incomingEdgesQuery.take(3);
|
||||
const checkDurationMs = Date.now() - checkStartedAt;
|
||||
|
||||
const incomingCount = Array.isArray(incomingEdges)
|
||||
? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length
|
||||
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
|
||||
? 1
|
||||
: 0;
|
||||
const filteredIncomingEdges = incomingEdges.filter(
|
||||
(edge) => edge._id !== args.edgeIdToIgnore,
|
||||
);
|
||||
const incomingCount = filteredIncomingEdges.length;
|
||||
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||
const inspected = Array.isArray(incomingEdges)
|
||||
? incomingEdges.length
|
||||
: incomingEdges === null
|
||||
? 0
|
||||
: 1;
|
||||
const inspected = incomingEdges.length;
|
||||
|
||||
console.warn("[nodes.countIncomingEdges] slow incoming edge check", {
|
||||
targetNodeId: args.targetNodeId,
|
||||
@@ -455,7 +448,10 @@ async function countIncomingEdges(
|
||||
});
|
||||
}
|
||||
|
||||
return incomingCount;
|
||||
return {
|
||||
count: incomingCount,
|
||||
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertConnectionPolicyForTypes(
|
||||
@@ -464,16 +460,21 @@ async function assertConnectionPolicyForTypes(
|
||||
sourceType: Doc<"nodes">["type"];
|
||||
targetType: Doc<"nodes">["type"];
|
||||
targetNodeId: Id<"nodes">;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<void> {
|
||||
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
|
||||
const reason = validateCanvasConnectionPolicy({
|
||||
sourceType: args.sourceType,
|
||||
targetType: args.targetType,
|
||||
targetIncomingCount: await countIncomingEdges(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
}),
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncoming.targetHandles,
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
@@ -870,6 +871,8 @@ export const createWithEdgeSplit = mutation({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: args.type,
|
||||
targetIncomingCount: 0,
|
||||
targetHandle: args.newNodeTargetHandle,
|
||||
targetIncomingHandles: [],
|
||||
});
|
||||
if (firstEdgeReason) {
|
||||
throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason));
|
||||
@@ -879,6 +882,7 @@ export const createWithEdgeSplit = mutation({
|
||||
sourceType: args.type,
|
||||
targetType: targetNode.type,
|
||||
targetNodeId: edge.targetNodeId,
|
||||
targetHandle: args.splitTargetHandle,
|
||||
edgeIdToIgnore: args.splitEdgeId,
|
||||
});
|
||||
|
||||
@@ -1008,6 +1012,7 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: middle.type,
|
||||
targetNodeId: args.middleNodeId,
|
||||
targetHandle: args.newNodeTargetHandle,
|
||||
});
|
||||
|
||||
await ctx.db.insert("edges", {
|
||||
@@ -1022,6 +1027,7 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
sourceType: middle.type,
|
||||
targetType: targetNode.type,
|
||||
targetNodeId: edge.targetNodeId,
|
||||
targetHandle: args.splitTargetHandle,
|
||||
edgeIdToIgnore: args.splitEdgeId,
|
||||
});
|
||||
|
||||
@@ -1098,6 +1104,8 @@ export const createWithEdgeFromSource = mutation({
|
||||
sourceType: source.type,
|
||||
targetType: args.type,
|
||||
targetIncomingCount: 0,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: [],
|
||||
});
|
||||
if (fromSourceReason) {
|
||||
throw new Error(getCanvasConnectionValidationMessage(fromSourceReason));
|
||||
@@ -1188,6 +1196,7 @@ export const createWithEdgeToTarget = mutation({
|
||||
sourceType: args.type,
|
||||
targetType: target.type,
|
||||
targetNodeId,
|
||||
targetHandle: args.targetHandle,
|
||||
});
|
||||
|
||||
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||
|
||||
@@ -151,6 +151,161 @@ function parseStructuredJsonFromMessageContent(contentText: string):
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
type StructuredOpenRouterErrorInfo = {
|
||||
userMessage: string;
|
||||
providerMessage: string;
|
||||
providerCode: string;
|
||||
providerType: string;
|
||||
rawBodyPreview: string;
|
||||
};
|
||||
|
||||
type StructuredSchemaDiagnostics = {
|
||||
topLevelType: string;
|
||||
topLevelRequiredCount: number;
|
||||
topLevelPropertyCount: number;
|
||||
schemaBytes: number;
|
||||
messageCount: number;
|
||||
messageLengths: number[];
|
||||
hasAnyOf: boolean;
|
||||
hasOneOf: boolean;
|
||||
hasAllOf: boolean;
|
||||
hasPatternProperties: boolean;
|
||||
hasDynamicAdditionalProperties: boolean;
|
||||
};
|
||||
|
||||
function walkStructuredSchema(
|
||||
value: unknown,
|
||||
visitor: (node: Record<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>(
|
||||
apiKey: string,
|
||||
args: {
|
||||
@@ -163,6 +318,17 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
schema: Record<string, unknown>;
|
||||
},
|
||||
): 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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -188,10 +354,25 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const errorInfo = summarizeStructuredOpenRouterError(errorText, response.status);
|
||||
console.error("[openrouter][structured] non-ok response", {
|
||||
model: args.model,
|
||||
schemaName: args.schemaName,
|
||||
status: response.status,
|
||||
providerMessage: errorInfo.providerMessage || undefined,
|
||||
providerCode: errorInfo.providerCode || undefined,
|
||||
providerType: errorInfo.providerType || undefined,
|
||||
rawBodyPreview: errorInfo.rawBodyPreview,
|
||||
});
|
||||
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: response.status,
|
||||
message: errorText,
|
||||
message: errorInfo.userMessage,
|
||||
providerMessage: errorInfo.providerMessage || undefined,
|
||||
providerCode: errorInfo.providerCode || undefined,
|
||||
providerType: errorInfo.providerType || undefined,
|
||||
rawBodyPreview: errorInfo.rawBodyPreview,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +404,11 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
return parsedContent.value as T;
|
||||
}
|
||||
|
||||
export const __testables = {
|
||||
getStructuredSchemaDiagnostics,
|
||||
summarizeStructuredOpenRouterError,
|
||||
};
|
||||
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -107,7 +107,8 @@ isNodePaletteEnabled // true wenn: implementiert + kein systemOutput + Template
|
||||
|
||||
**Kategorien:**
|
||||
- `source` — Quelle (image, text, video, asset, color)
|
||||
- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text, ai-video, agent-output)
|
||||
- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text)
|
||||
- `agents` — Agents (agent, agent-output)
|
||||
- `transform` — Transformation (crop, bg-remove, upscale)
|
||||
- `image-edit` — Bildbearbeitung (adjustments)
|
||||
- `control` — Steuerung & Flow
|
||||
|
||||
@@ -215,6 +215,24 @@ function formatExecutionRequirements(plan: AgentExecutionPlan): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatDeliverableFirstInstructions(definition: AgentDefinition): string {
|
||||
const rules = [
|
||||
"Prioritize publishable, user-facing deliverables for every execution step.",
|
||||
"Lead with final copy/content that can be shipped immediately.",
|
||||
"Keep assumptions, rationale, and risk notes secondary and concise.",
|
||||
"Do not produce reasoning-dominant output or long meta commentary.",
|
||||
"When context is partial, deliver the best safe draft first and clearly note assumptions in brief form.",
|
||||
];
|
||||
|
||||
if (definition.id === "campaign-distributor") {
|
||||
rules.push(
|
||||
"For Campaign Distributor steps, output channel-ready publishable copy first, then short format/assumption notes.",
|
||||
);
|
||||
}
|
||||
|
||||
return `deliverable-first rules:\n- ${rules.join("\n- ")}`;
|
||||
}
|
||||
|
||||
export function buildExecuteMessages(input: {
|
||||
definition: AgentDefinition;
|
||||
locale: AgentLocale;
|
||||
@@ -235,6 +253,7 @@ export function buildExecuteMessages(input: {
|
||||
"Use the following compiled prompt segments:",
|
||||
formatPromptSegments(segments),
|
||||
`execution rules:\n- ${input.definition.executionRules.join("\n- ")}`,
|
||||
formatDeliverableFirstInstructions(input.definition),
|
||||
"Return one output payload per execution step keyed by step id.",
|
||||
].join("\n\n"),
|
||||
},
|
||||
|
||||
@@ -26,16 +26,23 @@ export type AgentStructuredOutput = {
|
||||
previewText: string;
|
||||
sections: AgentOutputSection[];
|
||||
metadata: Record<string, string | string[]>;
|
||||
metadataLabels: Record<string, string>;
|
||||
qualityChecks: string[];
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type AgentStructuredMetadataEntry = {
|
||||
key: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type AgentStructuredOutputDraft = Partial<
|
||||
AgentStructuredOutput & {
|
||||
sections: Array<Partial<AgentOutputSection> | null>;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
Omit<AgentStructuredOutput, "sections" | "metadata">
|
||||
> & {
|
||||
sections?: unknown[];
|
||||
metadata?: Record<string, unknown>;
|
||||
metadataEntries?: unknown[];
|
||||
};
|
||||
|
||||
export type AgentExecutionStep = {
|
||||
id: string;
|
||||
@@ -178,6 +185,93 @@ function normalizeStructuredMetadata(raw: unknown): Record<string, string | stri
|
||||
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 {
|
||||
return sections[0]?.content ?? "";
|
||||
}
|
||||
@@ -365,7 +459,12 @@ export function normalizeAgentStructuredOutput(
|
||||
trimString(draft.artifactType) || trimString(fallback.artifactType) || SAFE_FALLBACK_OUTPUT_TYPE;
|
||||
const sections = normalizeOutputSections(draft.sections);
|
||||
const previewText = trimString(draft.previewText) || derivePreviewTextFromSections(sections);
|
||||
const metadata = normalizeStructuredMetadata(draft.metadata);
|
||||
const metadataFromEntries = normalizeStructuredMetadataEntries(draft.metadataEntries);
|
||||
const rawMetadata =
|
||||
Object.keys(metadataFromEntries).length > 0
|
||||
? metadataFromEntries
|
||||
: normalizeStructuredMetadata(draft.metadata);
|
||||
const { metadata, metadataLabels } = sanitizeStructuredMetadata(rawMetadata);
|
||||
const qualityChecks = normalizeStringArray(draft.qualityChecks);
|
||||
const body =
|
||||
trimString(draft.body) ||
|
||||
@@ -382,6 +481,7 @@ export function normalizeAgentStructuredOutput(
|
||||
previewText,
|
||||
sections,
|
||||
metadata,
|
||||
metadataLabels,
|
||||
qualityChecks,
|
||||
body,
|
||||
};
|
||||
|
||||
@@ -50,6 +50,23 @@ const AGENT_ALLOWED_SOURCE_TYPES = new Set<string>([
|
||||
"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"]);
|
||||
|
||||
export type CanvasConnectionValidationReason =
|
||||
@@ -66,14 +83,52 @@ export type CanvasConnectionValidationReason =
|
||||
| "adjustment-target-forbidden"
|
||||
| "render-source-invalid"
|
||||
| "agent-source-invalid"
|
||||
| "agent-output-source-invalid";
|
||||
| "agent-output-source-invalid"
|
||||
| "mixer-source-invalid"
|
||||
| "mixer-target-handle-invalid"
|
||||
| "mixer-handle-incoming-limit"
|
||||
| "mixer-incoming-limit";
|
||||
|
||||
export function validateCanvasConnectionPolicy(args: {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
targetIncomingCount: number;
|
||||
targetHandle?: string | null;
|
||||
targetIncomingHandles?: Array<string | null | undefined>;
|
||||
}): CanvasConnectionValidationReason | null {
|
||||
const { sourceType, targetType, targetIncomingCount } = args;
|
||||
const {
|
||||
sourceType,
|
||||
targetType,
|
||||
targetIncomingCount,
|
||||
targetHandle,
|
||||
targetIncomingHandles,
|
||||
} = args;
|
||||
|
||||
if (targetType === "mixer") {
|
||||
if (!MIXER_ALLOWED_SOURCE_TYPES.has(sourceType)) {
|
||||
return "mixer-source-invalid";
|
||||
}
|
||||
|
||||
const normalizedTargetHandle = normalizeMixerHandle(targetHandle);
|
||||
if (!MIXER_TARGET_HANDLES.has(normalizedTargetHandle)) {
|
||||
return "mixer-target-handle-invalid";
|
||||
}
|
||||
|
||||
if (targetIncomingCount >= 2) {
|
||||
return "mixer-incoming-limit";
|
||||
}
|
||||
|
||||
const normalizedIncomingHandles = (targetIncomingHandles ?? []).map((handle) =>
|
||||
normalizeMixerHandle(handle),
|
||||
);
|
||||
const incomingOnHandle = normalizedIncomingHandles.filter(
|
||||
(handle) => handle === normalizedTargetHandle,
|
||||
).length;
|
||||
|
||||
if (incomingOnHandle >= 1) {
|
||||
return "mixer-handle-incoming-limit";
|
||||
}
|
||||
}
|
||||
|
||||
if (targetType === "agent-output" && sourceType !== "agent") {
|
||||
return "agent-output-source-invalid";
|
||||
@@ -159,6 +214,14 @@ export function getCanvasConnectionValidationMessage(
|
||||
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
|
||||
case "agent-output-source-invalid":
|
||||
return "Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.";
|
||||
case "mixer-source-invalid":
|
||||
return "Mixer akzeptiert nur Bild-, Asset-, KI-Bild- oder Render-Input.";
|
||||
case "mixer-target-handle-invalid":
|
||||
return "Mixer akzeptiert nur die Ziel-Handles 'base' und 'overlay'.";
|
||||
case "mixer-handle-incoming-limit":
|
||||
return "Jeder Mixer-Handle akzeptiert nur eine eingehende Verbindung.";
|
||||
case "mixer-incoming-limit":
|
||||
return "Mixer-Nodes erlauben maximal zwei eingehende Verbindungen.";
|
||||
default:
|
||||
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
|
||||
}
|
||||
|
||||
216
lib/canvas-mixer-preview.ts
Normal file
216
lib/canvas-mixer-preview.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
export type NodeCategoryId =
|
||||
| "source"
|
||||
| "ai-output"
|
||||
| "agents"
|
||||
| "transform"
|
||||
| "image-edit"
|
||||
| "control"
|
||||
@@ -20,10 +21,11 @@ export const NODE_CATEGORY_META: Record<
|
||||
> = {
|
||||
source: { label: "Quelle", order: 0 },
|
||||
"ai-output": { label: "KI-Ausgabe", order: 1 },
|
||||
transform: { label: "Transformation", order: 2 },
|
||||
"image-edit": { label: "Bildbearbeitung", order: 3 },
|
||||
control: { label: "Steuerung & Flow", order: 4 },
|
||||
layout: { label: "Canvas & Layout", order: 5 },
|
||||
agents: { label: "Agents", order: 2 },
|
||||
transform: { label: "Transformation", order: 3 },
|
||||
"image-edit": { label: "Bildbearbeitung", order: 4 },
|
||||
control: { label: "Steuerung & Flow", order: 5 },
|
||||
layout: { label: "Canvas & Layout", order: 6 },
|
||||
};
|
||||
|
||||
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
|
||||
@@ -85,6 +87,14 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
||||
category: "source",
|
||||
phase: 2,
|
||||
}),
|
||||
entry({
|
||||
type: "ai-video",
|
||||
label: "KI-Video-Ausgabe",
|
||||
category: "source",
|
||||
phase: 2,
|
||||
systemOutput: true,
|
||||
disabledHint: "Wird von der KI erzeugt",
|
||||
}),
|
||||
entry({
|
||||
type: "asset",
|
||||
label: "Asset (Stock)",
|
||||
@@ -112,18 +122,17 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
||||
systemOutput: true,
|
||||
disabledHint: "Wird von der KI erzeugt",
|
||||
}),
|
||||
// Agents
|
||||
entry({
|
||||
type: "ai-video",
|
||||
label: "KI-Video-Ausgabe",
|
||||
category: "ai-output",
|
||||
type: "agent",
|
||||
label: "Campaign Orchestrator",
|
||||
category: "agents",
|
||||
phase: 2,
|
||||
systemOutput: true,
|
||||
disabledHint: "Wird von der KI erzeugt",
|
||||
}),
|
||||
entry({
|
||||
type: "agent-output",
|
||||
label: "Agent-Ausgabe",
|
||||
category: "ai-output",
|
||||
category: "agents",
|
||||
phase: 2,
|
||||
implemented: true,
|
||||
systemOutput: true,
|
||||
@@ -216,19 +225,11 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
||||
implemented: false,
|
||||
disabledHint: "Folgt in Phase 2",
|
||||
}),
|
||||
entry({
|
||||
type: "agent",
|
||||
label: "Agent",
|
||||
category: "control",
|
||||
phase: 2,
|
||||
}),
|
||||
entry({
|
||||
type: "mixer",
|
||||
label: "Mixer / Merge",
|
||||
category: "control",
|
||||
phase: 3,
|
||||
implemented: false,
|
||||
disabledHint: "Folgt in Phase 3",
|
||||
phase: 1,
|
||||
}),
|
||||
entry({
|
||||
type: "switch",
|
||||
|
||||
@@ -43,6 +43,18 @@ export const CANVAS_NODE_TEMPLATES = [
|
||||
templateId: "campaign-distributor",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "mixer",
|
||||
label: "Mixer / Merge",
|
||||
width: 360,
|
||||
height: 320,
|
||||
defaultData: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "note",
|
||||
label: "Notiz",
|
||||
|
||||
@@ -72,6 +72,7 @@ export type CanvasSyncOpPayloadByType = {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
clientRequestId: string;
|
||||
};
|
||||
removeEdge: {
|
||||
@@ -477,6 +478,10 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
typeof payload.targetHandle === "string"
|
||||
? payload.targetHandle
|
||||
: undefined,
|
||||
edgeIdToIgnore:
|
||||
typeof payload.edgeIdToIgnore === "string"
|
||||
? (payload.edgeIdToIgnore as Id<"edges">)
|
||||
: undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
},
|
||||
enqueuedAt,
|
||||
|
||||
@@ -231,6 +231,7 @@ export const NODE_HANDLE_MAP: Record<
|
||||
crop: { source: undefined, target: undefined },
|
||||
render: { source: undefined, target: undefined },
|
||||
agent: { target: "agent-in" },
|
||||
mixer: { source: "mixer-out", target: "base" },
|
||||
"agent-output": { target: "agent-output-in" },
|
||||
};
|
||||
|
||||
@@ -292,6 +293,16 @@ export const NODE_DEFAULTS: Record<
|
||||
outputNodeIds: [],
|
||||
},
|
||||
},
|
||||
mixer: {
|
||||
width: 360,
|
||||
height: 320,
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
},
|
||||
"agent-output": {
|
||||
width: 360,
|
||||
height: 260,
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"sectionsLabel": "Abschnitte",
|
||||
"metadataLabel": "Metadaten",
|
||||
"qualityChecksLabel": "Qualitaetschecks",
|
||||
"detailsLabel": "Details",
|
||||
"previewLabel": "Vorschau",
|
||||
"previewFallback": "Keine Vorschau verfuegbar",
|
||||
"emptyValue": "-",
|
||||
@@ -275,6 +276,46 @@
|
||||
"openrouterDataUriCreationFailed": "OpenRouter: Bild konnte nicht verarbeitet werden.",
|
||||
"openrouterDataUriMissingBase64": "OpenRouter: Bild konnte nicht verarbeitet werden."
|
||||
},
|
||||
"mediaLibrary": {
|
||||
"common": {
|
||||
"untitledImage": "Unbenanntes Bild",
|
||||
"untitledVideo": "Unbenanntes Video",
|
||||
"untitledAsset": "Unbenanntes Asset",
|
||||
"unknownSize": "Groesse unbekannt",
|
||||
"videoFile": "Videodatei"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Mediathek",
|
||||
"descriptionDefault": "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.",
|
||||
"descriptionImage": "Waehle ein Bild aus deiner LemonSpace-Mediathek.",
|
||||
"descriptionVideo": "Waehle ein Video aus deiner LemonSpace-Mediathek.",
|
||||
"descriptionAsset": "Waehle ein Asset aus deiner LemonSpace-Mediathek.",
|
||||
"urlResolveError": "URLs konnten nicht geladen werden.",
|
||||
"errorTitle": "Mediathek konnte nicht geladen werden",
|
||||
"emptyTitle": "Keine Medien vorhanden",
|
||||
"emptyDescription": "Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.",
|
||||
"previewOnly": "Nur Vorschau",
|
||||
"pick": "Auswaehlen",
|
||||
"pickLoading": "Wird uebernommen...",
|
||||
"previous": "Zurueck",
|
||||
"next": "Weiter",
|
||||
"pageOf": "Seite {page} von {totalPages}"
|
||||
},
|
||||
"dashboard": {
|
||||
"sectionTitle": "Mediathek",
|
||||
"openAll": "Ganze Mediathek oeffnen",
|
||||
"loading": "Mediathek wird geladen...",
|
||||
"previewError": "Mediathek-Vorschau konnte nicht geladen werden. {error}",
|
||||
"empty": "Noch keine Medien vorhanden. Sobald du Bilder hochlaedst oder generierst, werden sie hier angezeigt.",
|
||||
"dialogDescription": "Alle deine Medien aus LemonSpace in einer zentralen Vorschau."
|
||||
},
|
||||
"imageNode": {
|
||||
"openButton": "Aus Mediathek",
|
||||
"preparingButton": "Mediathek wird vorbereitet...",
|
||||
"pickCta": "Uebernehmen",
|
||||
"invalidSelection": "Nur Bilddateien mit Storage-ID koennen uebernommen werden."
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"canvas": {
|
||||
"imageUploaded": "Bild hochgeladen",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"sectionsLabel": "Sections",
|
||||
"metadataLabel": "Metadata",
|
||||
"qualityChecksLabel": "Quality checks",
|
||||
"detailsLabel": "Details",
|
||||
"previewLabel": "Preview",
|
||||
"previewFallback": "No preview available",
|
||||
"emptyValue": "-",
|
||||
@@ -275,6 +276,46 @@
|
||||
"openrouterDataUriCreationFailed": "OpenRouter: Could not process image.",
|
||||
"openrouterDataUriMissingBase64": "OpenRouter: Could not process image."
|
||||
},
|
||||
"mediaLibrary": {
|
||||
"common": {
|
||||
"untitledImage": "Untitled image",
|
||||
"untitledVideo": "Untitled video",
|
||||
"untitledAsset": "Untitled asset",
|
||||
"unknownSize": "Unknown size",
|
||||
"videoFile": "Video file"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Media library",
|
||||
"descriptionDefault": "Browse your media from uploads, AI generation, and archive sources.",
|
||||
"descriptionImage": "Choose an image from your LemonSpace media library.",
|
||||
"descriptionVideo": "Choose a video from your LemonSpace media library.",
|
||||
"descriptionAsset": "Choose an asset from your LemonSpace media library.",
|
||||
"urlResolveError": "Could not load URLs.",
|
||||
"errorTitle": "Media library could not be loaded",
|
||||
"emptyTitle": "No media yet",
|
||||
"emptyDescription": "As soon as you upload or generate media, it will appear here.",
|
||||
"previewOnly": "Preview only",
|
||||
"pick": "Select",
|
||||
"pickLoading": "Applying...",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageOf": "Page {page} of {totalPages}"
|
||||
},
|
||||
"dashboard": {
|
||||
"sectionTitle": "Media library",
|
||||
"openAll": "Open full media library",
|
||||
"loading": "Loading media library...",
|
||||
"previewError": "Could not load media preview. {error}",
|
||||
"empty": "No media yet. As soon as you upload or generate images, they will appear here.",
|
||||
"dialogDescription": "All your LemonSpace media in one central preview."
|
||||
},
|
||||
"imageNode": {
|
||||
"openButton": "From media library",
|
||||
"preparingButton": "Preparing media library...",
|
||||
"pickCta": "Apply",
|
||||
"invalidSelection": "Only image files with a storage ID can be applied."
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"canvas": {
|
||||
"imageUploaded": "Image uploaded",
|
||||
|
||||
@@ -32,6 +32,7 @@ const translations: Record<string, string> = {
|
||||
"agentOutputNode.sectionsLabel": "Sections",
|
||||
"agentOutputNode.metadataLabel": "Metadata",
|
||||
"agentOutputNode.qualityChecksLabel": "Quality checks",
|
||||
"agentOutputNode.detailsLabel": "Details",
|
||||
"agentOutputNode.previewLabel": "Preview",
|
||||
"agentOutputNode.previewFallback": "No preview available",
|
||||
"agentOutputNode.emptyValue": "-",
|
||||
@@ -76,7 +77,7 @@ describe("AgentOutputNode", () => {
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders structured output with artifact meta, sections, metadata, quality checks, and preview fallback", async () => {
|
||||
it("renders structured output with deliverable first and default-collapsed details", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -134,6 +135,65 @@ describe("AgentOutputNode", () => {
|
||||
expect(container.querySelector('[data-testid="agent-output-metadata"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-preview"]')).not.toBeNull();
|
||||
const details = container.querySelector('[data-testid="agent-output-details"]') as
|
||||
| HTMLDetailsElement
|
||||
| null;
|
||||
expect(details).not.toBeNull();
|
||||
expect(details?.open).toBe(false);
|
||||
});
|
||||
|
||||
it("prioritizes social caption sections and moves secondary notes into details", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(AgentOutputNode, {
|
||||
id: "agent-output-caption-pack",
|
||||
selected: false,
|
||||
dragging: false,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
type: "agent-output",
|
||||
data: {
|
||||
title: "Caption Pack",
|
||||
channel: "instagram-feed",
|
||||
artifactType: "social-caption-pack",
|
||||
sections: [
|
||||
{ id: "hook", label: "Hook", content: "Start strong" },
|
||||
{ id: "format", label: "Format note", content: "Best as 4:5" },
|
||||
{ id: "cta", label: "CTA", content: "Save this post" },
|
||||
{ id: "hashtags", label: "Hashtags", content: "#buildinpublic #launch" },
|
||||
{ id: "caption", label: "Caption", content: "Launch day is here" },
|
||||
{ id: "assumptions", label: "Assumptions", content: "Audience is founder-led" },
|
||||
],
|
||||
qualityChecks: ["channel-fit"],
|
||||
} as Record<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 () => {
|
||||
|
||||
@@ -223,4 +223,16 @@ describe("canvas connection policy", () => {
|
||||
getCanvasConnectionValidationMessage("agent-output-source-invalid"),
|
||||
).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.");
|
||||
});
|
||||
|
||||
it("treats legacy mixer handles 'null' and empty string as base occupancy", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "asset",
|
||||
targetType: "mixer",
|
||||
targetIncomingCount: 1,
|
||||
targetHandle: "base",
|
||||
targetIncomingHandles: ["null", ""],
|
||||
}),
|
||||
).toBe("mixer-handle-incoming-limit");
|
||||
});
|
||||
});
|
||||
|
||||
31
tests/canvas-connection-validation.test.ts
Normal file
31
tests/canvas-connection-validation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testables } from "@/convex/agents";
|
||||
import { __testables as openrouterTestables } from "@/convex/openrouter";
|
||||
|
||||
describe("agent orchestration contract helpers", () => {
|
||||
it("builds skeleton output data with rich execution-plan metadata", () => {
|
||||
@@ -59,7 +60,8 @@ describe("agent orchestration contract helpers", () => {
|
||||
{ id: "hook", label: "Hook", content: "Lead with proof." },
|
||||
{ id: "cta", label: "CTA", content: "Invite comments." },
|
||||
],
|
||||
metadata: { audience: "SaaS founders" },
|
||||
metadata: { tonalitaet: "freundlich", audience: "SaaS founders" },
|
||||
metadataLabels: { tonalitaet: "tonalität", audience: "audience" },
|
||||
qualityChecks: [],
|
||||
body: "",
|
||||
},
|
||||
@@ -69,6 +71,7 @@ describe("agent orchestration contract helpers", () => {
|
||||
expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments.");
|
||||
expect(data.previewText).toBe("Lead with proof.");
|
||||
expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]);
|
||||
expect(data.metadataLabels).toEqual({ tonalitaet: "tonalität", audience: "audience" });
|
||||
});
|
||||
|
||||
it("requires rich execution-step fields in analyze schema", () => {
|
||||
@@ -87,6 +90,25 @@ describe("agent orchestration contract helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds provider-safe execute schema without dynamic metadata maps", () => {
|
||||
const schema = __testables.buildExecuteSchema(["step-1"]);
|
||||
const diagnostics = openrouterTestables.getStructuredSchemaDiagnostics({
|
||||
schema,
|
||||
messages: [
|
||||
{ role: "system", content: "system" },
|
||||
{ role: "user", content: "user" },
|
||||
],
|
||||
});
|
||||
|
||||
const stepOne = (((schema.properties as Record<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", () => {
|
||||
const promptSummary = __testables.resolveExecutionPlanSummary({
|
||||
executionPlanSummary: "",
|
||||
|
||||
@@ -60,6 +60,19 @@ describe("ai error helpers", () => {
|
||||
).toBe("Provider: OpenRouter API error 503: Upstream timeout");
|
||||
});
|
||||
|
||||
it("formats structured-output http error with extracted provider details from JSON payload", () => {
|
||||
expect(
|
||||
formatTerminalStatusMessage(
|
||||
new ConvexError({
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: 502,
|
||||
message:
|
||||
'{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
|
||||
}),
|
||||
),
|
||||
).toBe("Provider: OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]");
|
||||
});
|
||||
|
||||
it("formats structured-output http error without falling back to raw code", () => {
|
||||
expect(
|
||||
formatTerminalStatusMessage(
|
||||
|
||||
383
tests/convex/edges-create.test.ts
Normal file
383
tests/convex/edges-create.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter";
|
||||
import {
|
||||
__testables,
|
||||
generateStructuredObjectViaOpenRouter,
|
||||
} from "@/convex/openrouter";
|
||||
|
||||
type MockResponseInit = {
|
||||
ok: boolean;
|
||||
@@ -285,4 +288,65 @@ describe("generateStructuredObjectViaOpenRouter", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts provider details from OpenRouter JSON error payload", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: '{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateStructuredObjectViaOpenRouter("test-api-key", {
|
||||
model: "openai/gpt-5-mini",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
schemaName: "test_schema",
|
||||
schema: { type: "object" },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
data: {
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: 502,
|
||||
providerCode: "provider_error",
|
||||
providerType: "upstream_error",
|
||||
providerMessage: "Provider returned error",
|
||||
message: "OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("detects schema features that are likely to matter for structured-output debugging", () => {
|
||||
const diagnostics = __testables.getStructuredSchemaDiagnostics({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["summary", "metadata"],
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
metadata: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: "system prompt" },
|
||||
{ role: "user", content: "user prompt" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(diagnostics).toMatchObject({
|
||||
topLevelType: "object",
|
||||
topLevelRequiredCount: 2,
|
||||
topLevelPropertyCount: 2,
|
||||
messageCount: 2,
|
||||
messageLengths: [13, 11],
|
||||
hasAnyOf: true,
|
||||
hasDynamicAdditionalProperties: true,
|
||||
hasPatternProperties: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,10 @@ describe("agent prompting helpers", () => {
|
||||
const user = messages[1]?.content ?? "";
|
||||
|
||||
expect(system).toContain("execution rules");
|
||||
expect(system).toContain("deliverable-first rules");
|
||||
expect(system).toContain("Prioritize publishable, user-facing deliverables");
|
||||
expect(system).toContain("Do not produce reasoning-dominant output");
|
||||
expect(system).toContain("For Campaign Distributor steps, output channel-ready publishable copy first");
|
||||
expect(system).toContain("channel-notes");
|
||||
expect(system).toContain("German (de-DE)");
|
||||
expect(user).toContain("Execution plan summary: Ship launch content");
|
||||
|
||||
@@ -47,6 +47,10 @@ describe("normalizeAgentStructuredOutput", () => {
|
||||
language: "en",
|
||||
tags: ["launch", "saas"],
|
||||
},
|
||||
metadataLabels: {
|
||||
language: "language",
|
||||
tags: "tags",
|
||||
},
|
||||
qualityChecks: ["concise", "channel-fit"],
|
||||
body: "Legacy flat content",
|
||||
});
|
||||
@@ -145,4 +149,38 @@ describe("normalizeAgentStructuredOutput", () => {
|
||||
"Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.",
|
||||
);
|
||||
});
|
||||
|
||||
it("slugifies non-ascii metadata keys and preserves original labels", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
sections: [
|
||||
{
|
||||
id: "caption",
|
||||
label: "Caption",
|
||||
content: "Publish-ready caption.",
|
||||
},
|
||||
],
|
||||
metadataEntries: [
|
||||
{ key: "tonalität", values: ["freundlich"] },
|
||||
{ key: "hashtags", values: ["dogs", "berner-sennenhund"] },
|
||||
{ key: "empty", values: [] },
|
||||
{ key: " ", values: ["ignored"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized.metadata).toEqual({
|
||||
tonalitaet: "freundlich",
|
||||
hashtags: ["dogs", "berner-sennenhund"],
|
||||
});
|
||||
expect(normalized.metadataLabels).toEqual({
|
||||
tonalitaet: "tonalität",
|
||||
hashtags: "hashtags",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
import { NODE_CATALOG, isNodePaletteEnabled } from "@/lib/canvas-node-catalog";
|
||||
import {
|
||||
NODE_CATALOG,
|
||||
NODE_CATEGORY_META,
|
||||
catalogEntriesByCategory,
|
||||
isNodePaletteEnabled,
|
||||
} from "@/lib/canvas-node-catalog";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
describe("canvas agent config", () => {
|
||||
@@ -22,6 +27,27 @@ describe("canvas agent config", () => {
|
||||
expect(entry && isNodePaletteEnabled(entry)).toBe(true);
|
||||
});
|
||||
|
||||
it("moves agent nodes into an Agents category", () => {
|
||||
expect(NODE_CATEGORY_META.agents.label).toBe("Agents");
|
||||
|
||||
const byCategory = catalogEntriesByCategory();
|
||||
const agentsEntries = byCategory.get("agents") ?? [];
|
||||
const aiOutputEntries = byCategory.get("ai-output") ?? [];
|
||||
|
||||
expect(agentsEntries.map((entry) => entry.type)).toEqual(["agent", "agent-output"]);
|
||||
expect(agentsEntries[0]).toMatchObject({
|
||||
label: "Campaign Orchestrator",
|
||||
category: "agents",
|
||||
});
|
||||
|
||||
expect(aiOutputEntries.map((entry) => entry.type)).toEqual([
|
||||
"prompt",
|
||||
"video-prompt",
|
||||
"ai-text",
|
||||
]);
|
||||
expect(NODE_CATALOG.find((entry) => entry.type === "ai-video")?.category).toBe("source");
|
||||
});
|
||||
|
||||
it("keeps the agent input-only in MVP", () => {
|
||||
expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in");
|
||||
expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined();
|
||||
|
||||
198
tests/lib/canvas-mixer-preview.test.ts
Normal file
198
tests/lib/canvas-mixer-preview.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
"components/canvas/__tests__/canvas-connection-drop-target.test.tsx",
|
||||
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
|
||||
"components/canvas/__tests__/compare-node.test.tsx",
|
||||
"components/canvas/__tests__/mixer-node.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
||||
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-connections.test.tsx",
|
||||
|
||||
Reference in New Issue
Block a user