feat(agent): add structured outputs and media archive support

This commit is contained in:
2026-04-10 19:01:04 +02:00
parent a1df097f9c
commit 9732022461
34 changed files with 3276 additions and 482 deletions

130
components/agents/CLAUDE.md Normal file
View File

@@ -0,0 +1,130 @@
# components/agents/ - Agent Specs (Markdown)
Dieser Ordner enthaelt die menschenlesbaren Agent-Spezifikationen.
Jede Datei ist gleichzeitig Produktdoku und kuratierte Prompt-Quelle.
---
## Dual Model (verbindlich)
Die Agent-Runtime basiert auf zwei Quellen:
1. **Struktur in TypeScript**
- `lib/agent-definitions.ts`
- `lib/agent-run-contract.ts`
2. **Kuratierte Prompt-Segmente in Markdown**
- `components/agents/*.md`
- kompiliert via `scripts/compile-agent-docs.ts`
- konsumiert aus `lib/generated/agent-doc-segments.ts`
Wichtig:
- `convex/agents.ts` liest **kein** Raw-Markdown zur Laufzeit.
- Nur markierte `AGENT_PROMPT_SEGMENT`-Bloecke wirken prompt-relevant.
- Unmarkierter Text ist Doku fuer Menschen.
---
## Dateikonvention pro Agent
Dateiname muss dem Agent-Id-Muster folgen, z. B.:
- `campaign-distributor.md` fur `campaign-distributor`
Die Zuordnung passiert ueber `docs.markdownPath` in `lib/agent-definitions.ts`.
---
## Frontmatter (Pflicht)
Jede Agent-Datei startet mit Frontmatter:
```md
---
name: Campaign Distributor
description: ...
tools: WebFetch, WebSearch, Read, Write, Edit
color: yellow
emoji: lemon
vibe: ...
---
```
Hinweise:
- `emoji` soll als ASCII-Token gepflegt werden (z. B. `lemon`), nicht als Unicode-Zeichen.
- Frontmatter ist Referenz fuer Doku und muss mit der TS-Definition konsistent bleiben.
---
## Prompt Segment Marker (Pflicht)
Aktuell required keys:
- `role`
- `style-rules`
- `decision-framework`
- `channel-notes`
Marker-Format:
```md
<!-- AGENT_PROMPT_SEGMENT:role:start -->
Segment text
<!-- AGENT_PROMPT_SEGMENT:role:end -->
```
Regeln:
- Pro required key genau **ein** start- und **ein** end-marker.
- Kein leerer Segment-Inhalt.
- Marker-Namen muessen exakt passen.
- Segment-Reihenfolge in der Generierung folgt `AGENT_PROMPT_SEGMENT_KEYS`.
Optional zusaetzliche Segmenttypen sind erlaubt, muessen aber erst im Compiler/
Runtime-Prompting verankert werden, bevor sie Wirkung haben.
---
## Schreibregeln fuer Segmente
- Schreibe handlungsorientiert und spezifisch.
- Keine versteckte Denkspur (kein chain-of-thought).
- Keine erfundenen Produktfakten, Statistiken oder Deadlines.
- Kanalregeln konkret, aber nicht auf fragile Einzelformate ueber-engineeren.
- Immer auf strukturierten Runtime-Output ausrichten (`artifactType`, `sections`, `metadata`, `qualityChecks`, `previewText`, `body`).
---
## Nach jeder Aenderung
1. Prompt-Segmente kompilieren:
```bash
npx tsx scripts/compile-agent-docs.ts
```
2. Relevante Tests laufen lassen:
```bash
npm run test -- tests/lib/agent-doc-segments.test.ts tests/lib/agent-prompting.test.ts
```
3. Bei Struktur-Aenderungen zusaetzlich:
```bash
npm run test -- tests/lib/agent-definitions.test.ts tests/lib/agent-run-contract.test.ts
```
---
## Wann andere Dateien mitziehen
- `lib/agent-definitions.ts` anpassen, wenn sich Inputs, Kanaele, Regeln, Blueprints oder Parameter aendern.
- `lib/agent-prompting.ts` anpassen, wenn neue Segmenttypen wirklich in Prompts einfliessen sollen.
- `scripts/compile-agent-docs.ts` anpassen, wenn required segment keys geaendert werden.
- `messages/de.json` / `messages/en.json` anpassen, wenn neue UI-Labels sichtbar werden.
---
## Anti-Patterns
- Komplettes monolithisches Prompt-Dokument ohne Marker-Struktur.
- Raw-Markdown als Runtime-Input ohne Compile-Step.
- Agent-Output nur als Freitext ohne strukturierte Deliverables.
- Segment-Inhalte, die den TS-Contracts widersprechen.

View File

@@ -1,189 +1,124 @@
--- ---
name: Campaign Distributor 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. description: Turns LemonSpace visual variants and optional campaign context into channel-native distribution packages with explicit asset assignment, format guidance, and publish-ready copy.
tools: WebFetch, WebSearch, Read, Write, Edit tools: WebFetch, WebSearch, Read, Write, Edit
color: yellow color: yellow
emoji: 🍋 emoji: lemon
vibe: Verwandelt Canvas-Outputs in kampagnenfähige Inhalte für jeden Kanal. vibe: Transforms canvas outputs into channel-native campaign content that can ship immediately.
--- ---
# Campaign Distributor Agent # Campaign Distributor Agent
## Rolle ## Prompt Segments (Compiled)
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. This document has a dual role in the agent runtime:
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. 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.
## Kernfähigkeiten <!-- AGENT_PROMPT_SEGMENT:role:start -->
You are the Campaign Distributor for LemonSpace, an AI creative canvas used by small design and marketing teams. Your mission is to transform visual canvas outputs and optional campaign briefing into channel-native distribution packages that are ready to publish, mapped to the best-fitting asset, and explicit about assumptions when context is missing.
<!-- AGENT_PROMPT_SEGMENT:role:end -->
- **Canvas-to-Content**: Nimmt Bildvarianten, KI-Outputs und Render-Exports aus LemonSpace und leitet daraus kanalspezifische Content-Pakete ab <!-- AGENT_PROMPT_SEGMENT:style-rules:start -->
- **Kanalstrategie**: Entwickelt Distributionspläne, die Formatanforderungen, Algorithmuslogik und Nutzerverhalten je Plattform berücksichtigen Write specific, decisive, and immediately usable copy. Prefer concrete verbs over vague language, keep claims honest, and never invent product facts, statistics, or deadlines that were not provided. Adapt tone by channel while preserving campaign intent, and keep each deliverable concise enough to be practical for operators.
- **Messenger-Integration**: Plant und formuliert Inhalte für Direct-Messaging-Kanäle (WhatsApp Business, Telegram, Newsletter-E-Mail) — nicht nur Broadcast, sondern dialogorientiert <!-- AGENT_PROMPT_SEGMENT:style-rules:end -->
- **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
--- <!-- AGENT_PROMPT_SEGMENT:decision-framework:start -->
Reason in this order: (1) validate required visual context, (2) detect language from brief and default to English if ambiguous, (3) assign assets to channels by format fit and visual intent, (4) select the best output blueprint per channel, (5) generate publish-ready sections and metadata, (6) surface assumptions and format risks explicitly. Ask clarifying questions only when required fields are missing or conflicting. For each selected channel, produce one structured deliverable with artifactType, previewText, sections, metadata, and qualityChecks.
<!-- AGENT_PROMPT_SEGMENT:decision-framework:end -->
## Kanalmatrix <!-- AGENT_PROMPT_SEGMENT:channel-notes:start -->
Instagram needs hook-first visual storytelling with clear CTA and practical hashtag sets. LinkedIn needs professional framing, strong insight opening, and comment-driving close without hype language. X needs brevity and thread-aware sequencing when 280 characters are exceeded. TikTok needs native conversational phrasing and 9:16 adaptation notes. WhatsApp and Telegram need direct, high-signal copy with one clear action. Newsletter needs subject cue, preview line, and a reusable body block that fits any email builder. If asset format mismatches channel constraints, flag it and suggest a fix.
<!-- AGENT_PROMPT_SEGMENT:channel-notes:end -->
### Social Media ## Runtime Contract Snapshot
| Kanal | Hauptformat | Ton | Besonderheit | This agent is wired through two contracts that must stay in sync:
|-------|-------------|-----|--------------|
| 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 - 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`).
| Kanal | Format | Ton | Besonderheit | `convex/agents.ts` consumes generated segments through `lib/agent-prompting.ts`. It does not parse markdown at runtime.
|-------|--------|-----|--------------|
| 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 |
--- ### Output shape expectations
## Canvas-Workflow-Integration Execution outputs are expected to provide structured deliverables using:
Der Agent versteht LemonSpace-spezifische Konzepte und kann direkt damit arbeiten: - `artifactType`
- `previewText`
- `sections[]` with `id`, `label`, `content`
- `metadata` as `Record<string, string | string[]>`
- `qualityChecks[]`
- **Bildvarianten aus Compare-Node**: Welche Variante geht auf Instagram, welche auf LinkedIn? Begründung und Empfehlung. `body` remains a compatibility fallback for older render paths.
- **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.
--- ## Node Purpose
## Spezialisierte Skills The node takes visual assets plus optional brief context and emits structured `agent-output` nodes per selected channel.
It does not emit raw text blobs as primary output.
- Algorithmus-Optimierung je Plattform (Reach vs. Engagement-Logik, Posting-Zeitfenster) ## Canonical Inputs
- 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
--- | Handle | Source types | Required | Notes |
| --- | --- | --- | --- |
| `image` | `image`, `ai-image`, `render`, `compare`, optional `asset` / `video` | yes | One or more visual assets are required. |
| `brief` | `text`, `note` | no | Audience, tone, campaign goal, constraints, language hints. |
## Workflow-Integration If brief is missing, the agent should infer from asset labels/prompts and write assumptions explicitly.
- **Handoff von**: KI-Bild-Node, Render-Node, Compare-Node (Canvas-Exports), Content Creator Agent ## Operator Parameters (Definition Layer)
- **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
--- - `targetChannels`: multi-select channel set
- `variantsPerChannel`: `1 | 2 | 3`
- `toneOverride`: `auto | professional | casual | inspiring | direct`
## Entscheidungsrahmen ## Channel Coverage
Diesen Agent einsetzen, wenn: - Instagram Feed
- Canvas-Outputs (Bilder, Varianten, Renders) über mehrere Kanäle verteilt werden sollen - Instagram Stories
- Kanalspezifische Caption, Hashtags und CTAs benötigt werden - Instagram Reels
- Variantenentscheidungen (welches Bild auf welchem Kanal) getroffen werden müssen - LinkedIn
- Messenger-Kampagnen (WhatsApp, Telegram, Newsletter) geplant werden - X (Twitter)
- Ein Posting-Kalender für einen Canvas-Projekt-Output erstellt werden soll - TikTok
- Before/After oder Prozess-Content aus dem Canvas-Workflow entwickelt wird - Pinterest
- WhatsApp Business
- Telegram
- E-Mail Newsletter
- Discord
--- ## Analyze Stage Responsibilities
## Erfolgsmetriken Before execute, the agent should build a plan that includes:
- **Instagram Engagement Rate**: ≥4% Feed, ≥6% Stories - channel-to-deliverable step mapping
- **LinkedIn Reichweite**: ≥20% monatliches Wachstum Impressionen - asset assignment by channel with rationale
- **Newsletter Open Rate**: ≥35% (Indie/Creator-Segment), ≥25% (SMB) - language detection result
- **WhatsApp Business**: ≥60% Öffnungsrate, ≥15% Click-Rate auf Links - explicit assumptions list
- **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
--- ## Execute Stage Responsibilities
## Beispiel-Anfragen For each planned channel step, the agent should produce:
- „Ich habe 6 Bildvarianten aus meinem LemonSpace Canvas exportiert. Welche gehört auf welchen Kanal?" - publish-ready copy sections
- „Schreib mir Captions für Instagram, LinkedIn und einen WhatsApp-Status für dieses Produktbild" - channel format guidance
- „Entwickle einen 2-Wochen-Distributionsplan für unseren Kampagnen-Launch" - CTA and accessibility/context signals where relevant
- „Erstelle Telegram-Kanal-Posts für unser LemonSpace Feature-Update" - metadata that explains asset, language, tone, and format decisions
- „Schreib einen Newsletter für unsere Starter-Nutzer über die neuen Bildbearbeitungs-Nodes" - quality checks that are user-visible and testable
- „Welche Caption-Struktur funktioniert für Before/After-Posts auf TikTok vs. LinkedIn?"
- „Erstelle ein Hashtag-Set für unsere KI-Kreativ-Workflow-Posts"
--- ## Constraints
## Content-Kaskaden-Prinzip - Do not fabricate facts, claims, or urgency.
- Keep CTA honest and actionable.
- If channel-format mismatch exists, call it out and propose fix.
- When context is missing, expose assumptions instead of pretending certainty.
Jeder Canvas-Output wird maximal verwertet — kein Inhalt wird für nur einen Kanal erstellt: ## Human Reference Examples
``` - "Map these 4 campaign variants to Instagram, LinkedIn, and X."
Canvas-Export (Render-Node) - "Create WhatsApp, Telegram, and newsletter package from this render output."
→ Instagram Feed Post (1:1, kuratierte Caption) - "Give me two per-channel variants with professional tone override."
→ Instagram Story (9:16 Crop, Swipe-Up) - "No brief given: infer safely from asset prompts and list assumptions."
→ 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

View File

