diff --git a/components/agents/campaign-distributor.md b/components/agents/campaign-distributor.md new file mode 100644 index 0000000..239737b --- /dev/null +++ b/components/agents/campaign-distributor.md @@ -0,0 +1,189 @@ +--- +name: Campaign Distributor +description: Entwickelt und verteilt LemonSpace-Kampagneninhalte kanalgerecht über Social Media und Messenger. Transformiert Canvas-Outputs in plattformspezifische Posts, Stories, Captions und Nachrichten — mit konsistenter Markenstimme und maximaler Reichweite. +tools: WebFetch, WebSearch, Read, Write, Edit +color: yellow +emoji: 🍋 +vibe: Verwandelt Canvas-Outputs in kampagnenfähige Inhalte für jeden Kanal. +--- + +# Campaign Distributor Agent + +## Rolle + +Spezialist für kanalübergreifende Content-Distribution im LemonSpace-Ökosystem. Der Agent nimmt fertige Canvas-Outputs (KI-Bilder, Varianten, Renders) und transformiert sie in plattformgerechte Inhalte — mit angepasstem Format, Ton und Rhythmus für jeden Kanal. Kein generischer Einheitsbrei, sondern natives Content-Verhalten je Plattform. + +Besonderheit gegenüber generischen Social-Media-Agenten: Der Campaign Distributor kennt den LemonSpace-Canvas-Workflow. Er weiß, wie Bildvarianten entstehen, wie Compare-Nodes zur A/B-Entscheidung genutzt werden, und kann direkt aus einem Canvas-Export heraus Verteilungsvorschläge machen. + +--- + +## Kernfähigkeiten + +- **Canvas-to-Content**: Nimmt Bildvarianten, KI-Outputs und Render-Exports aus LemonSpace und leitet daraus kanalspezifische Content-Pakete ab +- **Kanalstrategie**: Entwickelt Distributionspläne, die Formatanforderungen, Algorithmuslogik und Nutzerverhalten je Plattform berücksichtigen +- **Messenger-Integration**: Plant und formuliert Inhalte für Direct-Messaging-Kanäle (WhatsApp Business, Telegram, Newsletter-E-Mail) — nicht nur Broadcast, sondern dialogorientiert +- **Caption & Copy**: Erstellt plattformgerechte Texte, Hashtag-Sets, CTAs und Alt-Texte für alle Kanäle +- **Posting-Rhythmus**: Empfiehlt Zeitpläne basierend auf Plattformdaten und Zielgruppe +- **Variantensteuerung**: Entscheidet welche Bildvariante auf welchem Kanal ausgespielt wird (basierend auf Format, Aspect Ratio, Zielgruppe) +- **Performance-Hypothesen**: Formuliert A/B-Thesen für Variantenvergleiche, bevor Daten vorliegen + +--- + +## Kanalmatrix + +### Social Media + +| Kanal | Hauptformat | Ton | Besonderheit | +|-------|-------------|-----|--------------| +| Instagram Feed | 1:1, 4:5 | Visuell, knapp | Carousel für Variantenvergleiche nutzen | +| Instagram Stories | 9:16 | Schnell, direkt | Swipe-Up/Link-Sticker, Polls | +| Instagram Reels | 9:16 Video | Unterhaltsam | KI-Prozess als Timelapse/BTS | +| LinkedIn | 1:1, 1200×627 | Professionell, substanziell | Thought Leadership, Produkt-Demos | +| Twitter / X | 16:9, 1:1 | Prägnant, mutig | Threads für Canvas-Workflows | +| TikTok | 9:16 Video | Nativ, lo-fi | Tool-Demos, Before/After | +| Pinterest | 2:3, 9:16 | Inspirierend | Moodboards aus Canvas-Outputs | + +### Messenger & Direct + +| Kanal | Format | Ton | Besonderheit | +|-------|--------|-----|--------------| +| WhatsApp Business | Bild + Text, Status | Persönlich, direkt | Kampagnenstart-Announcement, Exklusiv-Previews | +| Telegram | Bild, Kanal-Post, Bot | Community-nah | Changelog-Posts, Beta-Zugang | +| E-Mail Newsletter | HTML, Text-Fallback | Persönlich, kuratiert | Canvas-Workflow-Tutorials, Produkt-Updates | +| Discord | Embeds, Channels | Community | Creator-Feedback, Feature-Previews | + +--- + +## Canvas-Workflow-Integration + +Der Agent versteht LemonSpace-spezifische Konzepte und kann direkt damit arbeiten: + +- **Bildvarianten aus Compare-Node**: Welche Variante geht auf Instagram, welche auf LinkedIn? Begründung und Empfehlung. +- **KI-Bild-Outputs**: Automatisch Alt-Text, Caption und Hashtags vorschlagen, basierend auf dem verwendeten Prompt. +- **Render-Node-Export**: PNG/WebP-Dateien kanalgerecht benennen, Metadaten vorschlagen. +- **Frame-Dimensionen**: Prüfen, ob Canvas-Frames den Zielkanal-Spezifikationen entsprechen (z.B. 1080×1080 für Instagram Feed). Bei Abweichung: Zuschnitt-Empfehlung. +- **Branching-Stacks**: Verschiedene Adjustment-Varianten (warm vs. cool) gezielt auf verschiedene Plattformen aufteilen. + +--- + +## Spezialisierte Skills + +- Algorithmus-Optimierung je Plattform (Reach vs. Engagement-Logik, Posting-Zeitfenster) +- Hashtag-Recherche und -Clustering (branded, community, discovery) +- Caption-Strukturen: Hook → Body → CTA, angepasst je Plattform +- Messenger-Broadcast-Texte: kurz, handlungsauslösend, mit klarem Mehrwert +- Newsletter-Sequenz-Design für Onboarding und Feature-Announcements +- Before/After-Storytelling mit Canvas-Outputs (Bild-Node → Render-Node) +- Community-Management-Vorlagen für Kommentar-Replies und DMs +- UTM-Parameter-Logik für Attribution je Kanal + +--- + +## Workflow-Integration + +- **Handoff von**: KI-Bild-Node, Render-Node, Compare-Node (Canvas-Exports), Content Creator Agent +- **Kollaboriert mit**: Instagram Curator Agent (Feintuning Reels/Stories), E-Mail-Agent, Analytics Agent +- **Liefert an**: Scheduling-Tool, Kanal-Manager, Analytics Reporter +- **Eskaliert an**: Brand Guardian bei Messaging-Abweichungen, Legal Compliance bei regulierten Themen + +--- + +## Entscheidungsrahmen + +Diesen Agent einsetzen, wenn: +- Canvas-Outputs (Bilder, Varianten, Renders) über mehrere Kanäle verteilt werden sollen +- Kanalspezifische Caption, Hashtags und CTAs benötigt werden +- Variantenentscheidungen (welches Bild auf welchem Kanal) getroffen werden müssen +- Messenger-Kampagnen (WhatsApp, Telegram, Newsletter) geplant werden +- Ein Posting-Kalender für einen Canvas-Projekt-Output erstellt werden soll +- Before/After oder Prozess-Content aus dem Canvas-Workflow entwickelt wird + +--- + +## Erfolgsmetriken + +- **Instagram Engagement Rate**: ≥4% Feed, ≥6% Stories +- **LinkedIn Reichweite**: ≥20% monatliches Wachstum Impressionen +- **Newsletter Open Rate**: ≥35% (Indie/Creator-Segment), ≥25% (SMB) +- **WhatsApp Business**: ≥60% Öffnungsrate, ≥15% Click-Rate auf Links +- **Telegram**: ≥50% Views pro Post im Kanal +- **Follower-Wachstum**: ≥8% monatlich über alle Kanäle +- **Canvas-to-Post-Zykluszeit**: ≤30 Minuten von Export bis distributionsfertigem Content-Paket +- **Variantenperformance-Delta**: A/B-Hypothesen haben ≥70% Trefferrate + +--- + +## Beispiel-Anfragen + +- „Ich habe 6 Bildvarianten aus meinem LemonSpace Canvas exportiert. Welche gehört auf welchen Kanal?" +- „Schreib mir Captions für Instagram, LinkedIn und einen WhatsApp-Status für dieses Produktbild" +- „Entwickle einen 2-Wochen-Distributionsplan für unseren Kampagnen-Launch" +- „Erstelle Telegram-Kanal-Posts für unser LemonSpace Feature-Update" +- „Schreib einen Newsletter für unsere Starter-Nutzer über die neuen Bildbearbeitungs-Nodes" +- „Welche Caption-Struktur funktioniert für Before/After-Posts auf TikTok vs. LinkedIn?" +- „Erstelle ein Hashtag-Set für unsere KI-Kreativ-Workflow-Posts" + +--- + +## Content-Kaskaden-Prinzip + +Jeder Canvas-Output wird maximal verwertet — kein Inhalt wird für nur einen Kanal erstellt: + +``` +Canvas-Export (Render-Node) + → Instagram Feed Post (1:1, kuratierte Caption) + → Instagram Story (9:16 Crop, Swipe-Up) + → LinkedIn Post (1:1, professioneller Kontext) + → Twitter/X Thread (Prozess-Story, mehrere Bilder) + → WhatsApp Status (komprimiert, direkter CTA) + → Newsletter-Sektion (eingebettet mit Kontext) + → Telegram Kanal-Post (Community-Framing) +``` + +Die Cascade nutzt LemonSpace-spezifisch die verschiedenen Adjustment-Stack-Varianten: warme Variante → Instagram/Pinterest, kühle Variante → LinkedIn/Newsletter. + +--- + +## Messenger-Strategie + +### WhatsApp Business +- **Broadcast-Listen**: Segmentiert nach Kundenstatus (Free, Starter, Pro, Max) +- **Status-Updates**: Exklusiv-Previews von Canvas-Outputs vor öffentlichem Release +- **Willkommenssequenz**: Automatisierter Flow nach Sign-Up mit ersten Canvas-Tipps +- **Ton**: Persönlich, knapp, immer mit konkretem Nutzen + +### Telegram +- **Öffentlicher Kanal**: Feature-Announcements, Changelog, Canvas-Tutorials +- **Community-Gruppe**: Creator-Austausch, Feedback, Beta-Testing-Rekrutierung +- **Bot-Integration**: Canvas-Export-Notifications, Credit-Alerts (Phase 2) +- **Ton**: Community-nah, technisch informiert, offen für Diskussion + +### E-Mail Newsletter +- **Segmente**: Neue Nutzer (Onboarding), aktive Creator (Feature-Deep-Dives), Inaktive (Re-Engagement) +- **Kadenz**: Wöchentlich für aktive Nutzer, monatlich für passive Segmente +- **Inhalt**: Canvas-Workflow-Tutorials mit Screenshot-Sequenzen, Modell-Empfehlungen, Credit-Tipps +- **Ton**: Kuratiert, substanziell, respektiert die Zeit des Lesers + +### Discord +- **Channels**: #canvas-showcase, #feedback, #feature-requests, #changelog +- **Engagement**: Creator spotlights mit Canvas-Outputs, monatliche Challenges +- **Ton**: Community-first, technisch offen, Fehler werden transparent kommuniziert + +--- + +## Kommunikationsstil + +- **Direkt**: Keine generischen Plattitüden — spezifische, umsetzbare Empfehlungen +- **Kanalspezifisch**: Schreibt und denkt nativ in der Sprache jedes Kanals +- **Output-orientiert**: Jede Empfehlung endet mit einem konkreten Artefakt (Text, Plan, Zeitplan) +- **LemonSpace-bewusst**: Versteht Canvas-Konzepte (Nodes, Varianten, Render-Exports) und kommuniziert diese als Stärken + +--- + +## Lernmuster + +- **Algorithmus-Updates**: Verfolgt Plattformänderungen bei Reichweite und Engagement-Logik +- **Content-Performance**: Dokumentiert, welche Canvas-Output-Typen auf welchem Kanal performen +- **Messenger-Öffnungsraten**: Lernt optimale Versandzeitpunkte je Segment +- **Kanal-Trends**: Beobachtet, welche Content-Formate gerade Reichweite gewinnen (z.B. Karussell vs. Einzelbild) +- **LemonSpace-ICP-Verhalten**: Passt Strategie an das Verhalten kleiner Design- und Marketing-Teams an \ No newline at end of file diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index aa363e8..c1611c6 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -53,7 +53,7 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert: | **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte | | **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` | Kontrollfluss-Elemente | +| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `agent` | Kontrollfluss-Elemente | | **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente | ### Node-Typen im Detail @@ -68,6 +68,7 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert: | `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` (input-only MVP) | | `agent-output` | 3 | 🔲 | ai-output | systemOutput: true | | `crop` | 2 | 🔲 | transform | 🔲 | | `bg-remove` | 2 | 🔲 | transform | 🔲 | @@ -115,6 +116,7 @@ Zweistufiger Node-Flow analog `prompt → ai-image`: image: 280 × 200 prompt: 288 × 220 text: 256 × 120 ai-image: 320 × 408 video-prompt: 288 × 220 ai-video: 360 × 280 +agent: 360 × 320 group: 400 × 300 frame: 400 × 300 note: 208 × 100 compare: 500 × 380 ``` @@ -213,6 +215,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent | `ai-image-node.tsx` | KI-Bild-Output-Node mit Bildvorschau, Metadaten, Retry | | `video-prompt-node.tsx` | KI-Video-Steuer-Node mit Modell-/Dauer-Selector, Credit-Anzeige, Generate-Button | | `ai-video-node.tsx` | KI-Video-Output-Node mit Video-Player, Metadaten, Retry-Button | +| `agent-node.tsx` | Statischer Agent-Input-Node (Campaign Distributor) mit Kanal-/Input-/Output-Metadaten | --- @@ -278,5 +281,6 @@ 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. +- **Agent-MVP:** `agent` ist aktuell input-only (`agent-in`), ohne ausgehenden Handle. Er akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`), keine Prompt-Steuerknoten. - **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren. - **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`. diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index 251af14..da0fefd 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -5,6 +5,8 @@ import { Frame, Focus, GitCompare, + Crop as CropIcon, + Bot, ImageDown, Image, Package, @@ -28,12 +30,14 @@ const NODE_ICONS: Record = { text: Type, prompt: Sparkles, "video-prompt": Video, + agent: Bot, note: StickyNote, frame: Frame, compare: GitCompare, group: FolderOpen, asset: Package, video: Video, + crop: CropIcon, curves: Sparkles, "color-adjust": Palette, "light-adjust": Sun, @@ -48,12 +52,14 @@ const NODE_SEARCH_KEYWORDS: Partial< text: ["text", "typo"], prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"], "video-prompt": ["video", "ai", "ki-video", "ki", "prompt"], + agent: ["agent", "campaign", "distribution", "social"], note: ["note", "sticky", "notiz"], frame: ["frame", "artboard"], compare: ["compare", "before", "after", "vergleich"], group: ["group", "gruppe", "folder"], asset: ["asset", "freepik", "stock"], video: ["video", "pexels", "clip"], + crop: ["crop", "resize", "ratio"], curves: ["curves", "tone", "contrast"], "color-adjust": ["color", "hue", "saturation"], "light-adjust": ["light", "exposure", "brightness"], diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts index aa34a12..36e5432 100644 --- a/components/canvas/node-types.ts +++ b/components/canvas/node-types.ts @@ -16,6 +16,7 @@ import LightAdjustNode from "./nodes/light-adjust-node"; import DetailAdjustNode from "./nodes/detail-adjust-node"; import RenderNode from "./nodes/render-node"; import CropNode from "./nodes/crop-node"; +import AgentNode from "./nodes/agent-node"; /** * Node-Type-Map für React Flow. @@ -43,4 +44,5 @@ export const nodeTypes = { "detail-adjust": DetailAdjustNode, crop: CropNode, render: RenderNode, + agent: AgentNode, } as const; diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx index f0c3d42..1dd4d0b 100644 --- a/components/canvas/nodes/adjustment-preview.tsx +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -12,7 +12,7 @@ import { import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot"; -const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]); +const PREVIEW_PIPELINE_TYPES = new Set(["crop", "curves", "color-adjust", "light-adjust", "detail-adjust"]); export default function AdjustmentPreview({ nodeId, diff --git a/components/canvas/nodes/agent-node.tsx b/components/canvas/nodes/agent-node.tsx new file mode 100644 index 0000000..cda0113 --- /dev/null +++ b/components/canvas/nodes/agent-node.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Bot } from "lucide-react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import { getAgentTemplate } from "@/lib/agent-templates"; +import BaseNodeWrapper from "./base-node-wrapper"; + +type AgentNodeData = { + templateId?: string; + canvasId?: string; + _status?: string; + _statusMessage?: string; +}; + +type AgentNodeType = Node; + +const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor"; + +function CompactList({ items }: { items: readonly string[] }) { + return ( + + ); +} + +export default function AgentNode({ data, selected }: NodeProps) { + const nodeData = data as AgentNodeData; + const template = + getAgentTemplate(nodeData.templateId ?? DEFAULT_AGENT_TEMPLATE_ID) ?? + getAgentTemplate(DEFAULT_AGENT_TEMPLATE_ID); + + if (!template) { + return null; + } + + return ( + + + +
+
+
+ + {template.emoji} + {template.name} +
+

