Implement agent node functionality in canvas, including connection policies and UI updates. Add support for agent node type in node catalog, templates, and connection validation. Update documentation to reflect new agent capabilities and ensure proper handling of input sources. Enhance adjustment preview to include crop node. Add tests for agent connection policies.
This commit is contained in:
189
components/agents/campaign-distributor.md
Normal file
189
components/agents/campaign-distributor.md
Normal file
@@ -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
|
||||||
@@ -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 |
|
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte |
|
||||||
| **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen |
|
| **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen |
|
||||||
| **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments |
|
| **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments |
|
||||||
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch` | Kontrollfluss-Elemente |
|
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `agent` | Kontrollfluss-Elemente |
|
||||||
| **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente |
|
| **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente |
|
||||||
|
|
||||||
### Node-Typen im Detail
|
### Node-Typen im Detail
|
||||||
@@ -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` |
|
| `video-prompt` | 2 | ✅ | ai-output | source: `video-prompt-out`, target: `video-prompt-in` |
|
||||||
| `ai-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` |
|
| `ai-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` |
|
||||||
| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` |
|
| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` |
|
||||||
|
| `agent` | 2 | ✅ | control | target: `agent-in` (input-only MVP) |
|
||||||
| `agent-output` | 3 | 🔲 | ai-output | systemOutput: true |
|
| `agent-output` | 3 | 🔲 | ai-output | systemOutput: true |
|
||||||
| `crop` | 2 | 🔲 | transform | 🔲 |
|
| `crop` | 2 | 🔲 | transform | 🔲 |
|
||||||
| `bg-remove` | 2 | 🔲 | transform | 🔲 |
|
| `bg-remove` | 2 | 🔲 | transform | 🔲 |
|
||||||
@@ -115,6 +116,7 @@ Zweistufiger Node-Flow analog `prompt → ai-image`:
|
|||||||
image: 280 × 200 prompt: 288 × 220
|
image: 280 × 200 prompt: 288 × 220
|
||||||
text: 256 × 120 ai-image: 320 × 408
|
text: 256 × 120 ai-image: 320 × 408
|
||||||
video-prompt: 288 × 220 ai-video: 360 × 280
|
video-prompt: 288 × 220 ai-video: 360 × 280
|
||||||
|
agent: 360 × 320
|
||||||
group: 400 × 300 frame: 400 × 300
|
group: 400 × 300 frame: 400 × 300
|
||||||
note: 208 × 100 compare: 500 × 380
|
note: 208 × 100 compare: 500 × 380
|
||||||
```
|
```
|
||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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.
|
- **Optimistic IDs:** Temporäre Nodes/Edges erhalten IDs mit `optimistic_` / `optimistic_edge_`-Prefix, werden durch echte Convex-IDs ersetzt, sobald die Mutation abgeschlossen ist.
|
||||||
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
|
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
|
||||||
- **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt.
|
- **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt.
|
||||||
|
- **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.
|
- **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren.
|
||||||
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.
|
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Frame,
|
Frame,
|
||||||
Focus,
|
Focus,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
|
Crop as CropIcon,
|
||||||
|
Bot,
|
||||||
ImageDown,
|
ImageDown,
|
||||||
Image,
|
Image,
|
||||||
Package,
|
Package,
|
||||||
@@ -28,12 +30,14 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
|||||||
text: Type,
|
text: Type,
|
||||||
prompt: Sparkles,
|
prompt: Sparkles,
|
||||||
"video-prompt": Video,
|
"video-prompt": Video,
|
||||||
|
agent: Bot,
|
||||||
note: StickyNote,
|
note: StickyNote,
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
compare: GitCompare,
|
compare: GitCompare,
|
||||||
group: FolderOpen,
|
group: FolderOpen,
|
||||||
asset: Package,
|
asset: Package,
|
||||||
video: Video,
|
video: Video,
|
||||||
|
crop: CropIcon,
|
||||||
curves: Sparkles,
|
curves: Sparkles,
|
||||||
"color-adjust": Palette,
|
"color-adjust": Palette,
|
||||||
"light-adjust": Sun,
|
"light-adjust": Sun,
|
||||||
@@ -48,12 +52,14 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
|||||||
text: ["text", "typo"],
|
text: ["text", "typo"],
|
||||||
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
|
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
|
||||||
"video-prompt": ["video", "ai", "ki-video", "ki", "prompt"],
|
"video-prompt": ["video", "ai", "ki-video", "ki", "prompt"],
|
||||||
|
agent: ["agent", "campaign", "distribution", "social"],
|
||||||
note: ["note", "sticky", "notiz"],
|
note: ["note", "sticky", "notiz"],
|
||||||
frame: ["frame", "artboard"],
|
frame: ["frame", "artboard"],
|
||||||
compare: ["compare", "before", "after", "vergleich"],
|
compare: ["compare", "before", "after", "vergleich"],
|
||||||
group: ["group", "gruppe", "folder"],
|
group: ["group", "gruppe", "folder"],
|
||||||
asset: ["asset", "freepik", "stock"],
|
asset: ["asset", "freepik", "stock"],
|
||||||
video: ["video", "pexels", "clip"],
|
video: ["video", "pexels", "clip"],
|
||||||
|
crop: ["crop", "resize", "ratio"],
|
||||||
curves: ["curves", "tone", "contrast"],
|
curves: ["curves", "tone", "contrast"],
|
||||||
"color-adjust": ["color", "hue", "saturation"],
|
"color-adjust": ["color", "hue", "saturation"],
|
||||||
"light-adjust": ["light", "exposure", "brightness"],
|
"light-adjust": ["light", "exposure", "brightness"],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import LightAdjustNode from "./nodes/light-adjust-node";
|
|||||||
import DetailAdjustNode from "./nodes/detail-adjust-node";
|
import DetailAdjustNode from "./nodes/detail-adjust-node";
|
||||||
import RenderNode from "./nodes/render-node";
|
import RenderNode from "./nodes/render-node";
|
||||||
import CropNode from "./nodes/crop-node";
|
import CropNode from "./nodes/crop-node";
|
||||||
|
import AgentNode from "./nodes/agent-node";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node-Type-Map für React Flow.
|
* Node-Type-Map für React Flow.
|
||||||
@@ -43,4 +44,5 @@ export const nodeTypes = {
|
|||||||
"detail-adjust": DetailAdjustNode,
|
"detail-adjust": DetailAdjustNode,
|
||||||
crop: CropNode,
|
crop: CropNode,
|
||||||
render: RenderNode,
|
render: RenderNode,
|
||||||
|
agent: AgentNode,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||||
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
|
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({
|
export default function AdjustmentPreview({
|
||||||
nodeId,
|
nodeId,
|
||||||
|
|||||||
90
components/canvas/nodes/agent-node.tsx
Normal file
90
components/canvas/nodes/agent-node.tsx
Normal file
@@ -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<AgentNodeData, "agent">;
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor";
|
||||||
|
|
||||||
|
function CompactList({ items }: { items: readonly string[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{items.slice(0, 4).map((item) => (
|
||||||
|
<li key={item} className="truncate text-[11px] text-foreground/90" title={item}>
|
||||||
|
- {item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentNode({ data, selected }: NodeProps<AgentNodeType>) {
|
||||||
|
const nodeData = data as AgentNodeData;
|
||||||
|
const template =
|
||||||
|
getAgentTemplate(nodeData.templateId ?? DEFAULT_AGENT_TEMPLATE_ID) ??
|
||||||
|
getAgentTemplate(DEFAULT_AGENT_TEMPLATE_ID);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="agent"
|
||||||
|
selected={selected}
|
||||||
|
status={nodeData._status}
|
||||||
|
statusMessage={nodeData._statusMessage}
|
||||||
|
className="min-w-[300px] border-amber-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="agent-in"
|
||||||
|
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
<span>{template.emoji}</span>
|
||||||
|
<span>{template.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">{template.description}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Channels
|
||||||
|
</p>
|
||||||
|
<CompactList items={template.channels} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Expected Inputs
|
||||||
|
</p>
|
||||||
|
<CompactList items={template.expectedInputs} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Expected Outputs
|
||||||
|
</p>
|
||||||
|
<CompactList items={template.expectedOutputs} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
|||||||
"detail-adjust": { minWidth: 300, minHeight: 820 },
|
"detail-adjust": { minWidth: 300, minHeight: 820 },
|
||||||
crop: { minWidth: 320, minHeight: 520 },
|
crop: { minWidth: 320, minHeight: 520 },
|
||||||
render: { minWidth: 260, minHeight: 300, keepAspectRatio: true },
|
render: { minWidth: 260, minHeight: 300, keepAspectRatio: true },
|
||||||
|
agent: { minWidth: 300, minHeight: 280 },
|
||||||
text: { minWidth: 220, minHeight: 90 },
|
text: { minWidth: 220, minHeight: 90 },
|
||||||
note: { minWidth: 200, minHeight: 90 },
|
note: { minWidth: 200, minHeight: 90 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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-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-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 |
|
| `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-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 |
|
| `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) |
|
| `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_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`)
|
- `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`)
|
- `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
|
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
||||||
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
- `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
|
- Erstellt durch die Node-Katalog-Einträge
|
||||||
- Enthält default-Werte für `data`-Felder
|
- Enthält default-Werte für `data`-Felder
|
||||||
- `video-prompt` hat Default-Daten: `{ modelId: "wan-2-2-720p", durationSeconds: 5 }`
|
- `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
|
- 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`)
|
- `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`)
|
- `video-prompt` als Source akzeptiert nur `ai-video` als Target (`video-prompt-target-invalid`)
|
||||||
- `text → video-prompt` ✅ (Prompt-Quelle, über default-Handles)
|
- `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
|
- Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
69
lib/agent-templates.ts
Normal file
69
lib/agent-templates.ts
Normal file
@@ -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<AgentTemplateId, AgentTemplate>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,19 @@ const RENDER_ALLOWED_SOURCE_TYPES = new Set<string>([
|
|||||||
"detail-adjust",
|
"detail-adjust",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const AGENT_ALLOWED_SOURCE_TYPES = new Set<string>([
|
||||||
|
"image",
|
||||||
|
"asset",
|
||||||
|
"video",
|
||||||
|
"text",
|
||||||
|
"note",
|
||||||
|
"frame",
|
||||||
|
"compare",
|
||||||
|
"render",
|
||||||
|
"ai-image",
|
||||||
|
"ai-video",
|
||||||
|
]);
|
||||||
|
|
||||||
const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set<string>(["prompt", "ai-image"]);
|
const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set<string>(["prompt", "ai-image"]);
|
||||||
|
|
||||||
export type CanvasConnectionValidationReason =
|
export type CanvasConnectionValidationReason =
|
||||||
@@ -51,7 +64,8 @@ export type CanvasConnectionValidationReason =
|
|||||||
| "crop-incoming-limit"
|
| "crop-incoming-limit"
|
||||||
| "compare-incoming-limit"
|
| "compare-incoming-limit"
|
||||||
| "adjustment-target-forbidden"
|
| "adjustment-target-forbidden"
|
||||||
| "render-source-invalid";
|
| "render-source-invalid"
|
||||||
|
| "agent-source-invalid";
|
||||||
|
|
||||||
export function validateCanvasConnectionPolicy(args: {
|
export function validateCanvasConnectionPolicy(args: {
|
||||||
sourceType: string;
|
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 (isAdjustmentNodeType(targetType) && targetType !== "render") {
|
||||||
if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) {
|
if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) {
|
||||||
return "adjustment-source-invalid";
|
return "adjustment-source-invalid";
|
||||||
@@ -132,6 +150,8 @@ export function getCanvasConnectionValidationMessage(
|
|||||||
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
||||||
case "render-source-invalid":
|
case "render-source-invalid":
|
||||||
return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Crop- oder Adjustment-Input.";
|
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:
|
default:
|
||||||
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
|
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,8 +220,6 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
|||||||
label: "Agent",
|
label: "Agent",
|
||||||
category: "control",
|
category: "control",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
entry({
|
entry({
|
||||||
type: "mixer",
|
type: "mixer",
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export const CANVAS_NODE_TEMPLATES = [
|
|||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "agent",
|
||||||
|
label: "Campaign Distributor",
|
||||||
|
width: 360,
|
||||||
|
height: 320,
|
||||||
|
defaultData: {
|
||||||
|
templateId: "campaign-distributor",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "note",
|
type: "note",
|
||||||
label: "Notiz",
|
label: "Notiz",
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
"detail-adjust": [99, 102, 241],
|
"detail-adjust": [99, 102, 241],
|
||||||
crop: [139, 92, 246],
|
crop: [139, 92, 246],
|
||||||
render: [14, 165, 233],
|
render: [14, 165, 233],
|
||||||
|
agent: [245, 158, 11],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
/** 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 },
|
"detail-adjust": { source: undefined, target: undefined },
|
||||||
crop: { source: undefined, target: undefined },
|
crop: { source: undefined, target: undefined },
|
||||||
render: { source: undefined, target: undefined },
|
render: { source: undefined, target: undefined },
|
||||||
|
agent: { target: "agent-in" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,6 +278,11 @@ export const NODE_DEFAULTS: Record<
|
|||||||
height: 420,
|
height: 420,
|
||||||
data: { outputResolution: "original", format: "png", jpegQuality: 90 },
|
data: { outputResolution: "original", format: "png", jpegQuality: 90 },
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
width: 360,
|
||||||
|
height: 320,
|
||||||
|
data: { templateId: "campaign-distributor" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaNodeKind = "asset" | "image";
|
type MediaNodeKind = "asset" | "image";
|
||||||
|
|||||||
143
tests/adjustment-preview.test.ts
Normal file
143
tests/adjustment-preview.test.ts
Normal file
@@ -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<string, unknown> }) | 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
110
tests/agent-node.test.ts
Normal file
110
tests/agent-node.test.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Campaign Distributor");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -159,4 +159,42 @@ describe("canvas connection policy", () => {
|
|||||||
getCanvasConnectionValidationMessage("ai-video-source-invalid"),
|
getCanvasConnectionValidationMessage("ai-video-source-invalid"),
|
||||||
).toBe("KI-Video-Ausgabe akzeptiert nur Eingaben von KI-Video.");
|
).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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
22
tests/lib/agent-templates.test.ts
Normal file
22
tests/lib/agent-templates.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tests/lib/canvas-agent-config.test.ts
Normal file
32
tests/lib/canvas-agent-config.test.ts
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user