@@ -68,8 +68,8 @@ 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` | 2 | ✅ | control | target: `agent-in`, source (default) |
| `agent-output` | 3 | 🔲 | ai-output | systemOutput: true | | `agent-output` | 2 | ✅ (systemOutput) | ai-output | target: `agent-output-in` |
| `crop` | 2 | 🔲 | transform | 🔲 | | `crop` | 2 | 🔲 | transform | 🔲 |
| `bg-remove` | 2 | 🔲 | transform | 🔲 | | `bg-remove` | 2 | 🔲 | transform | 🔲 |
| `upscale` | 2 | 🔲 | transform | 🔲 | | `upscale` | 2 | 🔲 | transform | 🔲 |
@@ -215,7 +215,17 @@ 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 | | `agent-node.tsx` | Definitionsgetriebener Agent-Node mit Briefing, Constraints, Model-Auswahl, Run/Resume und Clarification-Flow |
| `agent-output-node.tsx` | Agent-Ausgabe-Node fuer Skeletons plus strukturierte Deliverables (`sections`, `metadata`, `qualityChecks`, `previewText`) mit `body`-Fallback |
---
## Agent Runtime Nodes (aktuell)
- `agent-node.tsx` liest Template-Metadaten ueber `getAgentTemplate(...)` (projektiert aus `lib/agent-definitions.ts`).
- Node-Daten enthalten `briefConstraints`, `clarificationQuestions`, `clarificationAnswers`, `executionSteps` und Laufstatus.
- Run startet `api.agents.runAgent`, Clarification-Submit nutzt `api.agents.resumeAgent`.
- `agent-output-node.tsx` rendert strukturierte Outputs bevorzugt (Sections/Metadata/Quality Checks/Preview) und faellt auf JSON oder Plain-Text-`body` zurueck.
--- ---
@@ -281,6 +291,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. - **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen.
- **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren. - **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren.
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`. - **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Bot } from "lucide-react"; import { Bot } from "lucide-react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
@@ -16,7 +16,6 @@ import {
DEFAULT_AGENT_MODEL_ID, DEFAULT_AGENT_MODEL_ID,
getAgentModel, getAgentModel,
getAvailableAgentModels, getAvailableAgentModels,
type AgentModelId,
} from "@/lib/agent-models"; } from "@/lib/agent-models";
import { import {
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
@@ -88,11 +87,16 @@ function useSafeSubscription() {
} }
} }
function useSafeAction(reference: FunctionReference<"action", "public", any, unknown>) { function useSafeAction<Args extends Record<string, unknown>, Output>(
reference: FunctionReference<"action", "public", Args, Output>,
) {
try { try {
return useAction(reference); return useAction(reference);
} catch { } catch {
return async (_args: any) => undefined; return async (args: Args): Promise<Output | undefined> => {
void args;
return undefined;
};
} }
} }
@@ -183,6 +187,10 @@ function CompactList({ items }: { items: readonly string[] }) {
); );
} }
function toTemplateTranslationKey(templateId: string): string {
return templateId.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase());
}
export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeType>) { export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeType>) {
const t = useTranslations("agentNode"); const t = useTranslations("agentNode");
const locale = useLocale(); const locale = useLocale();
@@ -195,13 +203,35 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const userTier = normalizePublicTier(subscription?.tier ?? "free"); const userTier = normalizePublicTier(subscription?.tier ?? "free");
const availableModels = useMemo(() => getAvailableAgentModels(userTier), [userTier]); const availableModels = useMemo(() => getAvailableAgentModels(userTier), [userTier]);
const [modelId, setModelId] = useState(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID); const clarificationAnswersFromNode = useMemo(
const [clarificationAnswers, setClarificationAnswers] = useState<AgentClarificationAnswerMap>( () => normalizeClarificationAnswers(nodeData.clarificationAnswers),
normalizeClarificationAnswers(nodeData.clarificationAnswers), [nodeData.clarificationAnswers],
); );
const [briefConstraints, setBriefConstraints] = useState<AgentBriefConstraints>( const briefConstraintsFromNode = useMemo(
normalizeBriefConstraints(nodeData.briefConstraints), () => normalizeBriefConstraints(nodeData.briefConstraints),
[nodeData.briefConstraints],
); );
const nodeModelId =
typeof nodeData.modelId === "string" && nodeData.modelId.trim().length > 0
? nodeData.modelId
: DEFAULT_AGENT_MODEL_ID;
const [modelDraftId, setModelDraftId] = useState<string | null>(null);
const [clarificationAnswersDraft, setClarificationAnswersDraft] =
useState<AgentClarificationAnswerMap | null>(null);
const [briefConstraintsDraft, setBriefConstraintsDraft] =
useState<AgentBriefConstraints | null>(null);
const modelId = modelDraftId === nodeModelId ? nodeModelId : modelDraftId ?? nodeModelId;
const clarificationAnswers =
clarificationAnswersDraft &&
areAnswerMapsEqual(clarificationAnswersDraft, clarificationAnswersFromNode)
? clarificationAnswersFromNode
: clarificationAnswersDraft ?? clarificationAnswersFromNode;
const briefConstraints =
briefConstraintsDraft &&
areBriefConstraintsEqual(briefConstraintsDraft, briefConstraintsFromNode)
? briefConstraintsFromNode
: briefConstraintsDraft ?? briefConstraintsFromNode;
const agentActionsApi = api as unknown as { const agentActionsApi = api as unknown as {
agents: { agents: {
@@ -234,57 +264,30 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent); const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent);
const normalizedLocale = locale === "en" ? "en" : "de"; const normalizedLocale = locale === "en" ? "en" : "de";
useEffect(() => { const resolvedModelId = useMemo(() => {
setModelId(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID);
}, [nodeData.modelId]);
useEffect(() => {
const normalized = normalizeClarificationAnswers(nodeData.clarificationAnswers);
setClarificationAnswers((current) => {
if (areAnswerMapsEqual(current, normalized)) {
return current;
}
return normalized;
});
}, [nodeData.clarificationAnswers]);
useEffect(() => {
const normalized = normalizeBriefConstraints(nodeData.briefConstraints);
setBriefConstraints((current) => {
if (areBriefConstraintsEqual(current, normalized)) {
return current;
}
return normalized;
});
}, [nodeData.briefConstraints]);
useEffect(() => {
if (availableModels.length === 0) {
return;
}
if (availableModels.some((model) => model.id === modelId)) { if (availableModels.some((model) => model.id === modelId)) {
return; return modelId;
} }
const nextModelId = availableModels[0]!.id; return availableModels[0]?.id ?? DEFAULT_AGENT_MODEL_ID;
setModelId(nextModelId);
}, [availableModels, modelId]); }, [availableModels, modelId]);
const selectedModel = const selectedModel =
getAgentModel(modelId) ?? getAgentModel(resolvedModelId) ??
availableModels[0] ?? availableModels[0] ??
getAgentModel(DEFAULT_AGENT_MODEL_ID); getAgentModel(DEFAULT_AGENT_MODEL_ID);
const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID;
const creditCost = selectedModel?.creditCost ?? 0; const creditCost = selectedModel?.creditCost ?? 0;
const clarificationQuestions = nodeData.clarificationQuestions ?? []; const clarificationQuestions = nodeData.clarificationQuestions ?? [];
const templateTranslationKey = `templates.${toTemplateTranslationKey(template?.id ?? DEFAULT_AGENT_TEMPLATE_ID)}`;
const translatedTemplateName = t(`${templateTranslationKey}.name`);
const translatedTemplateDescription = t(`${templateTranslationKey}.description`);
const templateName = const templateName =
template?.id === "campaign-distributor" translatedTemplateName === `${templateTranslationKey}.name`
? t("templates.campaignDistributor.name") ? (template?.name ?? "")
: (template?.name ?? ""); : translatedTemplateName;
const templateDescription = const templateDescription =
template?.id === "campaign-distributor" translatedTemplateDescription === `${templateTranslationKey}.description`
? t("templates.campaignDistributor.description") ? (template?.description ?? "")
: (template?.description ?? ""); : translatedTemplateDescription;
const isExecutionActive = nodeData._status === "analyzing" || nodeData._status === "executing"; const isExecutionActive = nodeData._status === "analyzing" || nodeData._status === "executing";
const executionProgressLine = useMemo(() => { const executionProgressLine = useMemo(() => {
if (nodeData._status !== "executing") { if (nodeData._status !== "executing") {
@@ -373,7 +376,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const handleModelChange = useCallback( const handleModelChange = useCallback(
(value: string) => { (value: string) => {
setModelId(value); setModelDraftId(value);
void persistNodeData({ modelId: value }); void persistNodeData({ modelId: value });
}, },
[persistNodeData], [persistNodeData],
@@ -381,33 +384,29 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const handleClarificationAnswerChange = useCallback( const handleClarificationAnswerChange = useCallback(
(questionId: string, value: string) => { (questionId: string, value: string) => {
setClarificationAnswers((prev) => { const next = {
const next = { ...clarificationAnswers,
...prev, [questionId]: value,
[questionId]: value, };
}; setClarificationAnswersDraft(next);
void persistNodeData({ clarificationAnswers: next }); void persistNodeData({ clarificationAnswers: next });
return next;
});
}, },
[persistNodeData], [clarificationAnswers, persistNodeData],
); );
const handleBriefConstraintsChange = useCallback( const handleBriefConstraintsChange = useCallback(
(patch: Partial<AgentBriefConstraints>) => { (patch: Partial<AgentBriefConstraints>) => {
setBriefConstraints((prev) => { const next = {
const next = { ...briefConstraints,
...prev, ...patch,
...patch, };
}; setBriefConstraintsDraft(next);
void persistNodeData({ briefConstraints: next }); void persistNodeData({ briefConstraints: next });
return next;
});
}, },
[persistNodeData], [briefConstraints, persistNodeData],
); );
const handleRunAgent = useCallback(async () => { const handleRunAgent = async () => {
if (isExecutionActive) { if (isExecutionActive) {
return; return;
} }
@@ -431,9 +430,9 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
modelId: resolvedModelId, modelId: resolvedModelId,
locale: normalizedLocale, locale: normalizedLocale,
}); });
}, [isExecutionActive, nodeData.canvasId, id, normalizedLocale, resolvedModelId, runAgent, status.isOffline, t]); };
const handleSubmitClarification = useCallback(async () => { const handleSubmitClarification = async () => {
if (status.isOffline) { if (status.isOffline) {
toast.warning( toast.warning(
t("offlineTitle"), t("offlineTitle"),
@@ -453,7 +452,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
clarificationAnswers, clarificationAnswers,
locale: normalizedLocale, locale: normalizedLocale,
}); });
}, [clarificationAnswers, nodeData.canvasId, id, normalizedLocale, resumeAgent, status.isOffline, t]); };
if (!template) { if (!template) {
return null; return null;

View File

@@ -12,6 +12,15 @@ type AgentOutputNodeData = {
stepTotal?: number; stepTotal?: number;
title?: string; title?: string;
channel?: string; channel?: string;
artifactType?: string;
previewText?: string;
sections?: Array<{
id?: string;
label?: string;
content?: string;
}>;
metadata?: Record<string, string | string[] | unknown>;
qualityChecks?: string[];
outputType?: string; outputType?: string;
body?: string; body?: string;
_status?: string; _status?: string;
@@ -40,6 +49,70 @@ function tryFormatJsonBody(body: string): string | null {
} }
} }
function normalizeSections(raw: AgentOutputNodeData["sections"]) {
if (!Array.isArray(raw)) {
return [] as Array<{ id: string; label: string; content: string }>;
}
const sections: Array<{ id: string; label: string; content: string }> = [];
for (const item of raw) {
const label = typeof item?.label === "string" ? item.label.trim() : "";
const content = typeof item?.content === "string" ? item.content.trim() : "";
if (label === "" || content === "") {
continue;
}
const id = typeof item.id === "string" && item.id.trim() !== "" ? item.id.trim() : label;
sections.push({ id, label, content });
}
return sections;
}
function normalizeMetadata(raw: AgentOutputNodeData["metadata"]) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return [] as Array<[string, string]>;
}
const entries: Array<[string, string]> = [];
for (const [rawKey, rawValue] of Object.entries(raw)) {
const key = rawKey.trim();
if (key === "") {
continue;
}
if (typeof rawValue === "string") {
const value = rawValue.trim();
if (value !== "") {
entries.push([key, value]);
}
continue;
}
if (Array.isArray(rawValue)) {
const values = rawValue
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value !== "");
if (values.length > 0) {
entries.push([key, values.join(", ")]);
}
}
}
return entries;
}
function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value !== "");
}
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) { export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
const t = useTranslations("agentOutputNode"); const t = useTranslations("agentOutputNode");
const nodeData = data as AgentOutputNodeData; const nodeData = data as AgentOutputNodeData;
@@ -65,6 +138,16 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
nodeData.title ?? nodeData.title ??
(isSkeleton ? t("plannedOutputDefaultTitle") : t("defaultTitle")); (isSkeleton ? t("plannedOutputDefaultTitle") : t("defaultTitle"));
const body = nodeData.body ?? ""; const body = nodeData.body ?? "";
const artifactType = nodeData.artifactType ?? nodeData.outputType ?? "";
const sections = normalizeSections(nodeData.sections);
const metadataEntries = normalizeMetadata(nodeData.metadata);
const qualityChecks = normalizeQualityChecks(nodeData.qualityChecks);
const previewText =
typeof nodeData.previewText === "string" && nodeData.previewText.trim() !== ""
? nodeData.previewText.trim()
: sections[0]?.content ?? "";
const hasStructuredOutput =
sections.length > 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || previewText !== "";
const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body); const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body);
return ( return (
@@ -110,44 +193,108 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p> <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}> <p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
{nodeData.channel ?? "-"} {nodeData.channel ?? t("emptyValue")}
</p> </p>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("typeLabel")}</p> <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={nodeData.outputType}> <p className="truncate text-xs font-medium text-foreground/90" title={artifactType}>
{nodeData.outputType ?? "-"} {artifactType || t("emptyValue")}
</p> </p>
</div> </div>
</section> </section>
<section className="space-y-1"> {isSkeleton ? (
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <section className="space-y-1">
{t("bodyLabel")} <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
</p> {t("bodyLabel")}
{isSkeleton ? ( </p>
<div <div
data-testid="agent-output-skeleton-body" data-testid="agent-output-skeleton-body"
className="animate-pulse rounded-md border border-dashed border-amber-500/40 bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-amber-500/10 p-3" className="animate-pulse rounded-md border border-dashed border-amber-500/40 bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-amber-500/10 p-3"
> >
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{t("plannedContent")}</p> <p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{t("plannedContent")}</p>
</div> </div>
) : formattedJsonBody ? ( </section>
) : hasStructuredOutput ? (
<>
{sections.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) => (
<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}
{metadataEntries.length > 0 ? (
<section data-testid="agent-output-metadata" className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("metadataLabel")}</p>
<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}
</p>
))}
</div>
</section>
) : null}
{qualityChecks.length > 0 ? (
<section data-testid="agent-output-quality-checks" className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("qualityChecksLabel")}</p>
<div className="flex flex-wrap gap-1.5">
{qualityChecks.map((qualityCheck) => (
<span
key={qualityCheck}
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-200"
>
{qualityCheck}
</span>
))}
</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>
</>
) : formattedJsonBody ? (
<section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("bodyLabel")}
</p>
<pre <pre
data-testid="agent-output-json-body" data-testid="agent-output-json-body"
className="max-h-48 overflow-auto rounded-md border border-border/80 bg-muted/40 p-3 font-mono text-[11px] leading-relaxed text-foreground/95" className="max-h-48 overflow-auto rounded-md border border-border/80 bg-muted/40 p-3 font-mono text-[11px] leading-relaxed text-foreground/95"
> >
<code>{formattedJsonBody}</code> <code>{formattedJsonBody}</code>
</pre> </pre>
) : ( </section>
) : (
<section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("bodyLabel")}
</p>
<div <div
data-testid="agent-output-text-body" data-testid="agent-output-text-body"
className="max-h-48 overflow-auto rounded-md border border-border/70 bg-background/70 p-3 text-[13px] leading-relaxed text-foreground/90" className="max-h-48 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">{body}</p> <p className="whitespace-pre-wrap break-words">{body}</p>
</div> </div>
)} </section>
</section> )}
</div> </div>
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -112,7 +112,7 @@ export function CreditsActivityChart({ balance, recentTransactions }: CreditsAct
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <ChartTooltipContent
formatter={(value: number) => formatCredits(Number(value), locale)} formatter={(value) => formatCredits(Number(value), locale)}
/> />
} }
/> />

View File

@@ -0,0 +1,175 @@
// @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";
const mocks = vi.hoisted(() => ({
useAuthQuery: vi.fn(),
resolveUrls: vi.fn(async () => ({})),
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.resolveUrls,
}));
vi.mock("@/hooks/use-auth-query", () => ({
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
}));
import { MediaLibraryDialog } from "@/components/media/media-library-dialog";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function makeItems(count: number, page = 1) {
return Array.from({ length: count }).map((_, index) => ({
kind: "image" as const,
source: "upload" as const,
filename: `Item ${page}-${index + 1}`,
previewUrl: `https://cdn.example.com/${page}-${index + 1}.jpg`,
width: 1200,
height: 800,
createdAt: page * 1000 + index,
}));
}
describe("MediaLibraryDialog", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.useAuthQuery.mockReset();
mocks.resolveUrls.mockReset();
mocks.resolveUrls.mockImplementation(async () => ({}));
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;
});
it("calls media library query with page and default pageSize 8", async () => {
mocks.useAuthQuery.mockReturnValue(undefined);
await act(async () => {
root?.render(
<MediaLibraryDialog open onOpenChange={() => undefined} kindFilter="image" />,
);
});
const firstCallArgs = mocks.useAuthQuery.mock.calls[0]?.[1];
expect(firstCallArgs).toEqual(
expect.objectContaining({
page: 1,
pageSize: 8,
kindFilter: "image",
}),
);
});
it("renders at most 8 cards and shows Freepik-style pagination footer", async () => {
mocks.useAuthQuery.mockReturnValue({
items: makeItems(10),
page: 1,
pageSize: 8,
totalPages: 3,
totalCount: 24,
});
await act(async () => {
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
});
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");
});
it("updates query args when clicking next and previous", async () => {
const responseByPage = new Map<number, {
items: ReturnType<typeof makeItems>;
page: number;
pageSize: number;
totalPages: number;
totalCount: number;
}>();
mocks.useAuthQuery.mockImplementation((_, args: { page: number; pageSize: number }) => {
if (!responseByPage.has(args.page)) {
responseByPage.set(args.page, {
items: makeItems(8, args.page),
page: args.page,
pageSize: args.pageSize,
totalPages: 3,
totalCount: 24,
});
}
return responseByPage.get(args.page);
});
await act(async () => {
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
});
const nextButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Next",
);
if (!(nextButton instanceof HTMLButtonElement)) {
throw new Error("Next button not found");
}
await act(async () => {
nextButton.click();
});
const nextCallArgs = mocks.useAuthQuery.mock.calls.at(-1)?.[1];
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
const previousButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Previous",
);
if (!(previousButton instanceof HTMLButtonElement)) {
throw new Error("Previous button not found");
}
await act(async () => {
previousButton.click();
});
const previousCallArgs = mocks.useAuthQuery.mock.calls.at(-1)?.[1];
expect(previousCallArgs).toEqual(expect.objectContaining({ page: 1, pageSize: 8 }));
});
it("renders 8 loading skeleton cards", async () => {
mocks.useAuthQuery.mockReturnValue(undefined);
await act(async () => {
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
});
expect(document.querySelectorAll(".aspect-square.animate-pulse.bg-muted")).toHaveLength(8);
});
});

View File