{template.description}

+
+ +
+

+ Channels +

+ +
+ +
+

+ Expected Inputs +

+ +
+ +
+

+ Expected Outputs +

+ +
+
+
+ ); +} diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 5ecc08f..91e6f15 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -45,6 +45,7 @@ const RESIZE_CONFIGS: Record = { "detail-adjust": { minWidth: 300, minHeight: 820 }, crop: { minWidth: 320, minHeight: 520 }, render: { minWidth: 260, minHeight: 300, keepAspectRatio: true }, + agent: { minWidth: 300, minHeight: 280 }, text: { minWidth: 220, minHeight: 90 }, note: { minWidth: 200, minHeight: 90 }, }; diff --git a/lib/CLAUDE.md b/lib/CLAUDE.md index 6f5f9c5..fb0a02c 100644 --- a/lib/CLAUDE.md +++ b/lib/CLAUDE.md @@ -13,6 +13,7 @@ Geteilte Hilfsfunktionen, Typ-Definitionen und Konfiguration. Keine React-Kompon | `canvas-node-types.ts` | TypeScript-Typen und Union-Typen für Canvas-Nodes | | `canvas-node-templates.ts` | Default-Daten für neue Nodes (beim Einfügen aus Palette) | | `canvas-connection-policy.ts` | Validierungsregeln für Edge-Verbindungen zwischen Nodes | +| `agent-templates.ts` | Typsichere Agent-Registry für statische Agent-Node-Metadaten | | `ai-models.ts` | Client-seitige Bild-Modell-Definitionen (muss mit `convex/openrouter.ts` in sync bleiben) | | `ai-video-models.ts` | Video-Modell-Registry: 5 MVP-Modelle mit Endpunkten, Credit-Kosten, Tier-Zugang | | `video-poll-logging.ts` | Log-Volumen-Steuerung für Video-Polling (vermeidet excessive Konsolenausgabe) | @@ -48,6 +49,7 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in ` - `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`) - `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`) - `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`) +- `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`) - `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung - `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen @@ -112,6 +114,7 @@ Default-Initial-Daten für neue Nodes beim Einfügen aus Palette. - Erstellt durch die Node-Katalog-Einträge - Enthält default-Werte für `data`-Felder - `video-prompt` hat Default-Daten: `{ modelId: "wan-2-2-720p", durationSeconds: 5 }` +- `agent` hat aktuell ein statisches Template-Default: `{ templateId: "campaign-distributor" }` - Wird von `canvas.tsx` verwendet beim Node-Create --- @@ -134,6 +137,7 @@ Regeln für erlaubte Verbindungen zwischen Node-Typen. - `ai-video` als Target akzeptiert nur `video-prompt` als Source (`ai-video-source-invalid`) - `video-prompt` als Source akzeptiert nur `ai-video` als Target (`video-prompt-target-invalid`) - `text → video-prompt` ✅ (Prompt-Quelle, über default-Handles) +- **Agent-MVP:** `agent` akzeptiert nur Content-/Kontext-Quellen (`agent-source-invalid` bei Prompt/Steuerknoten), ohne eingehendes Kantenlimit - Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges --- diff --git a/lib/agent-templates.ts b/lib/agent-templates.ts new file mode 100644 index 0000000..eec0aa3 --- /dev/null +++ b/lib/agent-templates.ts @@ -0,0 +1,69 @@ +export type AgentTemplateId = "campaign-distributor"; + +export type AgentTemplate = { + id: AgentTemplateId; + name: string; + description: string; + emoji: string; + color: string; + vibe: string; + tools: readonly string[]; + channels: readonly string[]; + expectedInputs: readonly string[]; + expectedOutputs: readonly string[]; + notes: readonly string[]; +}; + +export const AGENT_TEMPLATES: readonly AgentTemplate[] = [ + { + id: "campaign-distributor", + name: "Campaign Distributor", + description: + "Develops and distributes LemonSpace campaign content across social media and messenger channels.", + emoji: "lemon", + color: "yellow", + vibe: "Transforms canvas outputs into campaign-ready channel content.", + tools: ["WebFetch", "WebSearch", "Read", "Write", "Edit"], + channels: [ + "Instagram Feed", + "Instagram Stories", + "Instagram Reels", + "LinkedIn", + "Twitter / X", + "TikTok", + "Pinterest", + "WhatsApp Business", + "Telegram", + "E-Mail Newsletter", + "Discord", + ], + expectedInputs: [ + "Render-Node-Export", + "Compare-Varianten", + "KI-Bild-Output", + "Frame-Dimensionen", + ], + expectedOutputs: [ + "Caption-Pakete", + "Kanal-Matrix", + "Posting-Plan", + "Hashtag-Sets", + "Messenger-Texte", + ], + notes: [ + "MVP: static input-only node, no execution flow.", + "agent-output remains pending until runtime orchestration exists.", + ], + }, +] as const; + +const AGENT_TEMPLATE_BY_ID = new Map( + AGENT_TEMPLATES.map((template) => [template.id, template]), +); + +export function getAgentTemplate(id: string): AgentTemplate | undefined { + if (id === "campaign-distributor") { + return AGENT_TEMPLATE_BY_ID.get(id); + } + return undefined; +} diff --git a/lib/canvas-connection-policy.ts b/lib/canvas-connection-policy.ts index 9f370f2..4cb51bb 100644 --- a/lib/canvas-connection-policy.ts +++ b/lib/canvas-connection-policy.ts @@ -37,6 +37,19 @@ const RENDER_ALLOWED_SOURCE_TYPES = new Set([ "detail-adjust", ]); +const AGENT_ALLOWED_SOURCE_TYPES = new Set([ + "image", + "asset", + "video", + "text", + "note", + "frame", + "compare", + "render", + "ai-image", + "ai-video", +]); + const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set(["prompt", "ai-image"]); export type CanvasConnectionValidationReason = @@ -51,7 +64,8 @@ export type CanvasConnectionValidationReason = | "crop-incoming-limit" | "compare-incoming-limit" | "adjustment-target-forbidden" - | "render-source-invalid"; + | "render-source-invalid" + | "agent-source-invalid"; export function validateCanvasConnectionPolicy(args: { sourceType: string; @@ -81,6 +95,10 @@ export function validateCanvasConnectionPolicy(args: { } } + if (targetType === "agent" && !AGENT_ALLOWED_SOURCE_TYPES.has(sourceType)) { + return "agent-source-invalid"; + } + if (isAdjustmentNodeType(targetType) && targetType !== "render") { if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) { return "adjustment-source-invalid"; @@ -132,6 +150,8 @@ export function getCanvasConnectionValidationMessage( return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden."; case "render-source-invalid": return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Crop- oder Adjustment-Input."; + case "agent-source-invalid": + return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt."; default: return "Verbindung ist fuer diese Node-Typen nicht erlaubt."; } diff --git a/lib/canvas-node-catalog.ts b/lib/canvas-node-catalog.ts index a4661a0..b7c2cb5 100644 --- a/lib/canvas-node-catalog.ts +++ b/lib/canvas-node-catalog.ts @@ -220,8 +220,6 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [ label: "Agent", category: "control", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), entry({ type: "mixer", diff --git a/lib/canvas-node-templates.ts b/lib/canvas-node-templates.ts index efb982d..819cf1b 100644 --- a/lib/canvas-node-templates.ts +++ b/lib/canvas-node-templates.ts @@ -34,6 +34,15 @@ export const CANVAS_NODE_TEMPLATES = [ hasAudio: false, }, }, + { + type: "agent", + label: "Campaign Distributor", + width: 360, + height: 320, + defaultData: { + templateId: "campaign-distributor", + }, + }, { type: "note", label: "Notiz", diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index f3770f1..a7ac1db 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -120,6 +120,7 @@ const SOURCE_NODE_GLOW_RGB: Record = "detail-adjust": [99, 102, 241], crop: [139, 92, 246], render: [14, 165, 233], + agent: [245, 158, 11], }; /** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */ @@ -227,6 +228,7 @@ export const NODE_HANDLE_MAP: Record< "detail-adjust": { source: undefined, target: undefined }, crop: { source: undefined, target: undefined }, render: { source: undefined, target: undefined }, + agent: { target: "agent-in" }, }; /** @@ -276,6 +278,11 @@ export const NODE_DEFAULTS: Record< height: 420, data: { outputResolution: "original", format: "png", jpegQuality: 90 }, }, + agent: { + width: 360, + height: 320, + data: { templateId: "campaign-distributor" }, + }, }; type MediaNodeKind = "asset" | "image"; diff --git a/tests/adjustment-preview.test.ts b/tests/adjustment-preview.test.ts new file mode 100644 index 0000000..e4358f3 --- /dev/null +++ b/tests/adjustment-preview.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildGraphSnapshot, type CanvasGraphSnapshot } from "@/lib/canvas-render-preview"; +import { DEFAULT_CURVES_DATA } from "@/lib/image-pipeline/adjustment-types"; + +const pipelinePreviewMock = vi.fn(); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let currentGraph: (CanvasGraphSnapshot & { previewNodeDataOverrides: Map }) | null = null; + +vi.mock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => { + if (!currentGraph) { + throw new Error("Graph not configured for test"); + } + + return currentGraph; + }, +})); + +vi.mock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: (options: unknown) => { + pipelinePreviewMock(options); + return { + canvasRef: { current: null }, + histogram: { + rgb: Array.from({ length: 256 }, () => 0), + red: Array.from({ length: 256 }, () => 0), + green: Array.from({ length: 256 }, () => 0), + blue: Array.from({ length: 256 }, () => 0), + max: 0, + }, + isRendering: false, + hasSource: true, + previewAspectRatio: 1, + error: null, + }; + }, +})); + +import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; + +describe("AdjustmentPreview", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + pipelinePreviewMock.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + + container?.remove(); + container = null; + root = null; + currentGraph = null; + }); + + it("includes upstream crop steps for adjustment previews", async () => { + currentGraph = { + ...buildGraphSnapshot( + [ + { + id: "image-1", + type: "image", + data: { url: "https://cdn.example.com/source.png" }, + }, + { + id: "crop-1", + type: "crop", + data: { + crop: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 }, + resize: { mode: "source", fit: "cover", keepAspect: true }, + }, + }, + { + id: "curves-1", + type: "curves", + data: DEFAULT_CURVES_DATA, + }, + ], + [ + { source: "image-1", target: "crop-1" }, + { source: "crop-1", target: "curves-1" }, + ], + ), + previewNodeDataOverrides: new Map(), + }; + + const currentParams = { + ...DEFAULT_CURVES_DATA, + levels: { + ...DEFAULT_CURVES_DATA.levels, + gamma: 1.4, + }, + }; + + await act(async () => { + root?.render( + React.createElement(AdjustmentPreview, { + nodeId: "curves-1", + nodeWidth: 320, + currentType: "curves", + currentParams, + }), + ); + }); + + expect(pipelinePreviewMock).toHaveBeenCalledTimes(1); + expect(pipelinePreviewMock.mock.calls[0]?.[0]).toMatchObject({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "crop-1", + type: "crop", + params: { + crop: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 }, + resize: { mode: "source", fit: "cover", keepAspect: true }, + }, + }, + { + nodeId: "curves-1", + type: "curves", + params: currentParams, + }, + ], + }); + }); +}); diff --git a/tests/agent-node.test.ts b/tests/agent-node.test.ts new file mode 100644 index 0000000..50893f4 --- /dev/null +++ b/tests/agent-node.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const handleCalls: Array<{ type: string; id?: string }> = []; + +vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children), +})); + +vi.mock("@xyflow/react", () => ({ + Handle: ({ type, id }: { type: string; id?: string }) => { + handleCalls.push({ type, id }); + return React.createElement("div", { + "data-handle-type": type, + "data-handle-id": id, + }); + }, + Position: { Left: "left", Right: "right" }, +})); + +import AgentNode from "@/components/canvas/nodes/agent-node"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("AgentNode", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + handleCalls.length = 0; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + it("renders campaign distributor metadata and input-only handle", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(AgentNode, { + id: "agent-1", + selected: false, + dragging: false, + draggable: true, + selectable: true, + deletable: true, + zIndex: 1, + isConnectable: true, + type: "agent", + data: { + templateId: "campaign-distributor", + _status: "idle", + } as Record, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }), + ); + }); + + expect(container.textContent).toContain("Campaign Distributor"); + expect(container.textContent).toContain("Instagram Feed"); + expect(container.textContent).toContain("Caption-Pakete"); + expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1); + expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(0); + }); + + it("falls back to the default template when templateId is missing", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(AgentNode, { + id: "agent-2", + selected: true, + dragging: false, + draggable: true, + selectable: true, + deletable: true, + zIndex: 1, + isConnectable: true, + type: "agent", + data: { + _status: "done", + } as Record, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }), + ); + }); + + expect(container.textContent).toContain("Campaign Distributor"); + }); +}); diff --git a/tests/canvas-connection-policy.test.ts b/tests/canvas-connection-policy.test.ts index 699bc64..00f0067 100644 --- a/tests/canvas-connection-policy.test.ts +++ b/tests/canvas-connection-policy.test.ts @@ -159,4 +159,42 @@ describe("canvas connection policy", () => { getCanvasConnectionValidationMessage("ai-video-source-invalid"), ).toBe("KI-Video-Ausgabe akzeptiert nur Eingaben von KI-Video."); }); + + it("allows render to agent", () => { + expect( + validateCanvasConnectionPolicy({ + sourceType: "render", + targetType: "agent", + targetIncomingCount: 0, + }), + ).toBeNull(); + }); + + it("allows compare to agent", () => { + expect( + validateCanvasConnectionPolicy({ + sourceType: "compare", + targetType: "agent", + targetIncomingCount: 0, + }), + ).toBeNull(); + }); + + it("blocks prompt to agent", () => { + expect( + validateCanvasConnectionPolicy({ + sourceType: "prompt", + targetType: "agent", + targetIncomingCount: 0, + }), + ).toBe("agent-source-invalid"); + }); + + it("describes invalid agent source message", () => { + expect( + getCanvasConnectionValidationMessage("agent-source-invalid"), + ).toBe( + "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.", + ); + }); }); diff --git a/tests/lib/agent-templates.test.ts b/tests/lib/agent-templates.test.ts new file mode 100644 index 0000000..536bbb2 --- /dev/null +++ b/tests/lib/agent-templates.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { AGENT_TEMPLATES, getAgentTemplate } from "@/lib/agent-templates"; + +describe("agent templates", () => { + it("registers the campaign distributor template", () => { + expect(AGENT_TEMPLATES.map((template) => template.id)).toEqual([ + "campaign-distributor", + ]); + }); + + it("exposes normalized metadata needed by the canvas node", () => { + const template = getAgentTemplate("campaign-distributor"); + + expect(template?.name).toBe("Campaign Distributor"); + expect(template?.color).toBe("yellow"); + expect(template?.tools).toContain("WebFetch"); + expect(template?.channels).toContain("Instagram Feed"); + expect(template?.expectedInputs).toContain("Render-Node-Export"); + expect(template?.expectedOutputs).toContain("Caption-Pakete"); + }); +}); diff --git a/tests/lib/canvas-agent-config.test.ts b/tests/lib/canvas-agent-config.test.ts new file mode 100644 index 0000000..d6ba8bf --- /dev/null +++ b/tests/lib/canvas-agent-config.test.ts @@ -0,0 +1,32 @@ +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_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; + +describe("canvas agent config", () => { + it("registers the agent node type", () => { + expect(nodeTypes.agent).toBeTypeOf("function"); + }); + + it("adds a campaign distributor palette template", () => { + expect(CANVAS_NODE_TEMPLATES.find((template) => template.type === "agent")?.label).toBe( + "Campaign Distributor", + ); + }); + + it("enables the agent in the catalog", () => { + const entry = NODE_CATALOG.find((item) => item.type === "agent"); + expect(entry).toBeDefined(); + expect(entry && isNodePaletteEnabled(entry)).toBe(true); + }); + + it("keeps the agent input-only in MVP", () => { + expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in"); + expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined(); + expect(NODE_DEFAULTS.agent?.data).toMatchObject({ + templateId: "campaign-distributor", + }); + }); +});