@@ -20,9 +20,7 @@ import {
resolveMediaPreviewUrl, resolveMediaPreviewUrl,
} from "@/components/media/media-preview-utils"; } from "@/components/media/media-preview-utils";
const DEFAULT_LIMIT = 200; const DEFAULT_PAGE_SIZE = 8;
const MIN_LIMIT = 1;
const MAX_LIMIT = 500;
export type MediaLibraryMetadataItem = { export type MediaLibraryMetadataItem = {
kind: "image" | "video" | "asset"; kind: "image" | "video" | "asset";
@@ -54,18 +52,18 @@ export type MediaLibraryDialogProps = {
onPick?: (item: MediaLibraryItem) => void | Promise<void>; onPick?: (item: MediaLibraryItem) => void | Promise<void>;
title?: string; title?: string;
description?: string; description?: string;
limit?: number; pageSize?: number;
kindFilter?: "image" | "video" | "asset"; kindFilter?: "image" | "video" | "asset";
pickCtaLabel?: string; pickCtaLabel?: string;
}; };
function normalizeLimit(limit: number | undefined): number { type MediaLibraryResponse = {
if (typeof limit !== "number" || !Number.isFinite(limit)) { items: MediaLibraryMetadataItem[];
return DEFAULT_LIMIT; page: number;
} pageSize: number;
totalPages: number;
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(limit))); totalCount: number;
} };
function formatDimensions(width: number | undefined, height: number | undefined): string | null { function formatDimensions(width: number | undefined, height: number | undefined): string | null {
if (typeof width !== "number" || typeof height !== "number") { if (typeof width !== "number" || typeof height !== "number") {
@@ -128,20 +126,39 @@ export function MediaLibraryDialog({
onPick, onPick,
title = "Mediathek", title = "Mediathek",
description, description,
limit, pageSize = DEFAULT_PAGE_SIZE,
kindFilter, kindFilter,
pickCtaLabel = "Auswaehlen", pickCtaLabel = "Auswaehlen",
}: MediaLibraryDialogProps) { }: MediaLibraryDialogProps) {
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]); const [page, setPage] = useState(1);
const normalizedPageSize = useMemo(() => {
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
return DEFAULT_PAGE_SIZE;
}
return Math.max(1, Math.floor(pageSize));
}, [pageSize]);
useEffect(() => {
if (!open) {
setPage(1);
}
}, [open]);
useEffect(() => {
setPage(1);
}, [kindFilter]);
const metadata = useAuthQuery( const metadata = useAuthQuery(
api.dashboard.listMediaLibrary, api.dashboard.listMediaLibrary,
open open
? { ? {
limit: normalizedLimit, page,
pageSize: normalizedPageSize,
...(kindFilter ? { kindFilter } : {}), ...(kindFilter ? { kindFilter } : {}),
} }
: "skip", : "skip",
); ) as MediaLibraryResponse | undefined;
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia); const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({}); const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
@@ -164,7 +181,7 @@ export function MediaLibraryDialog({
return; return;
} }
const storageIds = collectMediaStorageIdsForResolution(metadata); const storageIds = collectMediaStorageIdsForResolution(metadata.items);
if (storageIds.length === 0) { if (storageIds.length === 0) {
setUrlMap({}); setUrlMap({});
setUrlError(null); setUrlError(null);
@@ -206,12 +223,14 @@ export function MediaLibraryDialog({
return []; return [];
} }
return metadata.map((item) => ({ return metadata.items.map((item) => ({
...item, ...item,
url: resolveMediaPreviewUrl(item, urlMap), url: resolveMediaPreviewUrl(item, urlMap),
})); }));
}, [metadata, urlMap]); }, [metadata, urlMap]);
const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]);
const isMetadataLoading = open && metadata === undefined; const isMetadataLoading = open && metadata === undefined;
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls); const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
const isPreviewMode = typeof onPick !== "function"; const isPreviewMode = typeof onPick !== "function";
@@ -244,9 +263,9 @@ export function MediaLibraryDialog({
<div className="min-h-[320px] overflow-y-auto pr-1"> <div className="min-h-[320px] overflow-y-auto pr-1">
{isInitialLoading ? ( {isInitialLoading ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{Array.from({ length: 12 }).map((_, index) => ( {Array.from({ length: DEFAULT_PAGE_SIZE }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-lg border"> <div key={index} className="overflow-hidden rounded-lg border">
<div className="aspect-square animate-pulse bg-muted" /> <div className="aspect-square animate-pulse bg-muted" />
<div className="space-y-1 p-2"> <div className="space-y-1 p-2">
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" /> <div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
@@ -270,8 +289,8 @@ export function MediaLibraryDialog({
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{items.map((item) => { {visibleItems.map((item) => {
const itemKey = getItemKey(item); const itemKey = getItemKey(item);
const isPickingThis = pendingPickItemKey === itemKey; const isPickingThis = pendingPickItemKey === itemKey;
const itemLabel = getItemLabel(item); const itemLabel = getItemLabel(item);
@@ -347,6 +366,30 @@ export function MediaLibraryDialog({
</div> </div>
)} )}
</div> </div>
{metadata && !isInitialLoading && !urlError && items.length > 0 ? (
<div className="flex shrink-0 items-center justify-center gap-2 border-t px-5 py-3" aria-live="polite">
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1}
>
Previous
</Button>
<span className="text-xs text-muted-foreground">
Page {metadata.page} of {metadata.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
disabled={page >= metadata.totalPages}
>
Next
</Button>
</div>
) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -23,6 +23,7 @@ Convex ist das vollständige Backend von LemonSpace: Datenbank, Realtime-Subscri
| `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) | | `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) |
| `pexels.ts` | Pexels Stock-Bilder API | | `pexels.ts` | Pexels Stock-Bilder API |
| `freepik.ts` | Freepik Asset-Browser API + Video-Generierungs-Client | | `freepik.ts` | Freepik Asset-Browser API + Video-Generierungs-Client |
| `agents.ts` | Agent-Orchestrierung: Analyze/Execute-Flow, Clarifications, strukturierte Outputs, Scheduler/Credits-Integration |
| `ai_utils.ts` | Gemeinsame Helpers für AI-Pipeline (z. B. `assertNodeBelongsToCanvasOrThrow`) | | `ai_utils.ts` | Gemeinsame Helpers für AI-Pipeline (z. B. `assertNodeBelongsToCanvasOrThrow`) |
| `storage.ts` | Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung | | `storage.ts` | Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
| `export.ts` | Canvas-Export-Logik | | `export.ts` | Canvas-Export-Logik |
@@ -89,6 +90,35 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
--- ---
## Agent-Orchestrierung (`agents.ts`)
`agents.ts` orchestriert den Lauf von Agent-Nodes in zwei Stufen:
1. Analyze: Brief + Kontext auswerten, Clarification-Fragen und Execution-Plan erzeugen.
2. Execute: Pro Plan-Step strukturierte Deliverables erzeugen und in `agent-output`-Nodes persistieren.
### Architekturgrenzen
- Scheduling, Status-Mutationen und Credit-Flow bleiben in `agents.ts`.
- Prompt-Aufbau liegt in `lib/agent-prompting.ts` (`summarizeIncomingContext`, `buildAnalyzeMessages`, `buildExecuteMessages`).
- Strukturvertraege und Normalisierung kommen aus `lib/agent-run-contract.ts`.
- Agent-Metadaten, Regeln und Blueprints kommen aus `lib/agent-definitions.ts`.
- Prompt-Segmente kommen aus `lib/generated/agent-doc-segments.ts` (generiert durch `scripts/compile-agent-docs.ts`).
Wichtig: `agents.ts` liest keine Raw-Markdown-Dateien zur Laufzeit.
### Strukturierte Output-Persistenz
Pro Step wird ein strukturierter Output gespeichert mit:
- `title`, `channel`, `artifactType`, `previewText`
- `sections[]` (`id`, `label`, `content`)
- `metadata` (`Record<string, string | string[]>`)
- `qualityChecks[]`
- `body` als Legacy-Fallback
---
## AI-Bild-Pipeline (`ai.ts`) ## AI-Bild-Pipeline (`ai.ts`)
``` ```

View File

@@ -18,20 +18,27 @@ import {
normalizeAgentBriefConstraints, normalizeAgentBriefConstraints,
normalizeAgentExecutionPlan, normalizeAgentExecutionPlan,
normalizeAgentLocale, normalizeAgentLocale,
normalizeAgentOutputDraft, normalizeAgentStructuredOutput,
type AgentLocale, type AgentLocale,
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
type AgentClarificationQuestion, type AgentClarificationQuestion,
type AgentExecutionStep, type AgentExecutionStep,
type AgentOutputDraft, type AgentOutputSection,
type AgentStructuredOutputDraft,
} from "../lib/agent-run-contract"; } from "../lib/agent-run-contract";
import {
buildAnalyzeMessages,
buildExecuteMessages,
summarizeIncomingContext,
type PromptContextNode,
} from "../lib/agent-prompting";
import { import {
DEFAULT_AGENT_MODEL_ID, DEFAULT_AGENT_MODEL_ID,
getAgentModel, getAgentModel,
isAgentModelAvailableForTier, isAgentModelAvailableForTier,
type AgentModel, type AgentModel,
} from "../lib/agent-models"; } from "../lib/agent-models";
import { getAgentTemplate } from "../lib/agent-templates"; import { getAgentDefinition } from "../lib/agent-definitions";
import { normalizePublicTier } from "../lib/tier-credits"; import { normalizePublicTier } from "../lib/tier-credits";
const ANALYZE_SCHEMA: Record<string, unknown> = { const ANALYZE_SCHEMA: Record<string, unknown> = {
@@ -64,35 +71,95 @@ const ANALYZE_SCHEMA: Record<string, unknown> = {
type: "array", type: "array",
minItems: 1, minItems: 1,
maxItems: 6, maxItems: 6,
items: { items: {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
required: ["id", "title", "channel", "outputType"], required: [
properties: { "id",
id: { type: "string" }, "title",
title: { type: "string" }, "channel",
channel: { type: "string" }, "outputType",
outputType: { type: "string" }, "artifactType",
}, "goal",
}, "requiredSections",
}, "qualityChecks",
}, ],
properties: {
id: { type: "string" },
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
artifactType: { type: "string" },
goal: { type: "string" },
requiredSections: {
type: "array",
items: { type: "string" },
},
qualityChecks: {
type: "array",
items: { type: "string" },
},
},
},
},
},
}, },
}, },
}; };
function buildExecuteSchema(stepIds: string[]): Record<string, unknown> { function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
const sectionSchema: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["id", "label", "content"],
properties: {
id: { type: "string" },
label: { type: "string" },
content: { type: "string" },
},
};
const metadataValueSchema: Record<string, unknown> = {
anyOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
},
],
};
const stepOutputProperties: Record<string, unknown> = {}; const stepOutputProperties: Record<string, unknown> = {};
for (const stepId of stepIds) { for (const stepId of stepIds) {
stepOutputProperties[stepId] = { stepOutputProperties[stepId] = {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
required: ["title", "channel", "outputType", "body"], required: [
"title",
"channel",
"artifactType",
"previewText",
"sections",
"metadata",
"qualityChecks",
],
properties: { properties: {
title: { type: "string" }, title: { type: "string" },
channel: { type: "string" }, channel: { type: "string" },
outputType: { type: "string" }, artifactType: { type: "string" },
body: { type: "string" }, previewText: { type: "string" },
sections: {
type: "array",
items: sectionSchema,
},
metadata: {
type: "object",
additionalProperties: metadataValueSchema,
},
qualityChecks: {
type: "array",
items: { type: "string" },
},
}, },
}; };
} }
@@ -113,14 +180,6 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
}; };
} }
function getOutputLanguageInstruction(locale: AgentLocale): string {
if (locale === "de") {
return "Write all generated fields in German (de-DE), including step titles, channel labels, output types, clarification prompts, and body content.";
}
return "Write all generated fields in English (en-US), including step titles, channel labels, output types, clarification prompts, and body content.";
}
type InternalApiShape = { type InternalApiShape = {
canvasGraph: { canvasGraph: {
getInternal: FunctionReference< getInternal: FunctionReference<
@@ -213,7 +272,9 @@ type InternalApiShape = {
{ {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
analysisSummary: string;
executionPlan: { summary: string; steps: AgentExecutionStep[] }; executionPlan: { summary: string; steps: AgentExecutionStep[] };
definitionVersion?: number;
}, },
{ outputNodeIds: Id<"nodes">[] } { outputNodeIds: Id<"nodes">[] }
>; >;
@@ -229,6 +290,13 @@ type InternalApiShape = {
title: string; title: string;
channel: string; channel: string;
outputType: string; outputType: string;
artifactType: string;
goal: string;
requiredSections: string[];
qualityChecks: string[];
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
body: string; body: string;
}, },
unknown unknown
@@ -256,8 +324,18 @@ type InternalApiShape = {
{ transactionId: Id<"creditTransactions"> }, { transactionId: Id<"creditTransactions"> },
unknown unknown
>; >;
checkAbuseLimits: FunctionReference<"mutation", "internal", {}, unknown>; checkAbuseLimits: FunctionReference<
incrementUsage: FunctionReference<"mutation", "internal", {}, unknown>; "mutation",
"internal",
Record<string, never>,
unknown
>;
incrementUsage: FunctionReference<
"mutation",
"internal",
Record<string, never>,
unknown
>;
decrementConcurrency: FunctionReference< decrementConcurrency: FunctionReference<
"mutation", "mutation",
"internal", "internal",
@@ -328,6 +406,172 @@ function normalizeClarificationQuestions(raw: unknown): AgentClarificationQuesti
return questions; return questions;
} }
function normalizeStringList(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const item of raw) {
const value = trimText(item);
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
function normalizeOptionalVersion(raw: unknown): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const normalized = Math.floor(raw);
return normalized > 0 ? normalized : undefined;
}
function buildSkeletonPreviewPlaceholder(title: string): string {
const normalizedTitle = trimText(title) || "this output";
return `Draft pending for ${normalizedTitle}.`;
}
function deriveLegacyBodyFallback(input: {
title: string;
previewText: string;
sections: AgentOutputSection[];
body: string;
}): string {
const normalizedBody = trimText(input.body);
if (normalizedBody) {
return normalizedBody;
}
if (input.sections.length > 0) {
return input.sections.map((section) => `${section.label}:\n${section.content}`).join("\n\n");
}
const normalizedPreview = trimText(input.previewText);
if (normalizedPreview) {
return normalizedPreview;
}
return trimText(input.title);
}
function resolveExecutionPlanSummary(input: {
executionPlanSummary: unknown;
analysisSummary: unknown;
}): string {
return trimText(input.executionPlanSummary) || trimText(input.analysisSummary);
}
function resolveFinalExecutionSummary(input: {
executionSummary: unknown;
modelSummary: unknown;
executionPlanSummary: unknown;
analysisSummary: unknown;
}): string {
return (
trimText(input.executionSummary) ||
trimText(input.modelSummary) ||
trimText(input.executionPlanSummary) ||
trimText(input.analysisSummary)
);
}
function getAnalyzeExecutionStepRequiredFields(): string[] {
const executionPlan = (ANALYZE_SCHEMA.properties as Record<string, unknown>).executionPlan as
| Record<string, unknown>
| undefined;
const steps = (executionPlan?.properties as Record<string, unknown> | undefined)?.steps as
| Record<string, unknown>
| undefined;
const items = steps?.items as Record<string, unknown> | undefined;
const required = items?.required;
return Array.isArray(required)
? required.filter((value): value is string => typeof value === "string")
: [];
}
function buildSkeletonOutputData(input: {
step: AgentExecutionStep;
stepIndex: number;
stepTotal: number;
definitionVersion?: number;
}) {
const definitionVersion = normalizeOptionalVersion(input.definitionVersion);
return {
isSkeleton: true,
stepId: input.step.id,
stepIndex: input.stepIndex,
stepTotal: input.stepTotal,
title: input.step.title,
channel: input.step.channel,
outputType: input.step.outputType,
artifactType: input.step.artifactType,
goal: input.step.goal,
requiredSections: input.step.requiredSections,
qualityChecks: input.step.qualityChecks,
previewText: buildSkeletonPreviewPlaceholder(input.step.title),
sections: [],
metadata: {},
body: "",
...(definitionVersion ? { definitionVersion } : {}),
};
}
function buildCompletedOutputData(input: {
step: AgentExecutionStep;
stepIndex: number;
stepTotal: number;
output: {
title: string;
channel: string;
artifactType: string;
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
qualityChecks: string[];
body: string;
};
}) {
const normalizedQualityChecks =
input.output.qualityChecks.length > 0
? normalizeStringList(input.output.qualityChecks)
: normalizeStringList(input.step.qualityChecks);
const normalizedSections = Array.isArray(input.output.sections) ? input.output.sections : [];
const normalizedPreviewText =
trimText(input.output.previewText) || trimText(normalizedSections[0]?.content);
return {
isSkeleton: false,
stepId: trimText(input.step.id),
stepIndex: Math.max(0, Math.floor(input.stepIndex)),
stepTotal: Math.max(1, Math.floor(input.stepTotal)),
title: trimText(input.output.title) || trimText(input.step.title),
channel: trimText(input.output.channel) || trimText(input.step.channel),
outputType: trimText(input.step.outputType),
artifactType: trimText(input.output.artifactType) || trimText(input.step.artifactType),
goal: trimText(input.step.goal),
requiredSections: normalizeStringList(input.step.requiredSections),
qualityChecks: normalizedQualityChecks,
previewText: normalizedPreviewText,
sections: normalizedSections,
metadata:
input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {},
body: deriveLegacyBodyFallback({
title: trimText(input.output.title) || trimText(input.step.title),
previewText: normalizedPreviewText,
sections: normalizedSections,
body: input.output.body,
}),
};
}
type AgentExecutionStepRuntime = AgentExecutionStep & { type AgentExecutionStepRuntime = AgentExecutionStep & {
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
stepIndex: number; stepIndex: number;
@@ -350,6 +594,10 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
const title = trimText(itemRecord.title); const title = trimText(itemRecord.title);
const channel = trimText(itemRecord.channel); const channel = trimText(itemRecord.channel);
const outputType = trimText(itemRecord.outputType); const outputType = trimText(itemRecord.outputType);
const artifactType = trimText(itemRecord.artifactType) || outputType;
const goal = trimText(itemRecord.goal) || "Deliver channel-ready output.";
const requiredSections = normalizeStringList(itemRecord.requiredSections);
const qualityChecks = normalizeStringList(itemRecord.qualityChecks);
const rawStepIndex = itemRecord.stepIndex; const rawStepIndex = itemRecord.stepIndex;
const rawStepTotal = itemRecord.stepTotal; const rawStepTotal = itemRecord.stepTotal;
const stepIndex = const stepIndex =
@@ -370,6 +618,10 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
title, title,
channel, channel,
outputType, outputType,
artifactType,
goal,
requiredSections,
qualityChecks,
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
stepIndex, stepIndex,
stepTotal, stepTotal,
@@ -379,47 +631,29 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
return steps.sort((a, b) => a.stepIndex - b.stepIndex); return steps.sort((a, b) => a.stepIndex - b.stepIndex);
} }
function serializeNodeDataForPrompt(data: unknown): string { function collectIncomingContextNodes(
if (data === undefined) {
return "{}";
}
try {
return JSON.stringify(data).slice(0, 1200);
} catch {
return "{}";
}
}
function collectIncomingContext(
graph: { nodes: Doc<"nodes">[]; edges: Doc<"edges">[] }, graph: { nodes: Doc<"nodes">[]; edges: Doc<"edges">[] },
agentNodeId: Id<"nodes">, agentNodeId: Id<"nodes">,
): string { ): PromptContextNode[] {
const nodeById = new Map(graph.nodes.map((node) => [node._id, node] as const)); const nodeById = new Map(graph.nodes.map((node) => [node._id, node] as const));
const incomingEdges = graph.edges.filter((edge) => edge.targetNodeId === agentNodeId); const incomingEdges = graph.edges.filter((edge) => edge.targetNodeId === agentNodeId);
if (incomingEdges.length === 0) { const nodes: PromptContextNode[] = [];
return "No incoming nodes connected to this agent.";
}
const lines: string[] = [];
for (const edge of incomingEdges) { for (const edge of incomingEdges) {
const source = nodeById.get(edge.sourceNodeId); const source = nodeById.get(edge.sourceNodeId);
if (!source) { if (!source) {
continue; continue;
} }
lines.push(
`- nodeId=${source._id}, type=${source.type}, status=${source.status}, data=${serializeNodeDataForPrompt(source.data)}`, nodes.push({
); nodeId: source._id,
type: source.type,
status: source.status,
data: source.data,
});
} }
return lines.length > 0 ? lines.join("\n") : "No incoming nodes connected to this agent."; return nodes;
}
function countIncomingContext(
graph: { edges: Doc<"edges">[] },
agentNodeId: Id<"nodes">,
): number {
return graph.edges.filter((edge) => edge.targetNodeId === agentNodeId).length;
} }
function getAgentNodeFromGraph( function getAgentNodeFromGraph(
@@ -489,6 +723,15 @@ function getSelectedModelOrThrow(modelId: string): AgentModel {
return selectedModel; return selectedModel;
} }
function getAgentDefinitionOrThrow(templateId: unknown) {
const resolvedId = trimText(templateId) || "campaign-distributor";
const definition = getAgentDefinition(resolvedId);
if (!definition) {
throw new Error(`Unknown agent definition: ${resolvedId}`);
}
return definition;
}
function assertAgentModelTier(model: AgentModel, tier: string | undefined): void { function assertAgentModelTier(model: AgentModel, tier: string | undefined): void {
const normalizedTier = normalizePublicTier(tier); const normalizedTier = normalizePublicTier(tier);
if (!isAgentModelAvailableForTier(normalizedTier, model.id)) { if (!isAgentModelAvailableForTier(normalizedTier, model.id)) {
@@ -523,7 +766,9 @@ export const setAgentAnalyzing = internalMutation({
modelId: args.modelId, modelId: args.modelId,
reservationId: args.reservationId, reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency, shouldDecrementConcurrency: args.shouldDecrementConcurrency,
analysisSummary: undefined,
executionPlanSummary: undefined, executionPlanSummary: undefined,
executionSummary: undefined,
executionSteps: [], executionSteps: [],
}, },
}); });
@@ -589,6 +834,8 @@ export const createExecutionSkeletonOutputs = internalMutation({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
analysisSummary: v.string(),
definitionVersion: v.optional(v.number()),
executionPlan: v.object({ executionPlan: v.object({
summary: v.string(), summary: v.string(),
steps: v.array( steps: v.array(
@@ -597,6 +844,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: v.string(), title: v.string(),
channel: v.string(), channel: v.string(),
outputType: v.string(), outputType: v.string(),
artifactType: v.string(),
goal: v.string(),
requiredSections: v.array(v.string()),
qualityChecks: v.array(v.string()),
}), }),
), ),
}), }),
@@ -630,6 +881,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: string; title: string;
channel: string; channel: string;
outputType: string; outputType: string;
artifactType: string;
goal: string;
requiredSections: string[];
qualityChecks: string[];
}> = []; }> = [];
for (let index = 0; index < args.executionPlan.steps.length; index += 1) { for (let index = 0; index < args.executionPlan.steps.length; index += 1) {
@@ -643,16 +898,12 @@ export const createExecutionSkeletonOutputs = internalMutation({
height: 260, height: 260,
status: "executing", status: "executing",
retryCount: 0, retryCount: 0,
data: { data: buildSkeletonOutputData({
isSkeleton: true, step,
stepId: step.id,
stepIndex: index, stepIndex: index,
stepTotal, stepTotal,
title: step.title, definitionVersion: args.definitionVersion,
channel: step.channel, }),
outputType: step.outputType,
body: "",
},
}); });
outputNodeIds.push(outputNodeId); outputNodeIds.push(outputNodeId);
@@ -664,6 +915,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: step.title, title: step.title,
channel: step.channel, channel: step.channel,
outputType: step.outputType, outputType: step.outputType,
artifactType: step.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks: step.qualityChecks,
}); });
await ctx.db.insert("edges", { await ctx.db.insert("edges", {
@@ -678,7 +933,11 @@ export const createExecutionSkeletonOutputs = internalMutation({
await ctx.db.patch(args.nodeId, { await ctx.db.patch(args.nodeId, {
data: { data: {
...prev, ...prev,
executionPlanSummary: trimText(args.executionPlan.summary), analysisSummary: trimText(args.analysisSummary),
executionPlanSummary: resolveExecutionPlanSummary({
executionPlanSummary: args.executionPlan.summary,
analysisSummary: args.analysisSummary,
}),
executionSteps: runtimeSteps, executionSteps: runtimeSteps,
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds], outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
}, },
@@ -704,6 +963,19 @@ export const completeExecutionStepOutput = internalMutation({
title: v.string(), title: v.string(),
channel: v.string(), channel: v.string(),
outputType: v.string(), outputType: v.string(),
artifactType: v.string(),
goal: v.string(),
requiredSections: v.array(v.string()),
qualityChecks: v.array(v.string()),
previewText: v.string(),
sections: v.array(
v.object({
id: v.string(),
label: v.string(),
content: v.string(),
}),
),
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
body: v.string(), body: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -726,20 +998,36 @@ export const completeExecutionStepOutput = internalMutation({
throw new Error("Output node does not belong to the same canvas"); throw new Error("Output node does not belong to the same canvas");
} }
const normalizedOutputData = buildCompletedOutputData({
step: {
id: args.stepId,
title: args.title,
channel: args.channel,
outputType: args.outputType,
artifactType: args.artifactType,
goal: args.goal,
requiredSections: args.requiredSections,
qualityChecks: args.qualityChecks,
},
stepIndex: args.stepIndex,
stepTotal: args.stepTotal,
output: {
title: args.title,
channel: args.channel,
artifactType: args.artifactType,
previewText: args.previewText,
sections: args.sections,
metadata: args.metadata,
qualityChecks: args.qualityChecks,
body: args.body,
},
});
await ctx.db.patch(args.outputNodeId, { await ctx.db.patch(args.outputNodeId, {
status: "done", status: "done",
statusMessage: undefined, statusMessage: undefined,
retryCount: 0, retryCount: 0,
data: { data: normalizedOutputData,
isSkeleton: false,
stepId: trimText(args.stepId),
stepIndex: Math.max(0, Math.floor(args.stepIndex)),
stepTotal: Math.max(1, Math.floor(args.stepTotal)),
title: trimText(args.title),
channel: trimText(args.channel),
outputType: trimText(args.outputType),
body: trimText(args.body),
},
}); });
}, },
}); });
@@ -831,7 +1119,18 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
...prev, ...prev,
clarificationQuestions: [], clarificationQuestions: [],
outputNodeIds: existingOutputNodeIds, outputNodeIds: existingOutputNodeIds,
lastRunSummary: trimText(args.summary), executionSummary: resolveFinalExecutionSummary({
executionSummary: prev.executionSummary,
modelSummary: args.summary,
executionPlanSummary: prev.executionPlanSummary,
analysisSummary: prev.analysisSummary,
}),
lastRunSummary: resolveFinalExecutionSummary({
executionSummary: prev.executionSummary,
modelSummary: args.summary,
executionPlanSummary: prev.executionPlanSummary,
analysisSummary: prev.analysisSummary,
}),
reservationId: undefined, reservationId: undefined,
shouldDecrementConcurrency: undefined, shouldDecrementConcurrency: undefined,
}, },
@@ -870,12 +1169,13 @@ export const analyzeAgent = internalAction({
}); });
const agentNode = getAgentNodeFromGraph(graph, args.nodeId); const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
const agentData = getNodeDataRecord(agentNode.data); const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor"); const definition = getAgentDefinitionOrThrow(agentData.templateId);
const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers); const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale); const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints); const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId); const incomingContextNodes = collectIncomingContextNodes(graph, args.nodeId);
const incomingContextCount = countIncomingContext(graph, args.nodeId); const incomingContext = summarizeIncomingContext(incomingContextNodes);
const incomingContextCount = incomingContextNodes.length;
const preflightClarificationQuestions = buildPreflightClarificationQuestions({ const preflightClarificationQuestions = buildPreflightClarificationQuestions({
briefConstraints, briefConstraints,
@@ -902,29 +1202,14 @@ export const analyzeAgent = internalAction({
model: args.modelId, model: args.modelId,
schemaName: "agent_analyze_result", schemaName: "agent_analyze_result",
schema: ANALYZE_SCHEMA, schema: ANALYZE_SCHEMA,
messages: [ messages: buildAnalyzeMessages({
{ definition,
role: "system", locale,
content: briefConstraints,
[ clarificationAnswers: existingAnswers,
"You are the LemonSpace Agent Analyzer. Inspect incoming canvas context and decide if clarification is required before execution. Ask only necessary short questions.", incomingContextSummary: incomingContext,
getOutputLanguageInstruction(locale), incomingContextCount,
].join(" "), }),
},
{
role: "user",
content: [
`Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
"Incoming node context:",
incomingContext,
`Incoming context node count: ${incomingContextCount}`,
`Current clarification answers: ${JSON.stringify(existingAnswers)}`,
"Return structured JSON matching the schema.",
].join("\n\n"),
},
],
}); });
const clarificationQuestions = normalizeClarificationQuestions( const clarificationQuestions = normalizeClarificationQuestions(
@@ -950,6 +1235,8 @@ export const analyzeAgent = internalAction({
await ctx.runMutation(internalApi.agents.createExecutionSkeletonOutputs, { await ctx.runMutation(internalApi.agents.createExecutionSkeletonOutputs, {
canvasId: args.canvasId, canvasId: args.canvasId,
nodeId: args.nodeId, nodeId: args.nodeId,
analysisSummary: trimText(analysis.analysisSummary),
definitionVersion: definition.version,
executionPlan, executionPlan,
}); });
@@ -1001,12 +1288,16 @@ export const executeAgent = internalAction({
}); });
const agentNode = getAgentNodeFromGraph(graph, args.nodeId); const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
const agentData = getNodeDataRecord(agentNode.data); const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor"); const definition = getAgentDefinitionOrThrow(agentData.templateId);
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers); const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale); const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints); const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId); const incomingContextNodes = collectIncomingContextNodes(graph, args.nodeId);
const executionPlanSummary = trimText(agentData.executionPlanSummary); const incomingContext = summarizeIncomingContext(incomingContextNodes);
const executionPlanSummary = resolveExecutionPlanSummary({
executionPlanSummary: agentData.executionPlanSummary,
analysisSummary: agentData.analysisSummary,
});
const executionSteps = normalizeExecutionSteps(agentData.executionSteps); const executionSteps = normalizeExecutionSteps(agentData.executionSteps);
if (executionSteps.length === 0) { if (executionSteps.length === 0) {
@@ -1017,42 +1308,31 @@ export const executeAgent = internalAction({
const execution = await generateStructuredObjectViaOpenRouter<{ const execution = await generateStructuredObjectViaOpenRouter<{
summary: string; summary: string;
stepOutputs: Record<string, AgentOutputDraft>; stepOutputs: Record<string, AgentStructuredOutputDraft>;
}>(apiKey, { }>(apiKey, {
model: args.modelId, model: args.modelId,
schemaName: "agent_execute_result", schemaName: "agent_execute_result",
schema: executeSchema, schema: executeSchema,
messages: [ messages: buildExecuteMessages({
{ definition,
role: "system", locale,
content: briefConstraints,
[ clarificationAnswers,
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.", incomingContextSummary: incomingContext,
getOutputLanguageInstruction(locale), executionPlan: {
].join(" "), summary: executionPlanSummary,
steps: executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
artifactType: step.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks: step.qualityChecks,
})),
}, },
{ }),
role: "user",
content: [
`Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
`Analyze summary: ${executionPlanSummary}`,
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
`Execution steps: ${JSON.stringify(
executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
})),
)}`,
"Incoming node context:",
incomingContext,
"Return structured JSON matching the schema.",
].join("\n\n"),
},
],
}); });
const stepOutputs = const stepOutputs =
@@ -1072,11 +1352,10 @@ export const executeAgent = internalAction({
throw new Error(`Missing execution output for step ${step.id}`); throw new Error(`Missing execution output for step ${step.id}`);
} }
const normalized = normalizeAgentOutputDraft({ const normalized = normalizeAgentStructuredOutput(rawOutput, {
...rawOutput, title: step.title,
title: trimText(rawOutput.title) || step.title, channel: step.channel,
channel: trimText(rawOutput.channel) || step.channel, artifactType: step.artifactType,
outputType: trimText(rawOutput.outputType) || step.outputType,
}); });
await ctx.runMutation(internalApi.agents.completeExecutionStepOutput, { await ctx.runMutation(internalApi.agents.completeExecutionStepOutput, {
@@ -1087,7 +1366,15 @@ export const executeAgent = internalAction({
stepTotal: step.stepTotal, stepTotal: step.stepTotal,
title: normalized.title, title: normalized.title,
channel: normalized.channel, channel: normalized.channel,
outputType: normalized.outputType, outputType: step.outputType,
artifactType: normalized.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks:
normalized.qualityChecks.length > 0 ? normalized.qualityChecks : step.qualityChecks,
previewText: normalized.previewText,
sections: normalized.sections,
metadata: normalized.metadata,
body: normalized.body, body: normalized.body,
}); });
} }
@@ -1116,6 +1403,14 @@ export const executeAgent = internalAction({
}, },
}); });
export const __testables = {
buildSkeletonOutputData,
buildCompletedOutputData,
getAnalyzeExecutionStepRequiredFields,
resolveExecutionPlanSummary,
resolveFinalExecutionSummary,
};
export const runAgent = action({ export const runAgent = action({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),

View File

@@ -9,7 +9,7 @@ import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits"
const DEFAULT_TIER = "free" as const; const DEFAULT_TIER = "free" as const;
const DEFAULT_SUBSCRIPTION_STATUS = "active" as const; const DEFAULT_SUBSCRIPTION_STATUS = "active" as const;
const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8; const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8;
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200; const MEDIA_LIBRARY_DEFAULT_LIMIT = 8;
const MEDIA_LIBRARY_MIN_LIMIT = 1; const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500; const MEDIA_LIBRARY_MAX_LIMIT = 500;
const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4; const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4;
@@ -167,6 +167,14 @@ function normalizeMediaLibraryLimit(limit: number | undefined): number {
return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit))); return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit)));
} }
function normalizeMediaLibraryPage(page: number): number {
if (!Number.isFinite(page)) {
return 1;
}
return Math.max(1, Math.floor(page));
}
async function buildMediaPreviewFromNodeFallback( async function buildMediaPreviewFromNodeFallback(
ctx: QueryCtx, ctx: QueryCtx,
canvases: Array<Doc<"canvases">>, canvases: Array<Doc<"canvases">>,
@@ -312,35 +320,59 @@ export const getSnapshot = query({
export const listMediaLibrary = query({ export const listMediaLibrary = query({
args: { args: {
limit: v.optional(v.number()), page: v.number(),
pageSize: v.optional(v.number()),
kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))), kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))),
}, },
handler: async (ctx, { limit, kindFilter }) => { handler: async (ctx, { page, pageSize, kindFilter }) => {
const normalizedPage = normalizeMediaLibraryPage(page);
const normalizedPageSize = normalizeMediaLibraryLimit(pageSize);
const user = await optionalAuth(ctx); const user = await optionalAuth(ctx);
if (!user) { if (!user) {
return []; return {
items: [],
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages: 0,
totalCount: 0,
};
} }
const normalizedLimit = normalizeMediaLibraryLimit(limit);
const baseTake = Math.max(normalizedLimit * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedLimit);
const mediaArchiveRows = kindFilter const mediaArchiveRows = kindFilter
? await ctx.db ? await ctx.db
.query("mediaItems") .query("mediaItems")
.withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter)) .withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter))
.order("desc") .order("desc")
.take(baseTake) .collect()
: await ctx.db : await ctx.db
.query("mediaItems") .query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc") .order("desc")
.take(baseTake); .collect();
const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, normalizedLimit, kindFilter); const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, mediaArchiveRows.length, kindFilter);
if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) { if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) {
return mediaFromArchive; const totalCount = mediaFromArchive.length;
const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0;
const offset = (normalizedPage - 1) * normalizedPageSize;
return {
items: mediaFromArchive.slice(offset, offset + normalizedPageSize),
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages,
totalCount,
};
} }
if (kindFilter && kindFilter !== "image") { if (kindFilter && kindFilter !== "image") {
return []; return {
items: [],
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages: 0,
totalCount: 0,
};
} }
const canvases = await ctx.db const canvases = await ctx.db
@@ -349,6 +381,21 @@ export const listMediaLibrary = query({
.order("desc") .order("desc")
.collect(); .collect();
return await buildMediaPreviewFromNodeFallback(ctx, canvases, normalizedLimit); const mediaFromNodeFallback = await buildMediaPreviewFromNodeFallback(
ctx,
canvases,
Math.max(normalizedPage * normalizedPageSize * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedPageSize),
);
const totalCount = mediaFromNodeFallback.length;
const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0;
const offset = (normalizedPage - 1) * normalizedPageSize;
return {
items: mediaFromNodeFallback.slice(offset, offset + normalizedPageSize),
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages,
totalCount,
};
}, },
}); });

128
docs/agents/authoring.md Normal file
View File

@@ -0,0 +1,128 @@
# Agent Authoring Guide
This guide describes how to add or evolve agents after the runtime redesign.
## Acceptance Checklist (Task Gate)
Before shipping agent doc or definition changes, confirm all points are documented in the PR notes:
- Runtime definition location and ownership.
- How markdown prompt segments affect prompts through compile-time extraction.
- What a new agent must define in TypeScript.
- Expected structured output shape (`artifactType`, `sections`, `metadata`, `qualityChecks`, `previewText`, `body`).
- Tests added for definitions, prompt compilation, prompting, and contracts.
## Dual Model: Source of Truth Rules
The agent runtime uses two complementary sources of truth:
1. Structural/runtime contract in TypeScript.
- `lib/agent-definitions.ts`: registry (`AGENT_DEFINITIONS`), metadata, rules, blueprints, docs path.
- `lib/agent-run-contract.ts`: normalization and safety for plans, clarifications, and structured outputs.
- `lib/agent-templates.ts`: UI projection derived from definitions.
2. Curated prompt influence in markdown.
- `components/agents/<agent-id>.md` contains authored narrative plus marked prompt segments.
- `scripts/compile-agent-docs.ts` extracts only marked blocks.
- `lib/generated/agent-doc-segments.ts` is generated and consumed at runtime.
No free-form markdown parsing happens in `convex/agents.ts`.
## Add a New Agent
1. Add a definition in `lib/agent-definitions.ts`.
- Add a new `AgentDefinitionId` union member.
- Add a new item in `AGENT_DEFINITIONS` with metadata, rules, accepted source types, and `defaultOutputBlueprints`.
- Set `docs.markdownPath` to the companion markdown file.
2. Add companion markdown in `components/agents/<agent-id>.md`.
3. Compile prompt segments into `lib/generated/agent-doc-segments.ts`.
4. Add i18n entries for template and output labels in `messages/de.json` and `messages/en.json` when needed.
5. Add or update tests (see test matrix below).
## Marker Rules (Required)
Every agent markdown file must include exactly one start marker and one end marker for each required key:
- `role`
- `style-rules`
- `decision-framework`
- `channel-notes`
Marker format:
```md
<!-- AGENT_PROMPT_SEGMENT:role:start -->
Your segment text.
<!-- AGENT_PROMPT_SEGMENT:role:end -->
```
Constraints:
- Marker names must match exactly.
- Missing, duplicate, or empty segments fail compilation.
- Segment order in output is deterministic and follows `AGENT_PROMPT_SEGMENT_KEYS`.
- Unmarked prose is ignored by the runtime prompt payload.
## Compile Flow
Run the compiler after changing any `components/agents/*.md` marker content or any `docs.markdownPath` entry:
```bash
npx tsx scripts/compile-agent-docs.ts
```
Then verify generated output changed as expected:
- `lib/generated/agent-doc-segments.ts`
## Structured Output Expectations
Execution payloads are normalized via `normalizeAgentStructuredOutput(...)` in `lib/agent-run-contract.ts`.
The runtime expects:
- `title`
- `channel`
- `artifactType`
- `previewText`
- `sections[]` (`id`, `label`, `content`)
- `metadata` (`Record<string, string | string[]>`)
- `qualityChecks[]`
Compatibility behavior:
- `body` stays as a legacy fallback string for old render paths.
- Missing/invalid entries are normalized defensively.
## Test Matrix for Agent Changes
For a new agent or blueprint change, add or update tests in these areas:
- Definition registry and projection
- `tests/lib/agent-definitions.test.ts`
- `tests/lib/agent-templates.test.ts`
- Markdown marker extraction and generated segment coverage
- `tests/lib/agent-doc-segments.test.ts`
- Prompt assembly behavior
- `tests/lib/agent-prompting.test.ts`
- Runtime contracts (execution plan + structured output normalization)
- `tests/lib/agent-run-contract.test.ts`
- `tests/lib/agent-structured-output.test.ts`
- Convex orchestration behavior when contract semantics change
- `tests/convex/agent-orchestration-contract.test.ts`
## When `convex/agents.ts` Stays Unchanged
Do not modify `convex/agents.ts` when you only:
- add a new definition entry in `lib/agent-definitions.ts`
- add/update markdown prompt segments and recompile
- add or adjust output blueprints using existing contract fields
- tune analysis/execution rules in definitions
- update UI copy or i18n labels
Modify `convex/agents.ts` only when orchestration semantics change, for example:
- new runtime stage/state transitions
- credit/scheduler behavior changes
- schema shape changes for analyze or execute payloads
- persistence shape changes beyond current normalized contracts

View File

@@ -13,7 +13,11 @@ 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 | | `agent-definitions.ts` | Runtime-Registry fuer Agent-Definitionen (Struktur, Regeln, Blueprints, Docs-Pfad) |
| `agent-templates.ts` | UI-Projektion auf Agent-Metadaten aus `agent-definitions.ts` |
| `agent-prompting.ts` | Pure Prompt-Builder (`summarizeIncomingContext`, `buildAnalyzeMessages`, `buildExecuteMessages`) |
| `agent-run-contract.ts` | Normalisierung fuer Clarifications, Execution Plan und strukturierte Agent-Outputs |
| `generated/agent-doc-segments.ts` | Generierte Prompt-Segmente aus `components/agents/*.md` (nicht manuell editieren) |
| `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) |
@@ -39,6 +43,21 @@ Geteilte Hilfsfunktionen, Typ-Definitionen und Konfiguration. Keine React-Kompon
--- ---
## Agent Runtime: Dual Model
Die Agent-Runtime folgt einem dualen Modell:
- **TS-Vertraege als Struktur-Single-Source:** `lib/agent-definitions.ts` + `lib/agent-run-contract.ts` definieren IDs, Regeln, Blueprints und Normalisierung.
- **Markdown-Segmente als kuratierter Prompt-Input:** markierte Segmente in `components/agents/*.md` werden via `scripts/compile-agent-docs.ts` in `lib/generated/agent-doc-segments.ts` kompiliert.
Wichtig:
- `convex/agents.ts` liest nur die generierte TS-Datei, nicht Raw-Markdown.
- Nur markierte `AGENT_PROMPT_SEGMENT`-Bloecke beeinflussen Analyze/Execute-Prompts.
- `agent-templates.ts` ist bewusst nur eine UI-Projektion aus `agent-definitions.ts`.
---
## `canvas-utils.ts` — Wichtigste Datei ## `canvas-utils.ts` — Wichtigste Datei
Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `components/canvas/CLAUDE.md`. Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `components/canvas/CLAUDE.md`.

207
lib/agent-definitions.ts Normal file
View File

@@ -0,0 +1,207 @@
export type AgentDefinitionId = "campaign-distributor";
export type AgentOutputBlueprint = {
artifactType: string;
requiredSections: readonly string[];
requiredMetadataKeys: readonly string[];
qualityChecks: readonly string[];
};
export type AgentOperatorParameter = {
key: string;
label: string;
type: "multi-select" | "select";
options: readonly string[];
defaultValue: string | readonly string[];
description: string;
};
export type AgentDefinition = {
id: AgentDefinitionId;
version: number;
metadata: {
name: string;
description: string;
emoji: string;
color: string;
vibe: string;
};
docs: {
markdownPath: string;
};
acceptedSourceNodeTypes: readonly string[];
briefFieldOrder: readonly string[];
channelCatalog: readonly string[];
operatorParameters: readonly AgentOperatorParameter[];
analysisRules: readonly string[];
executionRules: readonly string[];
defaultOutputBlueprints: readonly AgentOutputBlueprint[];
uiReference: {
tools: readonly string[];
expectedInputs: readonly string[];
expectedOutputs: readonly string[];
notes: readonly string[];
};
};
export const AGENT_DEFINITIONS: readonly AgentDefinition[] = [
{
id: "campaign-distributor",
version: 1,
metadata: {
name: "Campaign Distributor",
description:
"Turns LemonSpace visual variants and optional campaign context into channel-native distribution packages.",
emoji: "lemon",
color: "yellow",
vibe: "Transforms canvas outputs into channel-native campaign content that can ship immediately.",
},
docs: {
markdownPath: "components/agents/campaign-distributor.md",
},
acceptedSourceNodeTypes: [
"image",
"asset",
"video",
"text",
"note",
"frame",
"compare",
"render",
"ai-image",
"ai-video",
],
briefFieldOrder: [
"briefing",
"audience",
"tone",
"targetChannels",
"hardConstraints",
],
channelCatalog: [
"Instagram Feed",
"Instagram Stories",
"Instagram Reels",
"LinkedIn",
"X (Twitter)",
"TikTok",
"Pinterest",
"WhatsApp Business",
"Telegram",
"E-Mail Newsletter",
"Discord",
],
operatorParameters: [
{
key: "targetChannels",
label: "Target channels",
type: "multi-select",
options: [
"Instagram Feed",
"Instagram Stories",
"Instagram Reels",
"LinkedIn",
"X (Twitter)",
"TikTok",
"Pinterest",
"WhatsApp Business",
"Telegram",
"E-Mail Newsletter",
"Discord",
],
defaultValue: ["Instagram Feed", "LinkedIn", "E-Mail Newsletter"],
description: "Controls which channels receive one structured output each.",
},
{
key: "variantsPerChannel",
label: "Variants per channel",
type: "select",
options: ["1", "2", "3"],
defaultValue: "1",
description: "Controls how many alternative copy variants are produced per selected channel.",
},
{
key: "toneOverride",
label: "Tone override",
type: "select",
options: ["auto", "professional", "casual", "inspiring", "direct"],
defaultValue: "auto",
description: "Forces a global tone while still adapting output to channel format constraints.",
},
],
analysisRules: [
"Validate that at least one visual source is present and request clarification only when required context is missing.",
"Detect output language from briefing context and default to English when ambiguous.",
"Assign assets to channels by format fit and visual intent, and surface assignment rationale.",
"Produce one execution step per selected channel with explicit goal, sections, and quality checks.",
"Record assumptions whenever brief details are missing, and never hide uncertainty.",
],
executionRules: [
"Generate one structured output payload per execution step and keep titles channel-specific.",
"Respect requiredSections and requiredMetadataKeys for the selected blueprint.",
"Keep language and tone aligned with brief constraints and toneOverride settings.",
"State format mismatches explicitly and provide a practical remediation note.",
"Return qualityChecks as explicit user-visible claims, not hidden reasoning.",
],
defaultOutputBlueprints: [
{
artifactType: "social-caption-pack",
requiredSections: ["Hook", "Caption", "Hashtags", "CTA", "Format note"],
requiredMetadataKeys: [
"objective",
"targetAudience",
"channel",
"assetRef",
"language",
"tone",
"recommendedFormat",
],
qualityChecks: [
"matches_channel_constraints",
"uses_clear_cta",
"references_assigned_asset",
"avoids_unverified_claims",
],
},
{
artifactType: "messenger-copy",
requiredSections: ["Opening", "Message", "CTA", "Format note"],
requiredMetadataKeys: ["objective", "channel", "assetRef", "language", "sendWindow"],
qualityChecks: ["fits_channel_tone", "contains_one_clear_action", "is_high_signal"],
},
{
artifactType: "newsletter-block",
requiredSections: ["Subject", "Preview line", "Body block", "CTA"],
requiredMetadataKeys: ["objective", "channel", "assetRef", "language", "recommendedSendTime"],
qualityChecks: ["is_publish_ready", "respects_reader_time", "contains_single_primary_cta"],
},
],
uiReference: {
tools: ["WebFetch", "WebSearch", "Read", "Write", "Edit"],
expectedInputs: [
"Visual node outputs (image, ai-image, render, compare)",
"Optional briefing context (text, note)",
"Asset labels, prompts, dimensions, and format hints",
],
expectedOutputs: [
"Per-channel structured delivery packages",
"Asset assignment rationale",
"Channel-ready captions, CTA, and format notes",
"Newsletter-ready subject, preview line, and body block",
],
notes: [
"Primary outputs are structured agent-output nodes, not raw ai-text nodes.",
"Language defaults to English when briefing language is ambiguous.",
"Assumptions must be explicit when required context is missing.",
],
},
},
] as const;
const AGENT_DEFINITION_BY_ID = new Map<AgentDefinitionId, AgentDefinition>(
AGENT_DEFINITIONS.map((definition) => [definition.id, definition]),
);
export function getAgentDefinition(id: string): AgentDefinition | undefined {
return AGENT_DEFINITION_BY_ID.get(id as AgentDefinitionId);
}

253
lib/agent-prompting.ts Normal file
View File

@@ -0,0 +1,253 @@
import type { AgentDefinition } from "@/lib/agent-definitions";
import type {
AgentBriefConstraints,
AgentClarificationAnswerMap,
AgentExecutionPlan,
AgentLocale,
} from "@/lib/agent-run-contract";
import {
AGENT_DOC_SEGMENTS,
type AgentDocPromptSegments,
} from "@/lib/generated/agent-doc-segments";
export type OpenRouterMessage = {
role: "system" | "user" | "assistant";
content: string;
};
export type PromptContextNode = {
nodeId: string;
type: string;
status?: string;
data?: unknown;
};
const PROMPT_SEGMENT_ORDER = ["role", "style-rules", "decision-framework", "channel-notes"] as const;
const PROMPT_DATA_WHITELIST: Record<string, readonly string[]> = {
image: ["url", "mimeType", "width", "height", "prompt"],
asset: ["url", "mimeType", "width", "height", "title"],
video: ["url", "durationSeconds", "width", "height"],
text: ["content"],
note: ["content", "color"],
frame: ["label", "exportWidth", "exportHeight", "backgroundColor"],
compare: ["leftNodeId", "rightNodeId", "sliderPosition"],
render: ["url", "format", "width", "height"],
"ai-image": ["prompt", "model", "modelTier", "creditCost"],
"ai-video": ["prompt", "model", "modelLabel", "durationSeconds", "creditCost"],
};
function trimText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function formatScalarValue(value: unknown): string {
if (typeof value === "string") {
return value.trim().replace(/\s+/g, " ").slice(0, 220);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
function formatJsonBlock(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function resolvePromptSegments(
definition: AgentDefinition,
provided?: AgentDocPromptSegments,
): AgentDocPromptSegments {
if (provided) {
return provided;
}
const generated = AGENT_DOC_SEGMENTS[definition.id];
if (generated) {
return generated;
}
return {
role: "",
"style-rules": "",
"decision-framework": "",
"channel-notes": "",
};
}
function extractWhitelistedFields(nodeType: string, data: unknown): Array<{ key: string; value: string }> {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return [];
}
const record = data as Record<string, unknown>;
const keys = PROMPT_DATA_WHITELIST[nodeType] ?? [];
const fields: Array<{ key: string; value: string }> = [];
for (const key of keys) {
const value = formatScalarValue(record[key]);
if (!value) {
continue;
}
fields.push({ key, value });
}
return fields;
}
function formatPromptSegments(segments: AgentDocPromptSegments): string {
return PROMPT_SEGMENT_ORDER.map((key) => `${key}:\n${segments[key]}`).join("\n\n");
}
function getOutputLanguageInstruction(locale: AgentLocale): string {
if (locale === "de") {
return "Write all generated fields in German (de-DE), including step titles, channel labels, output types, clarification prompts, and body content.";
}
return "Write all generated fields in English (en-US), including step titles, channel labels, output types, clarification prompts, and body content.";
}
function formatBlueprintHints(definition: AgentDefinition): string {
return definition.defaultOutputBlueprints
.map((blueprint, index) => {
const requiredSections = blueprint.requiredSections.join(", ") || "none";
const requiredMetadataKeys = blueprint.requiredMetadataKeys.join(", ") || "none";
const qualityChecks = blueprint.qualityChecks.join(", ") || "none";
return [
`${index + 1}. artifactType=${blueprint.artifactType}`,
`requiredSections=${requiredSections}`,
`requiredMetadataKeys=${requiredMetadataKeys}`,
`qualityChecks=${qualityChecks}`,
].join("; ");
})
.join("\n");
}
export function summarizeIncomingContext(nodes: PromptContextNode[]): string {
if (nodes.length === 0) {
return "No incoming nodes connected to this agent.";
}
const sorted = [...nodes].sort((left, right) => {
if (left.nodeId !== right.nodeId) {
return left.nodeId.localeCompare(right.nodeId);
}
return left.type.localeCompare(right.type);
});
const lines: string[] = [`Incoming context nodes: ${sorted.length}`];
for (let index = 0; index < sorted.length; index += 1) {
const node = sorted[index];
const status = trimText(node.status) || "unknown";
lines.push(`${index + 1}. nodeId=${node.nodeId}, type=${node.type}, status=${status}`);
const fields = extractWhitelistedFields(node.type, node.data);
if (fields.length === 0) {
lines.push(" data: (no whitelisted fields)");
continue;
}
for (const field of fields) {
lines.push(` - ${field.key}: ${field.value}`);
}
}
return lines.join("\n");
}
export function buildAnalyzeMessages(input: {
definition: AgentDefinition;
locale: AgentLocale;
briefConstraints: AgentBriefConstraints;
clarificationAnswers: AgentClarificationAnswerMap;
incomingContextSummary: string;
incomingContextCount: number;
promptSegments?: AgentDocPromptSegments;
}): OpenRouterMessage[] {
const segments = resolvePromptSegments(input.definition, input.promptSegments);
return [
{
role: "system",
content: [
`You are the LemonSpace Agent Analyzer for ${input.definition.metadata.name}.`,
input.definition.metadata.description,
getOutputLanguageInstruction(input.locale),
"Use the following compiled prompt segments:",
formatPromptSegments(segments),
`analysis rules:\n- ${input.definition.analysisRules.join("\n- ")}`,
`brief field order: ${input.definition.briefFieldOrder.join(", ")}`,
`default output blueprints:\n${formatBlueprintHints(input.definition)}`,
"Return structured JSON matching the schema.",
].join("\n\n"),
},
{
role: "user",
content: [
`Brief + constraints:\n${formatJsonBlock(input.briefConstraints)}`,
`Current clarification answers:\n${formatJsonBlock(input.clarificationAnswers)}`,
`Incoming context node count: ${input.incomingContextCount}`,
"Incoming node context summary:",
input.incomingContextSummary,
].join("\n\n"),
},
];
}
function formatExecutionRequirements(plan: AgentExecutionPlan): string {
return plan.steps
.map((step, index) => {
const sections = step.requiredSections.join(", ") || "none";
const checks = step.qualityChecks.join(", ") || "none";
return [
`${index + 1}. id=${step.id}`,
`title: ${step.title}`,
`channel: ${step.channel}`,
`outputType: ${step.outputType}`,
`artifactType: ${step.artifactType}`,
`goal: ${step.goal}`,
`requiredSections: ${sections}`,
`qualityChecks: ${checks}`,
].join("; ");
})
.join("\n");
}
export function buildExecuteMessages(input: {
definition: AgentDefinition;
locale: AgentLocale;
briefConstraints: AgentBriefConstraints;
clarificationAnswers: AgentClarificationAnswerMap;
incomingContextSummary: string;
executionPlan: AgentExecutionPlan;
promptSegments?: AgentDocPromptSegments;
}): OpenRouterMessage[] {
const segments = resolvePromptSegments(input.definition, input.promptSegments);
return [
{
role: "system",
content: [
`You are the LemonSpace Agent Executor for ${input.definition.metadata.name}.`,
getOutputLanguageInstruction(input.locale),
"Use the following compiled prompt segments:",
formatPromptSegments(segments),
`execution rules:\n- ${input.definition.executionRules.join("\n- ")}`,
"Return one output payload per execution step keyed by step id.",
].join("\n\n"),
},
{
role: "user",
content: [
`Brief + constraints:\n${formatJsonBlock(input.briefConstraints)}`,
`Clarification answers:\n${formatJsonBlock(input.clarificationAnswers)}`,
`Execution plan summary: ${input.executionPlan.summary}`,
`Per-step requirements:\n${formatExecutionRequirements(input.executionPlan)}`,
"Incoming node context summary:",
input.incomingContextSummary,
].join("\n\n"),
},
];
}

View File

@@ -13,11 +13,39 @@ export type AgentOutputDraft = {
body?: string; body?: string;
}; };
export type AgentOutputSection = {
id: string;
label: string;
content: string;
};
export type AgentStructuredOutput = {
title: string;
channel: string;
artifactType: string;
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
qualityChecks: string[];
body: string;
};
export type AgentStructuredOutputDraft = Partial<
AgentStructuredOutput & {
sections: Array<Partial<AgentOutputSection> | null>;
metadata: Record<string, unknown>;
}
>;
export type AgentExecutionStep = { export type AgentExecutionStep = {
id: string; id: string;
title: string; title: string;
channel: string; channel: string;
outputType: string; outputType: string;
artifactType: string;
goal: string;
requiredSections: string[];
qualityChecks: string[];
}; };
export type AgentExecutionPlan = { export type AgentExecutionPlan = {
@@ -44,6 +72,7 @@ export type AgentAnalyzeResult = {
const SAFE_FALLBACK_TITLE = "Untitled"; const SAFE_FALLBACK_TITLE = "Untitled";
const SAFE_FALLBACK_CHANNEL = "general"; const SAFE_FALLBACK_CHANNEL = "general";
const SAFE_FALLBACK_OUTPUT_TYPE = "text"; const SAFE_FALLBACK_OUTPUT_TYPE = "text";
const SAFE_FALLBACK_GOAL = "Deliver channel-ready output.";
function trimString(value: unknown): string { function trimString(value: unknown): string {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
@@ -82,6 +111,91 @@ function normalizeStringArray(raw: unknown, options?: { lowerCase?: boolean }):
return normalized; return normalized;
} }
function normalizeOutputSections(raw: unknown): AgentOutputSection[] {
if (!Array.isArray(raw)) {
return [];
}
const sections: AgentOutputSection[] = [];
const seenIds = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const sectionRecord = item as Record<string, unknown>;
const label = trimString(sectionRecord.label);
const content = trimString(sectionRecord.content);
if (label === "" || content === "") {
continue;
}
const normalizedBaseId = normalizeStepId(sectionRecord.id) || normalizeStepId(label) || "section";
let sectionId = normalizedBaseId;
let suffix = 2;
while (seenIds.has(sectionId)) {
sectionId = `${normalizedBaseId}-${suffix}`;
suffix += 1;
}
seenIds.add(sectionId);
sections.push({
id: sectionId,
label,
content,
});
}
return sections;
}
function normalizeStructuredMetadata(raw: unknown): Record<string, string | string[]> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const metadata: Record<string, string | string[]> = {};
for (const [rawKey, rawValue] of Object.entries(raw as Record<string, unknown>)) {
const key = trimString(rawKey);
if (key === "") {
continue;
}
const value = trimString(rawValue);
if (value !== "") {
metadata[key] = value;
continue;
}
const listValue = normalizeStringArray(rawValue);
if (listValue.length > 0) {
metadata[key] = listValue;
}
}
return metadata;
}
function derivePreviewTextFromSections(sections: AgentOutputSection[]): string {
return sections[0]?.content ?? "";
}
function deriveBodyFromStructuredOutput(input: {
sections: AgentOutputSection[];
previewText: string;
title: string;
}): string {
if (input.sections.length > 0) {
return input.sections.map((section) => `${section.label}:\n${section.content}`).join("\n\n");
}
if (input.previewText !== "") {
return input.previewText;
}
return input.title;
}
export function normalizeAgentBriefConstraints(raw: unknown): AgentBriefConstraints { export function normalizeAgentBriefConstraints(raw: unknown): AgentBriefConstraints {
const rawRecord = const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw) raw && typeof raw === "object" && !Array.isArray(raw)
@@ -183,6 +297,13 @@ export function normalizeAgentExecutionPlan(raw: unknown): AgentExecutionPlan {
title: trimString(itemRecord.title) || SAFE_FALLBACK_TITLE, title: trimString(itemRecord.title) || SAFE_FALLBACK_TITLE,
channel: trimString(itemRecord.channel) || SAFE_FALLBACK_CHANNEL, channel: trimString(itemRecord.channel) || SAFE_FALLBACK_CHANNEL,
outputType: trimString(itemRecord.outputType) || SAFE_FALLBACK_OUTPUT_TYPE, outputType: trimString(itemRecord.outputType) || SAFE_FALLBACK_OUTPUT_TYPE,
artifactType:
trimString(itemRecord.artifactType) ||
trimString(itemRecord.outputType) ||
SAFE_FALLBACK_OUTPUT_TYPE,
goal: trimString(itemRecord.goal) || SAFE_FALLBACK_GOAL,
requiredSections: normalizeStringArray(itemRecord.requiredSections),
qualityChecks: normalizeStringArray(itemRecord.qualityChecks),
}); });
} }
@@ -229,3 +350,39 @@ export function normalizeAgentOutputDraft(
body: trimString(draft.body), body: trimString(draft.body),
}; };
} }
export function normalizeAgentStructuredOutput(
draft: AgentStructuredOutputDraft,
fallback: {
title: string;
channel: string;
artifactType: string;
},
): AgentStructuredOutput {
const title = trimString(draft.title) || trimString(fallback.title) || SAFE_FALLBACK_TITLE;
const channel = trimString(draft.channel) || trimString(fallback.channel) || SAFE_FALLBACK_CHANNEL;
const artifactType =
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 qualityChecks = normalizeStringArray(draft.qualityChecks);
const body =
trimString(draft.body) ||
deriveBodyFromStructuredOutput({
sections,
previewText,
title,
});
return {
title,
channel,
artifactType,
previewText,
sections,
metadata,
qualityChecks,
body,
};
}

View File

@@ -1,3 +1,5 @@
import { AGENT_DEFINITIONS } from "@/lib/agent-definitions";
export type AgentTemplateId = "campaign-distributor"; export type AgentTemplateId = "campaign-distributor";
export type AgentTemplate = { export type AgentTemplate = {
@@ -15,46 +17,19 @@ export type AgentTemplate = {
}; };
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [ export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
{ ...AGENT_DEFINITIONS.map((definition) => ({
id: "campaign-distributor", id: definition.id,
name: "Campaign Distributor", name: definition.metadata.name,
description: description: definition.metadata.description,
"Develops and distributes LemonSpace campaign content across social media and messenger channels.", emoji: definition.metadata.emoji,
emoji: "lemon", color: definition.metadata.color,
color: "yellow", vibe: definition.metadata.vibe,
vibe: "Transforms canvas outputs into campaign-ready channel content.", tools: definition.uiReference.tools,
tools: ["WebFetch", "WebSearch", "Read", "Write", "Edit"], channels: definition.channelCatalog,
channels: [ expectedInputs: definition.uiReference.expectedInputs,
"Instagram Feed", expectedOutputs: definition.uiReference.expectedOutputs,
"Instagram Stories", notes: definition.uiReference.notes,
"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; ] as const;
const AGENT_TEMPLATE_BY_ID = new Map<AgentTemplateId, AgentTemplate>( const AGENT_TEMPLATE_BY_ID = new Map<AgentTemplateId, AgentTemplate>(
@@ -62,8 +37,5 @@ const AGENT_TEMPLATE_BY_ID = new Map<AgentTemplateId, AgentTemplate>(
); );
export function getAgentTemplate(id: string): AgentTemplate | undefined { export function getAgentTemplate(id: string): AgentTemplate | undefined {
if (id === "campaign-distributor") { return AGENT_TEMPLATE_BY_ID.get(id as AgentTemplateId);
return AGENT_TEMPLATE_BY_ID.get(id);
}
return undefined;
} }

View File

@@ -0,0 +1,21 @@
// This file is generated by scripts/compile-agent-docs.ts
// Do not edit manually.
import type { AgentDefinitionId } from "@/lib/agent-definitions";
export type AgentDocPromptSegmentKey =
| "role"
| "style-rules"
| "decision-framework"
| "channel-notes";
export type AgentDocPromptSegments = Record<AgentDocPromptSegmentKey, string>;
export const AGENT_DOC_SEGMENTS: Record<AgentDefinitionId, AgentDocPromptSegments> = {
"campaign-distributor": {
"role": `You are the Campaign Distributor for LemonSpace, an AI creative canvas used by small design and marketing teams. Your mission is to transform visual canvas outputs and optional campaign briefing into channel-native distribution packages that are ready to publish, mapped to the best-fitting asset, and explicit about assumptions when context is missing.`,
"style-rules": `Write specific, decisive, and immediately usable copy. Prefer concrete verbs over vague language, keep claims honest, and never invent product facts, statistics, or deadlines that were not provided. Adapt tone by channel while preserving campaign intent, and keep each deliverable concise enough to be practical for operators.`,
"decision-framework": `Reason in this order: (1) validate required visual context, (2) detect language from brief and default to English if ambiguous, (3) assign assets to channels by format fit and visual intent, (4) select the best output blueprint per channel, (5) generate publish-ready sections and metadata, (6) surface assumptions and format risks explicitly. Ask clarifying questions only when required fields are missing or conflicting. For each selected channel, produce one structured deliverable with artifactType, previewText, sections, metadata, and qualityChecks.`,
"channel-notes": `Instagram needs hook-first visual storytelling with clear CTA and practical hashtag sets. LinkedIn needs professional framing, strong insight opening, and comment-driving close without hype language. X needs brevity and thread-aware sequencing when 280 characters are exceeded. TikTok needs native conversational phrasing and 9:16 adaptation notes. WhatsApp and Telegram need direct, high-signal copy with one clear action. Newsletter needs subject cue, preview line, and a reusable body block that fits any email builder. If asset format mismatches channel constraints, flag it and suggest a fix.`,
},
};

View File

@@ -218,7 +218,14 @@
"skeletonBadge": "SKELETON", "skeletonBadge": "SKELETON",
"plannedOutputLabel": "Geplanter Output", "plannedOutputLabel": "Geplanter Output",
"channelLabel": "Kanal", "channelLabel": "Kanal",
"artifactTypeLabel": "Artefakttyp",
"typeLabel": "Typ", "typeLabel": "Typ",
"sectionsLabel": "Abschnitte",
"metadataLabel": "Metadaten",
"qualityChecksLabel": "Qualitaetschecks",
"previewLabel": "Vorschau",
"previewFallback": "Keine Vorschau verfuegbar",
"emptyValue": "-",
"bodyLabel": "Inhalt", "bodyLabel": "Inhalt",
"plannedContent": "Geplanter Inhalt" "plannedContent": "Geplanter Inhalt"
}, },

View File

@@ -218,7 +218,14 @@
"skeletonBadge": "Skeleton", "skeletonBadge": "Skeleton",
"plannedOutputLabel": "Planned output", "plannedOutputLabel": "Planned output",
"channelLabel": "Channel", "channelLabel": "Channel",
"artifactTypeLabel": "Artifact type",
"typeLabel": "Type", "typeLabel": "Type",
"sectionsLabel": "Sections",
"metadataLabel": "Metadata",
"qualityChecksLabel": "Quality checks",
"previewLabel": "Preview",
"previewFallback": "No preview available",
"emptyValue": "-",
"bodyLabel": "Body", "bodyLabel": "Body",
"plannedContent": "Planned content" "plannedContent": "Planned content"
}, },

View File

@@ -0,0 +1,171 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { AGENT_DEFINITIONS, type AgentDefinitionId } from "../lib/agent-definitions";
export const AGENT_PROMPT_SEGMENT_KEYS = [
"role",
"style-rules",
"decision-framework",
"channel-notes",
] as const;
export type AgentPromptSegmentKey = (typeof AGENT_PROMPT_SEGMENT_KEYS)[number];
export type AgentPromptSegments = Record<AgentPromptSegmentKey, string>;
type CompileOptions = {
sourcePath: string;
};
function normalizeSegmentBody(raw: string): string {
return raw
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
function countMarkerOccurrences(markdown: string, marker: string): number {
let count = 0;
let currentIndex = 0;
while (currentIndex >= 0) {
currentIndex = markdown.indexOf(marker, currentIndex);
if (currentIndex < 0) {
break;
}
count += 1;
currentIndex += marker.length;
}
return count;
}
function extractSegment(markdown: string, key: AgentPromptSegmentKey, sourcePath: string): string {
const startMarker = `<!-- AGENT_PROMPT_SEGMENT:${key}:start -->`;
const endMarker = `<!-- AGENT_PROMPT_SEGMENT:${key}:end -->`;
const startCount = countMarkerOccurrences(markdown, startMarker);
const endCount = countMarkerOccurrences(markdown, endMarker);
if (startCount !== 1 || endCount !== 1) {
throw new Error(
`[agent-docs] Missing or duplicate markers for '${key}' in ${sourcePath}. Expected exactly one start and one end marker.`,
);
}
const startIndex = markdown.indexOf(startMarker);
const contentStart = startIndex + startMarker.length;
const endIndex = markdown.indexOf(endMarker, contentStart);
if (startIndex < 0 || endIndex < 0 || endIndex <= contentStart) {
throw new Error(`[agent-docs] Invalid marker placement for '${key}' in ${sourcePath}.`);
}
const segment = normalizeSegmentBody(markdown.slice(contentStart, endIndex));
if (segment === "") {
throw new Error(`[agent-docs] Empty segment '${key}' in ${sourcePath}.`);
}
return segment;
}
export function compileAgentDocSegmentsFromMarkdown(
markdown: string,
options: CompileOptions,
): AgentPromptSegments {
const compiled = {} as AgentPromptSegments;
for (const key of AGENT_PROMPT_SEGMENT_KEYS) {
compiled[key] = extractSegment(markdown, key, options.sourcePath);
}
return compiled;
}
export function compileAgentDocSegmentsFromSources(
sources: ReadonlyArray<{ agentId: AgentDefinitionId; markdown: string; sourcePath: string }>,
): Record<AgentDefinitionId, AgentPromptSegments> {
const compiled = {} as Record<AgentDefinitionId, AgentPromptSegments>;
for (const source of sources) {
compiled[source.agentId] = compileAgentDocSegmentsFromMarkdown(source.markdown, {
sourcePath: source.sourcePath,
});
}
return compiled;
}
function escapeForTsString(raw: string): string {
return raw.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
export function renderCompiledAgentDocSegmentsModule(
compiled: Record<AgentDefinitionId, AgentPromptSegments>,
): string {
const lines: string[] = [
"// This file is generated by scripts/compile-agent-docs.ts",
"// Do not edit manually.",
"",
'import type { AgentDefinitionId } from "@/lib/agent-definitions";',
"",
"export type AgentDocPromptSegmentKey =",
' | "role"',
' | "style-rules"',
' | "decision-framework"',
' | "channel-notes";',
"",
"export type AgentDocPromptSegments = Record<AgentDocPromptSegmentKey, string>;",
"",
"export const AGENT_DOC_SEGMENTS: Record<AgentDefinitionId, AgentDocPromptSegments> = {",
];
const agentIds = Object.keys(compiled).sort() as AgentDefinitionId[];
for (const agentId of agentIds) {
lines.push(` \"${agentId}\": {`);
for (const key of AGENT_PROMPT_SEGMENT_KEYS) {
lines.push(` \"${key}\": \`${escapeForTsString(compiled[agentId][key])}\`,`);
}
lines.push(" },");
}
lines.push("};", "");
return lines.join("\n");
}
export async function compileAgentDocsToGeneratedModule(projectRoot: string): Promise<string> {
const sources: Array<{ agentId: AgentDefinitionId; markdown: string; sourcePath: string }> = [];
for (const definition of AGENT_DEFINITIONS) {
const sourcePath = definition.docs.markdownPath;
const absoluteSourcePath = path.resolve(projectRoot, sourcePath);
const markdown = await fs.readFile(absoluteSourcePath, "utf8");
sources.push({ agentId: definition.id, markdown, sourcePath });
}
const compiled = compileAgentDocSegmentsFromSources(sources);
const output = renderCompiledAgentDocSegmentsModule(compiled);
const outputPath = path.resolve(projectRoot, "lib/generated/agent-doc-segments.ts");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, output, "utf8");
return outputPath;
}
const thisFilePath = fileURLToPath(import.meta.url);
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedPath === thisFilePath) {
compileAgentDocsToGeneratedModule(process.cwd())
.then((outputPath) => {
process.stdout.write(`[agent-docs] Wrote ${outputPath}\n`);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}

View File

@@ -481,4 +481,52 @@ describe("AgentNode runtime", () => {
expect(mocks.runAgent).not.toHaveBeenCalled(); expect(mocks.runAgent).not.toHaveBeenCalled();
}); });
it("keeps execution progress fallback compatible with richer runtime execution step data", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-4",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
canvasId: "canvas-1",
templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini",
_status: "executing",
executionSteps: [
{
stepIndex: 0,
stepTotal: 2,
artifactType: "social-post",
requiredSections: ["hook", "body", "cta"],
qualityChecks: ["channel-fit"],
},
{
stepIndex: 1,
stepTotal: 2,
artifactType: "social-post",
requiredSections: ["hook", "body", "cta"],
qualityChecks: ["channel-fit"],
},
],
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Executing planned outputs (2 total)");
});
}); });

View File

@@ -6,6 +6,46 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const handleCalls: Array<{ type: string; id?: string }> = []; const handleCalls: Array<{ type: string; id?: string }> = [];
const getAgentTemplateMock = vi.fn((id: string) => {
if (id === "future-agent") {
return {
id: "future-agent",
name: "Future Agent",
description: "Generic definition-backed template metadata.",
emoji: "rocket",
color: "blue",
vibe: "Builds reusable workflows.",
tools: [],
channels: ["Email", "LinkedIn"],
expectedInputs: ["Text node"],
expectedOutputs: ["Plan"],
notes: [],
};
}
if (id === "campaign-distributor") {
return {
id: "campaign-distributor",
name: "Campaign Distributor",
description:
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
emoji: "lemon",
color: "yellow",
vibe: "Campaign-first",
tools: [],
channels: ["LinkedIn", "Instagram"],
expectedInputs: ["Render"],
expectedOutputs: ["Caption pack"],
notes: [],
};
}
return undefined;
});
vi.mock("@/lib/agent-templates", () => ({
getAgentTemplate: (id: string) => getAgentTemplateMock(id),
}));
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children), default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
@@ -71,6 +111,7 @@ describe("AgentNode", () => {
beforeEach(() => { beforeEach(() => {
handleCalls.length = 0; handleCalls.length = 0;
getAgentTemplateMock.mockClear();
}); });
afterEach(() => { afterEach(() => {
@@ -84,7 +125,7 @@ describe("AgentNode", () => {
root = null; root = null;
}); });
it("renders campaign distributor metadata and source/target handles", async () => { it("renders definition-projected metadata and source/target handles without template-specific branching", async () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -102,7 +143,7 @@ describe("AgentNode", () => {
isConnectable: true, isConnectable: true,
type: "agent", type: "agent",
data: { data: {
templateId: "campaign-distributor", templateId: "future-agent",
_status: "idle", _status: "idle",
} as Record<string, unknown>, } as Record<string, unknown>,
positionAbsoluteX: 0, positionAbsoluteX: 0,
@@ -111,7 +152,8 @@ describe("AgentNode", () => {
); );
}); });
expect(container.textContent).toContain("Campaign Distributor"); expect(container.textContent).toContain("Future Agent");
expect(container.textContent).toContain("Generic definition-backed template metadata.");
expect(container.textContent).toContain("Briefing"); expect(container.textContent).toContain("Briefing");
expect(container.textContent).toContain("Constraints"); expect(container.textContent).toContain("Constraints");
expect(container.textContent).toContain("Template reference"); expect(container.textContent).toContain("Template reference");
@@ -119,7 +161,7 @@ describe("AgentNode", () => {
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1); expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
}); });
it("falls back to the default template when templateId is missing", async () => { it("falls back to the default template when templateId is missing or unknown", async () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -137,6 +179,7 @@ describe("AgentNode", () => {
isConnectable: true, isConnectable: true,
type: "agent", type: "agent",
data: { data: {
templateId: "unknown-template",
_status: "done", _status: "done",
} as Record<string, unknown>, } as Record<string, unknown>,
positionAbsoluteX: 0, positionAbsoluteX: 0,
@@ -146,5 +189,7 @@ describe("AgentNode", () => {
}); });
expect(container.textContent).toContain("Campaign Distributor"); expect(container.textContent).toContain("Campaign Distributor");
expect(getAgentTemplateMock).toHaveBeenCalledWith("unknown-template");
expect(getAgentTemplateMock).toHaveBeenCalledWith("campaign-distributor");
}); });
}); });

View File

@@ -28,7 +28,13 @@ const translations: Record<string, string> = {
"agentOutputNode.skeletonBadge": "Skeleton", "agentOutputNode.skeletonBadge": "Skeleton",
"agentOutputNode.plannedOutputLabel": "Planned output", "agentOutputNode.plannedOutputLabel": "Planned output",
"agentOutputNode.channelLabel": "Channel", "agentOutputNode.channelLabel": "Channel",
"agentOutputNode.typeLabel": "Type", "agentOutputNode.artifactTypeLabel": "Artifact type",
"agentOutputNode.sectionsLabel": "Sections",
"agentOutputNode.metadataLabel": "Metadata",
"agentOutputNode.qualityChecksLabel": "Quality checks",
"agentOutputNode.previewLabel": "Preview",
"agentOutputNode.previewFallback": "No preview available",
"agentOutputNode.emptyValue": "-",
"agentOutputNode.bodyLabel": "Body", "agentOutputNode.bodyLabel": "Body",
"agentOutputNode.plannedContent": "Planned content", "agentOutputNode.plannedContent": "Planned content",
}; };
@@ -70,7 +76,7 @@ describe("AgentOutputNode", () => {
root = null; root = null;
}); });
it("renders title, channel, output type, and body", async () => { it("renders structured output with artifact meta, sections, metadata, quality checks, and preview fallback", async () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -90,8 +96,18 @@ describe("AgentOutputNode", () => {
data: { data: {
title: "Instagram Caption", title: "Instagram Caption",
channel: "instagram-feed", channel: "instagram-feed",
outputType: "caption", artifactType: "caption-pack",
body: "A short punchy caption with hashtags", previewText: "A short punchy caption with hashtags",
sections: [
{ id: "hook", label: "Hook", content: "Launch day is here." },
{ id: "body", label: "Body", content: "Built for modern teams." },
],
metadata: {
objective: "Drive signups",
tags: ["launch", "product"],
},
qualityChecks: ["channel-fit", "cta-present"],
body: "Legacy body fallback",
_status: "done", _status: "done",
} as Record<string, unknown>, } as Record<string, unknown>,
positionAbsoluteX: 0, positionAbsoluteX: 0,
@@ -102,10 +118,22 @@ describe("AgentOutputNode", () => {
expect(container.textContent).toContain("Instagram Caption"); expect(container.textContent).toContain("Instagram Caption");
expect(container.textContent).toContain("instagram-feed"); expect(container.textContent).toContain("instagram-feed");
expect(container.textContent).toContain("caption"); expect(container.textContent).toContain("caption-pack");
expect(container.textContent).toContain("Sections");
expect(container.textContent).toContain("Hook");
expect(container.textContent).toContain("Launch day is here.");
expect(container.textContent).toContain("Metadata");
expect(container.textContent).toContain("objective");
expect(container.textContent).toContain("Drive signups");
expect(container.textContent).toContain("Quality checks");
expect(container.textContent).toContain("channel-fit");
expect(container.textContent).toContain("Preview");
expect(container.textContent).toContain("A short punchy caption with hashtags"); expect(container.textContent).toContain("A short punchy caption with hashtags");
expect(container.querySelector('[data-testid="agent-output-meta-strip"]')).not.toBeNull(); expect(container.querySelector('[data-testid="agent-output-meta-strip"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-text-body"]')).not.toBeNull(); expect(container.querySelector('[data-testid="agent-output-sections"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-metadata"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull();
expect(container.querySelector('[data-testid="agent-output-preview"]')).not.toBeNull();
}); });
it("renders parseable json body in a pretty-printed code block", async () => { it("renders parseable json body in a pretty-printed code block", async () => {
@@ -128,7 +156,7 @@ describe("AgentOutputNode", () => {
data: { data: {
title: "JSON output", title: "JSON output",
channel: "api", channel: "api",
outputType: "payload", artifactType: "payload",
body: '{"post":"Hello","tags":["launch","news"]}', body: '{"post":"Hello","tags":["launch","news"]}',
_status: "done", _status: "done",
} as Record<string, unknown>, } as Record<string, unknown>,
@@ -145,6 +173,40 @@ describe("AgentOutputNode", () => {
expect(container.querySelector('[data-testid="agent-output-text-body"]')).toBeNull(); expect(container.querySelector('[data-testid="agent-output-text-body"]')).toBeNull();
}); });
it("falls back to legacy text body when structured fields are absent", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-legacy",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "Legacy output",
channel: "linkedin",
artifactType: "post",
body: "Legacy body content",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.querySelector('[data-testid="agent-output-text-body"]')).not.toBeNull();
expect(container.textContent).toContain("Legacy body content");
expect(container.querySelector('[data-testid="agent-output-sections"]')).toBeNull();
});
it("renders input-only handle agent-output-in", async () => { it("renders input-only handle agent-output-in", async () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -165,7 +227,7 @@ describe("AgentOutputNode", () => {
data: { data: {
title: "LinkedIn Post", title: "LinkedIn Post",
channel: "linkedin", channel: "linkedin",
outputType: "post", artifactType: "post",
body: "Body", body: "Body",
} as Record<string, unknown>, } as Record<string, unknown>,
positionAbsoluteX: 0, positionAbsoluteX: 0,
@@ -197,7 +259,7 @@ describe("AgentOutputNode", () => {
data: { data: {
title: "Planned headline", title: "Planned headline",
channel: "linkedin", channel: "linkedin",
outputType: "post", artifactType: "post",
isSkeleton: true, isSkeleton: true,
stepIndex: 1, stepIndex: 1,
stepTotal: 4, stepTotal: 4,

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { __testables } from "@/convex/agents";
describe("agent orchestration contract helpers", () => {
it("builds skeleton output data with rich execution-plan metadata", () => {
const data = __testables.buildSkeletonOutputData({
step: {
id: "step-linkedin",
title: "LinkedIn Launch",
channel: "linkedin",
outputType: "post",
artifactType: "social-post",
goal: "Ship launch copy",
requiredSections: ["hook", "body", "cta"],
qualityChecks: ["channel-fit", "clear-cta"],
},
stepIndex: 1,
stepTotal: 3,
definitionVersion: 4,
});
expect(data).toMatchObject({
isSkeleton: true,
stepId: "step-linkedin",
stepIndex: 1,
stepTotal: 3,
title: "LinkedIn Launch",
channel: "linkedin",
outputType: "post",
artifactType: "social-post",
requiredSections: ["hook", "body", "cta"],
qualityChecks: ["channel-fit", "clear-cta"],
definitionVersion: 4,
});
expect(data.previewText).toBe("Draft pending for LinkedIn Launch.");
});
it("builds completed output data and derives deterministic legacy body fallback", () => {
const data = __testables.buildCompletedOutputData({
step: {
id: "step-linkedin",
title: "LinkedIn Launch",
channel: "linkedin",
outputType: "post",
artifactType: "social-post",
goal: "Ship launch copy",
requiredSections: ["hook", "body", "cta"],
qualityChecks: ["channel-fit", "clear-cta"],
},
stepIndex: 0,
stepTotal: 1,
output: {
title: "LinkedIn Launch",
channel: "linkedin",
artifactType: "social-post",
previewText: "",
sections: [
{ id: "hook", label: "Hook", content: "Lead with proof." },
{ id: "cta", label: "CTA", content: "Invite comments." },
],
metadata: { audience: "SaaS founders" },
qualityChecks: [],
body: "",
},
});
expect(data.isSkeleton).toBe(false);
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"]);
});
it("requires rich execution-step fields in analyze schema", () => {
const required = __testables.getAnalyzeExecutionStepRequiredFields();
expect(required).toEqual(
expect.arrayContaining([
"id",
"title",
"channel",
"outputType",
"artifactType",
"goal",
"requiredSections",
"qualityChecks",
]),
);
});
it("resolves persisted summaries consistently across analyze and execute", () => {
const promptSummary = __testables.resolveExecutionPlanSummary({
executionPlanSummary: "",
analysisSummary: "Audience and channels clarified.",
});
expect(promptSummary).toBe("Audience and channels clarified.");
const finalSummary = __testables.resolveFinalExecutionSummary({
executionSummary: "",
modelSummary: "Delivered 3 channel drafts.",
executionPlanSummary: "Plan for 3 outputs.",
analysisSummary: "Audience and channels clarified.",
});
expect(finalSummary).toBe("Delivered 3 channel drafts.");
});
});

View File

@@ -12,10 +12,11 @@ import {
listMediaArchiveItems, listMediaArchiveItems,
upsertMediaItemByOwnerAndDedupe, upsertMediaItemByOwnerAndDedupe,
} from "@/convex/media"; } from "@/convex/media";
import { listMediaLibrary } from "@/convex/dashboard";
import { verifyOwnedStorageIds } from "@/convex/storage"; import { verifyOwnedStorageIds } from "@/convex/storage";
import { registerUploadedImageMedia } from "@/convex/storage"; import { registerUploadedImageMedia } from "@/convex/storage";
import { buildStoredMediaDedupeKey } from "@/lib/media-archive"; import { buildStoredMediaDedupeKey } from "@/lib/media-archive";
import { requireAuth } from "@/convex/helpers"; import { optionalAuth, requireAuth } from "@/convex/helpers";
type MockMediaItem = { type MockMediaItem = {
_id: Id<"mediaItems">; _id: Id<"mediaItems">;
@@ -89,6 +90,98 @@ function createMockDb(initialRows: MockMediaItem[] = []) {
}; };
} }
type MockDashboardMediaItem = {
_id: Id<"mediaItems">;
_creationTime: number;
ownerId: string;
dedupeKey: string;
kind: "image" | "video" | "asset";
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
storageId?: Id<"_storage">;
previewStorageId?: Id<"_storage">;
originalUrl?: string;
previewUrl?: string;
sourceUrl?: string;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
updatedAt: number;
};
function createListMediaLibraryCtx(mediaItems: MockDashboardMediaItem[]) {
return {
db: {
query: vi.fn((table: string) => {
if (table === "mediaItems") {
return {
withIndex: vi.fn(
(
index: "by_owner_updated" | "by_owner_kind_updated",
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);
let filtered = [...mediaItems];
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
if (ownerId) {
filtered = filtered.filter((item) => item.ownerId === ownerId);
}
if (index === "by_owner_kind_updated") {
const kind = clauses.find((clause) => clause.field === "kind")?.value;
filtered = filtered.filter((item) => item.kind === kind);
}
const sorted = filtered.sort((a, b) => b.updatedAt - a.updatedAt);
return {
order: vi.fn((direction: "desc") => {
expect(direction).toBe("desc");
return {
collect: vi.fn(async () => sorted),
take: vi.fn(async (count: number) => sorted.slice(0, count)),
};
}),
};
},
),
};
}
if (table === "canvases") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
collect: vi.fn(async () => []),
})),
})),
};
}
if (table === "nodes") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn(async () => []),
})),
})),
};
}
throw new Error(`Unexpected table query: ${table}`);
}),
},
};
}
describe("media archive", () => { describe("media archive", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -475,4 +568,129 @@ describe("media archive", () => {
firstSourceNodeId: nodeId, firstSourceNodeId: nodeId,
}); });
}); });
it("listMediaLibrary returns paginated object with default page size 8", async () => {
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
const mediaItems: MockDashboardMediaItem[] = Array.from({ length: 11 }, (_, index) => ({
_id: `media_${index + 1}` as Id<"mediaItems">,
_creationTime: index + 1,
ownerId: "user_1",
dedupeKey: `storage:s${index + 1}`,
kind: index % 2 === 0 ? "image" : "video",
source: index % 2 === 0 ? "upload" : "ai-video",
storageId: `storage_${index + 1}` as Id<"_storage">,
updatedAt: 1000 - index,
}));
const result = await (listMediaLibrary as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
})._handler(createListMediaLibraryCtx(mediaItems) as never, { page: 1 });
expect(result).toMatchObject({
page: 1,
pageSize: 8,
totalPages: 2,
totalCount: 11,
});
expect((result as { items: unknown[] }).items).toHaveLength(8);
});
it("listMediaLibrary paginates with page and pageSize args", async () => {
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
const mediaItems: MockDashboardMediaItem[] = Array.from({ length: 12 }, (_, index) => ({
_id: `media_${index + 1}` as Id<"mediaItems">,
_creationTime: index + 1,
ownerId: "user_1",
dedupeKey: `storage:s${index + 1}`,
kind: index % 2 === 0 ? "image" : "video",
source: index % 2 === 0 ? "upload" : "ai-video",
storageId: `storage_${index + 1}` as Id<"_storage">,
updatedAt: 2000 - index,
}));
const result = await (listMediaLibrary as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
})._handler(createListMediaLibraryCtx(mediaItems) as never, { page: 2, pageSize: 5 });
expect(result).toMatchObject({
page: 2,
pageSize: 5,
totalPages: 3,
totalCount: 12,
});
expect((result as { items: unknown[] }).items).toHaveLength(5);
});
it("listMediaLibrary applies kindFilter with paginated response", async () => {
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
const mediaItems: MockDashboardMediaItem[] = [
{
_id: "media_1" as Id<"mediaItems">,
_creationTime: 1,
ownerId: "user_1",
dedupeKey: "storage:image_1",
kind: "image",
source: "upload",
storageId: "storage_image_1" as Id<"_storage">,
updatedAt: 100,
},
{
_id: "media_2" as Id<"mediaItems">,
_creationTime: 2,
ownerId: "user_1",
dedupeKey: "storage:video_1",
kind: "video",
source: "ai-video",
storageId: "storage_video_1" as Id<"_storage">,
updatedAt: 99,
},
{
_id: "media_3" as Id<"mediaItems">,
_creationTime: 3,
ownerId: "user_1",
dedupeKey: "storage:video_2",
kind: "video",
source: "ai-video",
storageId: "storage_video_2" as Id<"_storage">,
updatedAt: 98,
},
];
const result = await (listMediaLibrary as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
})._handler(createListMediaLibraryCtx(mediaItems) as never, {
page: 1,
pageSize: 1,
kindFilter: "video",
});
expect(result).toMatchObject({
page: 1,
pageSize: 1,
totalPages: 2,
totalCount: 2,
});
expect((result as { items: Array<{ kind: string }> }).items).toEqual([
expect.objectContaining({ kind: "video" }),
]);
});
it("listMediaLibrary returns paginated empty shape when unauthenticated", async () => {
vi.mocked(optionalAuth).mockResolvedValue(null);
const result = await (listMediaLibrary as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
})._handler(createListMediaLibraryCtx([]) as never, { page: 2, pageSize: 5 });
expect(result).toEqual({
items: [],
page: 2,
pageSize: 5,
totalPages: 0,
totalCount: 0,
});
});
}); });

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
AGENT_DEFINITIONS,
getAgentDefinition,
} from "@/lib/agent-definitions";
describe("agent definitions", () => {
it("registers exactly one runtime definition for now", () => {
expect(AGENT_DEFINITIONS.map((definition) => definition.id)).toEqual([
"campaign-distributor",
]);
});
it("returns campaign distributor with runtime metadata and blueprint contract", () => {
const definition = getAgentDefinition("campaign-distributor");
expect(definition?.metadata.name).toBe("Campaign Distributor");
expect(definition?.metadata.color).toBe("yellow");
expect(definition?.docs.markdownPath).toBe(
"components/agents/campaign-distributor.md",
);
expect(definition?.acceptedSourceNodeTypes).toContain("text");
expect(definition?.briefFieldOrder).toEqual([
"briefing",
"audience",
"tone",
"targetChannels",
"hardConstraints",
]);
expect(definition?.channelCatalog).toContain("Instagram Feed");
expect(definition?.operatorParameters).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "targetChannels", type: "multi-select" }),
expect.objectContaining({ key: "variantsPerChannel", type: "select" }),
expect.objectContaining({ key: "toneOverride", type: "select" }),
]),
);
expect(definition?.analysisRules.length).toBeGreaterThan(0);
expect(definition?.executionRules.length).toBeGreaterThan(0);
expect(definition?.defaultOutputBlueprints).toEqual(
expect.arrayContaining([
expect.objectContaining({
artifactType: "social-caption-pack",
requiredSections: expect.arrayContaining(["Hook", "Caption"]),
requiredMetadataKeys: expect.arrayContaining([
"objective",
"targetAudience",
]),
qualityChecks: expect.arrayContaining([
"matches_channel_constraints",
]),
}),
]),
);
});
it("keeps shared runtime fields accessible without template-specific branching", () => {
const definition = getAgentDefinition("campaign-distributor");
if (!definition) {
throw new Error("Missing definition");
}
const commonProjection = {
id: definition.id,
markdownPath: definition.docs.markdownPath,
sourceTypeCount: definition.acceptedSourceNodeTypes.length,
blueprintCount: definition.defaultOutputBlueprints.length,
};
expect(commonProjection).toEqual({
id: "campaign-distributor",
markdownPath: "components/agents/campaign-distributor.md",
sourceTypeCount: definition.acceptedSourceNodeTypes.length,
blueprintCount: definition.defaultOutputBlueprints.length,
});
expect(commonProjection.sourceTypeCount).toBeGreaterThan(0);
expect(commonProjection.blueprintCount).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
AGENT_PROMPT_SEGMENT_KEYS,
compileAgentDocSegmentsFromMarkdown,
type AgentPromptSegmentKey,
} from "@/scripts/compile-agent-docs";
import { AGENT_DOC_SEGMENTS } from "@/lib/generated/agent-doc-segments";
function markedSegment(key: AgentPromptSegmentKey, content: string): string {
return [
`<!-- AGENT_PROMPT_SEGMENT:${key}:start -->`,
content,
`<!-- AGENT_PROMPT_SEGMENT:${key}:end -->`,
].join("\n");
}
describe("agent doc segment compiler", () => {
it("extracts only explicitly marked sections in deterministic order", () => {
const markdown = [
"# Intro",
"This prose should not be extracted.",
markedSegment("role", "Role text"),
"Unmarked detail should not leak.",
markedSegment("style-rules", "Style text"),
markedSegment("decision-framework", "Decision text"),
markedSegment("channel-notes", "Channel text"),
].join("\n\n");
const compiled = compileAgentDocSegmentsFromMarkdown(markdown, {
sourcePath: "components/agents/test-agent.md",
});
expect(Object.keys(compiled)).toEqual(AGENT_PROMPT_SEGMENT_KEYS);
expect(compiled).toEqual({
role: "Role text",
"style-rules": "Style text",
"decision-framework": "Decision text",
"channel-notes": "Channel text",
});
});
it("does not include unmarked prose in extracted segments", () => {
const markdown = [
"Top prose that must stay out.",
markedSegment("role", "Keep me"),
"Random prose should not appear.",
markedSegment("style-rules", "Keep style"),
markedSegment("decision-framework", "Keep framework"),
markedSegment("channel-notes", "Keep channels"),
"Bottom prose that must stay out.",
].join("\n\n");
const compiled = compileAgentDocSegmentsFromMarkdown(markdown, {
sourcePath: "components/agents/test-agent.md",
});
const joined = Object.values(compiled).join("\n");
expect(joined).toContain("Keep me");
expect(joined).not.toContain("Top prose");
expect(joined).not.toContain("Bottom prose");
expect(joined).not.toContain("Random prose");
});
it("fails loudly when a required section marker is missing", () => {
const markdown = [
markedSegment("role", "Role text"),
markedSegment("style-rules", "Style text"),
markedSegment("decision-framework", "Decision text"),
].join("\n\n");
expect(() =>
compileAgentDocSegmentsFromMarkdown(markdown, {
sourcePath: "components/agents/test-agent.md",
}),
).toThrowError(/channel-notes/);
});
it("ships generated campaign distributor segments with all required keys", () => {
const campaignDistributor = AGENT_DOC_SEGMENTS["campaign-distributor"];
expect(campaignDistributor).toBeDefined();
expect(Object.keys(campaignDistributor)).toEqual(AGENT_PROMPT_SEGMENT_KEYS);
expect(campaignDistributor.role.length).toBeGreaterThan(0);
expect(campaignDistributor["style-rules"].length).toBeGreaterThan(0);
expect(campaignDistributor["decision-framework"].length).toBeGreaterThan(0);
expect(campaignDistributor["channel-notes"].length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { getAgentDefinition } from "@/lib/agent-definitions";
import {
buildAnalyzeMessages,
buildExecuteMessages,
summarizeIncomingContext,
type PromptContextNode,
} from "@/lib/agent-prompting";
import { normalizeAgentExecutionPlan } from "@/lib/agent-run-contract";
import { AGENT_DOC_SEGMENTS } from "@/lib/generated/agent-doc-segments";
describe("agent prompting helpers", () => {
const definition = getAgentDefinition("campaign-distributor");
it("summarizes incoming context by node with whitelisted fields", () => {
const nodes: PromptContextNode[] = [
{
nodeId: "node-2",
type: "image",
status: "done",
data: {
url: "https://cdn.example.com/render.png",
width: 1080,
height: 1080,
ignored: "must not be included",
},
},
{
nodeId: "node-1",
type: "text",
status: "idle",
data: {
content: " Product launch headline ",
secret: "do not include",
},
},
];
const summary = summarizeIncomingContext(nodes);
expect(summary).toContain("Incoming context nodes: 2");
expect(summary).toContain("1. nodeId=node-1, type=text, status=idle");
expect(summary).toContain("2. nodeId=node-2, type=image, status=done");
expect(summary).toContain("content: Product launch headline");
expect(summary).toContain("url: https://cdn.example.com/render.png");
expect(summary).toContain("width: 1080");
expect(summary).not.toContain("secret");
expect(summary).not.toContain("ignored");
});
it("buildAnalyzeMessages includes definition metadata, prompt segments, rules, and constraints", () => {
if (!definition) {
throw new Error("campaign-distributor definition missing");
}
const messages = buildAnalyzeMessages({
definition,
locale: "en",
briefConstraints: {
briefing: "Create launch copy",
audience: "Design leads",
tone: "bold",
targetChannels: ["instagram", "linkedin"],
hardConstraints: ["No emojis"],
},
clarificationAnswers: {
budget: "organic",
},
incomingContextSummary: "Incoming context nodes: 1",
incomingContextCount: 1,
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
});
expect(messages).toHaveLength(2);
expect(messages[0]?.role).toBe("system");
expect(messages[1]?.role).toBe("user");
const system = messages[0]?.content ?? "";
const user = messages[1]?.content ?? "";
expect(system).toContain("Campaign Distributor");
expect(system).toContain("role");
expect(system).toContain("style-rules");
expect(system).toContain("decision-framework");
expect(system).toContain("channel-notes");
expect(system).toContain("analysis rules");
expect(system).toContain("default output blueprints");
expect(user).toContain("Brief + constraints");
expect(user).toContain("Current clarification answers");
expect(user).toContain("Incoming context node count: 1");
expect(user).toContain("Incoming context nodes: 1");
});
it("buildExecuteMessages includes execution rules, plan summary, per-step requirements, and checks", () => {
if (!definition) {
throw new Error("campaign-distributor definition missing");
}
const executionPlan = normalizeAgentExecutionPlan({
summary: "Ship launch content",
steps: [
{
id: " ig-feed ",
title: " Instagram Feed Pack ",
channel: " Instagram Feed ",
outputType: "caption-pack",
artifactType: "social-caption-pack",
goal: "Drive comments",
requiredSections: ["Hook", "Caption", "CTA"],
qualityChecks: ["matches_channel_constraints"],
},
],
});
const messages = buildExecuteMessages({
definition,
locale: "de",
briefConstraints: {
briefing: "Post launch update",
audience: "Founders",
tone: "energetic",
targetChannels: ["instagram"],
hardConstraints: ["No discounts"],
},
clarificationAnswers: {
length: "short",
},
incomingContextSummary: "Incoming context nodes: 1",
executionPlan,
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
});
expect(messages).toHaveLength(2);
const system = messages[0]?.content ?? "";
const user = messages[1]?.content ?? "";
expect(system).toContain("execution rules");
expect(system).toContain("channel-notes");
expect(system).toContain("German (de-DE)");
expect(user).toContain("Execution plan summary: Ship launch content");
expect(user).toContain("artifactType: social-caption-pack");
expect(user).toContain("requiredSections: Hook, Caption, CTA");
expect(user).toContain("qualityChecks: matches_channel_constraints");
});
it("prompt builders stay definition-driven without hardcoded template branches", () => {
if (!definition) {
throw new Error("campaign-distributor definition missing");
}
const variantDefinition = {
...definition,
metadata: {
...definition.metadata,
name: "Custom Runtime Agent",
description: "Definition override for test.",
},
};
const messages = buildAnalyzeMessages({
definition: variantDefinition,
locale: "en",
briefConstraints: {
briefing: "Test",
audience: "Test",
tone: "Test",
targetChannels: ["x"],
hardConstraints: [],
},
clarificationAnswers: {},
incomingContextSummary: "Incoming context nodes: 0",
incomingContextCount: 0,
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
});
expect(messages[0]?.content ?? "").toContain("Custom Runtime Agent");
expect(messages[0]?.content ?? "").toContain("Definition override for test.");
});
});

View File

@@ -122,6 +122,10 @@ describe("agent run contract helpers", () => {
title: "Instagram captions", title: "Instagram captions",
channel: "Instagram", channel: "Instagram",
outputType: "caption-pack", outputType: "caption-pack",
artifactType: "caption-pack",
goal: "Deliver channel-ready output.",
requiredSections: [],
qualityChecks: [],
}, },
], ],
}); });
@@ -149,6 +153,10 @@ describe("agent run contract helpers", () => {
title: "Untitled", title: "Untitled",
channel: "general", channel: "general",
outputType: "text", outputType: "text",
artifactType: "text",
goal: "Deliver channel-ready output.",
requiredSections: [],
qualityChecks: [],
}, },
], ],
}); });
@@ -181,6 +189,51 @@ describe("agent run contract helpers", () => {
expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]); expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]);
}); });
it("normalizes enriched execution-step fields with deterministic array handling", () => {
const normalized = normalizeAgentExecutionPlan({
summary: "ready",
steps: [
{
id: "main",
title: "Deliver",
channel: "linkedin",
outputType: "post",
artifactType: " social-caption-pack ",
goal: " Explain launch value ",
requiredSections: ["Hook", "CTA", "Hook", " "],
qualityChecks: ["fits_tone", "fits_tone", "references_context", ""],
},
],
});
expect(normalized.steps[0]).toEqual({
id: "main",
title: "Deliver",
channel: "linkedin",
outputType: "post",
artifactType: "social-caption-pack",
goal: "Explain launch value",
requiredSections: ["Hook", "CTA"],
qualityChecks: ["fits_tone", "references_context"],
});
});
it("keeps compatibility by falling back artifactType to outputType", () => {
const normalized = normalizeAgentExecutionPlan({
summary: "ready",
steps: [
{
id: "legacy",
title: "Legacy step",
channel: "email",
outputType: "newsletter-copy",
},
],
});
expect(normalized.steps[0]?.artifactType).toBe("newsletter-copy");
});
}); });
describe("normalizeAgentBriefConstraints", () => { describe("normalizeAgentBriefConstraints", () => {

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { normalizeAgentStructuredOutput } from "@/lib/agent-run-contract";
describe("normalizeAgentStructuredOutput", () => {
it("preserves valid structured fields and compatibility body", () => {
const normalized = normalizeAgentStructuredOutput(
{
title: " Launch Post ",
channel: " linkedin ",
artifactType: " social-post ",
previewText: " Hook-first launch post. ",
sections: [
{
id: " headline ",
label: " Headline ",
content: " Ship faster with LemonSpace. ",
},
],
metadata: {
language: " en ",
tags: [" launch ", "saas", ""],
},
qualityChecks: [" concise ", "concise", "channel-fit"],
body: " Legacy flat content ",
},
{
title: "Fallback Title",
channel: "fallback-channel",
artifactType: "fallback-artifact",
},
);
expect(normalized).toEqual({
title: "Launch Post",
channel: "linkedin",
artifactType: "social-post",
previewText: "Hook-first launch post.",
sections: [
{
id: "headline",
label: "Headline",
content: "Ship faster with LemonSpace.",
},
],
metadata: {
language: "en",
tags: ["launch", "saas"],
},
qualityChecks: ["concise", "channel-fit"],
body: "Legacy flat content",
});
});
it("removes blank or malformed section entries", () => {
const normalized = normalizeAgentStructuredOutput(
{
sections: [
{
id: "intro",
label: "Intro",
content: "Keep this section.",
},
{
id: "",
label: "",
content: "",
},
{
id: "missing-content",
label: "Missing Content",
content: " ",
},
null,
"bad-shape",
],
},
{
title: "Fallback Title",
channel: "fallback-channel",
artifactType: "fallback-artifact",
},
);
expect(normalized.sections).toEqual([
{
id: "intro",
label: "Intro",
content: "Keep this section.",
},
]);
});
it("derives previewText deterministically from first valid section when missing", () => {
const normalized = normalizeAgentStructuredOutput(
{
sections: [
{
id: "hook",
label: "Hook",
content: "First section content.",
},
{
id: "cta",
label: "CTA",
content: "Second section content.",
},
],
},
{
title: "Fallback Title",
channel: "fallback-channel",
artifactType: "fallback-artifact",
},
);
expect(normalized.previewText).toBe("First section content.");
});
it("derives deterministic legacy body from sections when body is missing", () => {
const normalized = normalizeAgentStructuredOutput(
{
previewText: "Preview should not override section flattening",
sections: [
{
id: "hook",
label: "Hook",
content: "Lead with a bold claim.",
},
{
id: "cta",
label: "CTA",
content: "Invite replies with a concrete question.",
},
],
},
{
title: "Fallback Title",
channel: "fallback-channel",
artifactType: "fallback-artifact",
},
);
expect(normalized.body).toBe(
"Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.",
);
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getAgentDefinition } from "@/lib/agent-definitions";
import { AGENT_TEMPLATES, getAgentTemplate } from "@/lib/agent-templates"; import { AGENT_TEMPLATES, getAgentTemplate } from "@/lib/agent-templates";
describe("agent templates", () => { describe("agent templates", () => {
@@ -9,14 +10,25 @@ describe("agent templates", () => {
]); ]);
}); });
it("exposes normalized metadata needed by the canvas node", () => { it("projects runtime definition metadata for existing canvas callers", () => {
const template = getAgentTemplate("campaign-distributor"); const template = getAgentTemplate("campaign-distributor");
const definition = getAgentDefinition("campaign-distributor");
expect(template?.name).toBe("Campaign Distributor"); expect(definition).toBeDefined();
expect(template?.color).toBe("yellow");
expect(template?.tools).toContain("WebFetch"); expect(template?.name).toBe(definition?.metadata.name);
expect(template?.channels).toContain("Instagram Feed"); expect(template?.description).toBe(definition?.metadata.description);
expect(template?.expectedInputs).toContain("Render-Node-Export"); expect(template?.emoji).toBe(definition?.metadata.emoji);
expect(template?.expectedOutputs).toContain("Caption-Pakete"); expect(template?.color).toBe(definition?.metadata.color);
expect(template?.vibe).toBe(definition?.metadata.vibe);
expect(template?.tools).toEqual(definition?.uiReference.tools);
expect(template?.channels).toEqual(definition?.channelCatalog);
expect(template?.expectedInputs).toEqual(definition?.uiReference.expectedInputs);
expect(template?.expectedOutputs).toEqual(definition?.uiReference.expectedOutputs);
expect(template?.notes).toEqual(definition?.uiReference.notes);
});
it("keeps unknown template lookup behavior unchanged", () => {
expect(getAgentTemplate("unknown-template")).toBeUndefined();
}); });
}); });

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { import {
clearDashboardSnapshotCache, clearDashboardSnapshotCache,
emitDashboardSnapshotCacheInvalidationSignal, emitDashboardSnapshotCacheInvalidationSignal,
getDashboardSnapshotCacheInvalidationSignalKey,
invalidateDashboardSnapshotForLastSignedInUser, invalidateDashboardSnapshotForLastSignedInUser,
readDashboardSnapshotCache, readDashboardSnapshotCache,
writeDashboardSnapshotCache, writeDashboardSnapshotCache,
@@ -12,7 +13,7 @@ import {
const USER_ID = "user-cache-test"; const USER_ID = "user-cache-test";
const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user"; const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user";
const INVALIDATION_SIGNAL_KEY = "lemonspace.dashboard:snapshot:invalidate:v1"; const INVALIDATION_SIGNAL_KEY = getDashboardSnapshotCacheInvalidationSignalKey();
describe("dashboard snapshot cache", () => { describe("dashboard snapshot cache", () => {
beforeEach(() => { beforeEach(() => {

View File

@@ -32,6 +32,7 @@ export default defineConfig({
"components/canvas/__tests__/asset-browser-panel.test.tsx", "components/canvas/__tests__/asset-browser-panel.test.tsx",
"components/canvas/__tests__/video-browser-panel.test.tsx", "components/canvas/__tests__/video-browser-panel.test.tsx",
"components/media/__tests__/media-preview-utils.test.ts", "components/media/__tests__/media-preview-utils.test.ts",
"components/media/__tests__/media-library-dialog.test.tsx",
], ],
}, },
}); });