feat(agent): localize generated agent workflow

This commit is contained in:
2026-04-10 13:56:11 +02:00
parent 66646bd62f
commit ddb2412349
10 changed files with 950 additions and 89 deletions

View File

@@ -5,6 +5,8 @@ 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";
import type { FunctionReference } from "convex/server"; import type { FunctionReference } from "convex/server";
import { useTranslations } from "next-intl";
import { useLocale } from "next-intl";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -37,6 +39,13 @@ type AgentNodeData = {
templateId?: string; templateId?: string;
canvasId?: string; canvasId?: string;
modelId?: string; modelId?: string;
briefConstraints?: {
briefing?: string;
audience?: string;
tone?: string;
targetChannels?: string[];
hardConstraints?: string[];
};
executionSteps?: Array<{ stepIndex?: number; stepTotal?: number }>; executionSteps?: Array<{ stepIndex?: number; stepTotal?: number }>;
executionStepIndex?: number; executionStepIndex?: number;
executionStepTotal?: number; executionStepTotal?: number;
@@ -48,6 +57,14 @@ type AgentNodeData = {
_statusMessage?: string; _statusMessage?: string;
}; };
type AgentBriefConstraints = {
briefing: string;
audience: string;
tone: string;
targetChannels: string[];
hardConstraints: string[];
};
type AgentNodeType = Node<AgentNodeData, "agent">; type AgentNodeType = Node<AgentNodeData, "agent">;
const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor"; const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor";
@@ -114,6 +131,46 @@ function areAnswerMapsEqual(
return true; return true;
} }
function normalizeDelimitedList(value: string, allowNewLines = false): string[] {
const separator = allowNewLines ? /[\n,]/ : /,/;
return value
.split(separator)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function normalizeBriefConstraints(raw: AgentNodeData["briefConstraints"]): AgentBriefConstraints {
return {
briefing: typeof raw?.briefing === "string" ? raw.briefing : "",
audience: typeof raw?.audience === "string" ? raw.audience : "",
tone: typeof raw?.tone === "string" ? raw.tone : "",
targetChannels: Array.isArray(raw?.targetChannels)
? raw.targetChannels.filter((item): item is string => typeof item === "string")
: [],
hardConstraints: Array.isArray(raw?.hardConstraints)
? raw.hardConstraints.filter((item): item is string => typeof item === "string")
: [],
};
}
function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
function areBriefConstraintsEqual(left: AgentBriefConstraints, right: AgentBriefConstraints): boolean {
return (
left.briefing === right.briefing &&
left.audience === right.audience &&
left.tone === right.tone &&
areStringArraysEqual(left.targetChannels, right.targetChannels) &&
areStringArraysEqual(left.hardConstraints, right.hardConstraints)
);
}
function CompactList({ items }: { items: readonly string[] }) { function CompactList({ items }: { items: readonly string[] }) {
return ( return (
<ul className="space-y-1"> <ul className="space-y-1">
@@ -127,6 +184,8 @@ function CompactList({ items }: { items: readonly string[] }) {
} }
export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeType>) { export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeType>) {
const t = useTranslations("agentNode");
const locale = useLocale();
const nodeData = data as AgentNodeData; const nodeData = data as AgentNodeData;
const template = const template =
getAgentTemplate(nodeData.templateId ?? DEFAULT_AGENT_TEMPLATE_ID) ?? getAgentTemplate(nodeData.templateId ?? DEFAULT_AGENT_TEMPLATE_ID) ??
@@ -140,6 +199,9 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const [clarificationAnswers, setClarificationAnswers] = useState<AgentClarificationAnswerMap>( const [clarificationAnswers, setClarificationAnswers] = useState<AgentClarificationAnswerMap>(
normalizeClarificationAnswers(nodeData.clarificationAnswers), normalizeClarificationAnswers(nodeData.clarificationAnswers),
); );
const [briefConstraints, setBriefConstraints] = useState<AgentBriefConstraints>(
normalizeBriefConstraints(nodeData.briefConstraints),
);
const agentActionsApi = api as unknown as { const agentActionsApi = api as unknown as {
agents: { agents: {
@@ -150,6 +212,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
modelId: string; modelId: string;
locale: "de" | "en";
}, },
unknown unknown
>; >;
@@ -160,6 +223,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
clarificationAnswers: AgentClarificationAnswerMap; clarificationAnswers: AgentClarificationAnswerMap;
locale: "de" | "en";
}, },
unknown unknown
>; >;
@@ -168,6 +232,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const runAgent = useSafeAction(agentActionsApi.agents.runAgent); const runAgent = useSafeAction(agentActionsApi.agents.runAgent);
const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent); const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent);
const normalizedLocale = locale === "en" ? "en" : "de";
useEffect(() => { useEffect(() => {
setModelId(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID); setModelId(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID);
@@ -183,6 +248,16 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
}); });
}, [nodeData.clarificationAnswers]); }, [nodeData.clarificationAnswers]);
useEffect(() => {
const normalized = normalizeBriefConstraints(nodeData.briefConstraints);
setBriefConstraints((current) => {
if (areBriefConstraintsEqual(current, normalized)) {
return current;
}
return normalized;
});
}, [nodeData.briefConstraints]);
useEffect(() => { useEffect(() => {
if (availableModels.length === 0) { if (availableModels.length === 0) {
return; return;
@@ -202,6 +277,14 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const resolvedModelId = selectedModel?.id ?? 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 templateName =
template?.id === "campaign-distributor"
? t("templates.campaignDistributor.name")
: (template?.name ?? "");
const templateDescription =
template?.id === "campaign-distributor"
? t("templates.campaignDistributor.description")
: (template?.description ?? "");
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") {
@@ -230,14 +313,19 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
stepTotalCandidate > 0; stepTotalCandidate > 0;
if (hasExecutionNumbers) { if (hasExecutionNumbers) {
return `Executing step ${Math.max(0, Math.floor(stepIndexCandidate)) + 1}/${Math.floor(stepTotalCandidate)}`; return t("executingStepFallback", {
current: Math.max(0, Math.floor(stepIndexCandidate)) + 1,
total: Math.floor(stepTotalCandidate),
});
} }
if (totalFromSteps > 0) { if (totalFromSteps > 0) {
return `Executing planned outputs (${totalFromSteps} total)`; return t("executingPlannedTotalFallback", {
total: totalFromSteps,
});
} }
return "Executing planned outputs"; return t("executingPlannedFallback");
}, [ }, [
nodeData._executionStepIndex, nodeData._executionStepIndex,
nodeData._executionStepTotal, nodeData._executionStepTotal,
@@ -246,8 +334,25 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
nodeData.executionStepIndex, nodeData.executionStepIndex,
nodeData.executionStepTotal, nodeData.executionStepTotal,
nodeData.executionSteps, nodeData.executionSteps,
t,
]); ]);
const resolveClarificationPrompt = useCallback(
(question: AgentClarificationQuestion) => {
if (question.id === "briefing") {
return t("clarificationPrompts.briefing");
}
if (question.id === "target-channels") {
return t("clarificationPrompts.targetChannels");
}
if (question.id === "incoming-context") {
return t("clarificationPrompts.incomingContext");
}
return question.prompt;
},
[t],
);
const persistNodeData = useCallback( const persistNodeData = useCallback(
(patch: Partial<AgentNodeData>) => { (patch: Partial<AgentNodeData>) => {
const raw = data as Record<string, unknown>; const raw = data as Record<string, unknown>;
@@ -288,6 +393,20 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
[persistNodeData], [persistNodeData],
); );
const handleBriefConstraintsChange = useCallback(
(patch: Partial<AgentBriefConstraints>) => {
setBriefConstraints((prev) => {
const next = {
...prev,
...patch,
};
void persistNodeData({ briefConstraints: next });
return next;
});
},
[persistNodeData],
);
const handleRunAgent = useCallback(async () => { const handleRunAgent = useCallback(async () => {
if (isExecutionActive) { if (isExecutionActive) {
return; return;
@@ -295,8 +414,8 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
if (status.isOffline) { if (status.isOffline) {
toast.warning( toast.warning(
"Offline aktuell nicht unterstuetzt", t("offlineTitle"),
"Agent-Lauf benoetigt eine aktive Verbindung.", t("offlineDescription"),
); );
return; return;
} }
@@ -310,14 +429,15 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
canvasId, canvasId,
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
modelId: resolvedModelId, modelId: resolvedModelId,
locale: normalizedLocale,
}); });
}, [isExecutionActive, nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]); }, [isExecutionActive, nodeData.canvasId, id, normalizedLocale, resolvedModelId, runAgent, status.isOffline, t]);
const handleSubmitClarification = useCallback(async () => { const handleSubmitClarification = useCallback(async () => {
if (status.isOffline) { if (status.isOffline) {
toast.warning( toast.warning(
"Offline aktuell nicht unterstuetzt", t("offlineTitle"),
"Agent-Lauf benoetigt eine aktive Verbindung.", t("offlineDescription"),
); );
return; return;
} }
@@ -331,8 +451,9 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
canvasId, canvasId,
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
clarificationAnswers, clarificationAnswers,
locale: normalizedLocale,
}); });
}, [clarificationAnswers, nodeData.canvasId, id, resumeAgent, status.isOffline]); }, [clarificationAnswers, nodeData.canvasId, id, normalizedLocale, resumeAgent, status.isOffline, t]);
if (!template) { if (!template) {
return null; return null;
@@ -363,14 +484,14 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300"> <div className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
<Bot className="h-3.5 w-3.5" /> <Bot className="h-3.5 w-3.5" />
<span>{template.emoji}</span> <span>{template.emoji}</span>
<span>{template.name}</span> <span>{templateName}</span>
</div> </div>
<p className="line-clamp-2 text-xs text-muted-foreground">{template.description}</p> <p className="line-clamp-2 text-xs text-muted-foreground">{templateDescription}</p>
</header> </header>
<section className="space-y-1.5"> <section className="space-y-1.5">
<Label htmlFor={`agent-model-${id}`} className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <Label htmlFor={`agent-model-${id}`} className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Model {t("modelLabel")}
</Label> </Label>
<Select value={resolvedModelId} onValueChange={handleModelChange}> <Select value={resolvedModelId} onValueChange={handleModelChange}>
<SelectTrigger id={`agent-model-${id}`} className="nodrag nowheel w-full" size="sm"> <SelectTrigger id={`agent-model-${id}`} className="nodrag nowheel w-full" size="sm">
@@ -385,10 +506,98 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{selectedModel?.label ?? resolvedModelId} - {creditCost} Cr {t("modelCreditMeta", {
model: selectedModel?.label ?? resolvedModelId,
credits: creditCost,
})}
</p> </p>
</section> </section>
<section className="space-y-1.5">
<Label htmlFor={`agent-${id}-briefing`} className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("briefingLabel")}
</Label>
<textarea
id={`agent-${id}-briefing`}
name="agent-briefing"
value={briefConstraints.briefing}
onChange={(event) =>
handleBriefConstraintsChange({ briefing: event.target.value })
}
placeholder={t("briefingPlaceholder")}
className="nodrag nowheel min-h-20 w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</section>
<section className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("constraintsLabel")}
</p>
<div className="space-y-1">
<label htmlFor={`agent-${id}-audience`} className="text-[11px] text-muted-foreground">
{t("audienceLabel")}
</label>
<input
id={`agent-${id}-audience`}
name="agent-audience"
type="text"
value={briefConstraints.audience}
onChange={(event) =>
handleBriefConstraintsChange({ audience: event.target.value })
}
className="nodrag nowheel w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
<div className="space-y-1">
<label htmlFor={`agent-${id}-tone`} className="text-[11px] text-muted-foreground">
{t("toneLabel")}
</label>
<input
id={`agent-${id}-tone`}
name="agent-tone"
type="text"
value={briefConstraints.tone}
onChange={(event) => handleBriefConstraintsChange({ tone: event.target.value })}
className="nodrag nowheel w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
<div className="space-y-1">
<label htmlFor={`agent-${id}-target-channels`} className="text-[11px] text-muted-foreground">
{t("targetChannelsLabel")}
</label>
<input
id={`agent-${id}-target-channels`}
name="agent-target-channels"
type="text"
value={briefConstraints.targetChannels.join(", ")}
onChange={(event) =>
handleBriefConstraintsChange({
targetChannels: normalizeDelimitedList(event.target.value),
})
}
placeholder={t("targetChannelsPlaceholder")}
className="nodrag nowheel w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
<div className="space-y-1">
<label htmlFor={`agent-${id}-hard-constraints`} className="text-[11px] text-muted-foreground">
{t("hardConstraintsLabel")}
</label>
<textarea
id={`agent-${id}-hard-constraints`}
name="agent-hard-constraints"
value={briefConstraints.hardConstraints.join("\n")}
onChange={(event) =>
handleBriefConstraintsChange({
hardConstraints: normalizeDelimitedList(event.target.value, true),
})
}
placeholder={t("hardConstraintsPlaceholder")}
className="nodrag nowheel min-h-16 w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
</section>
<section className="space-y-1.5"> <section className="space-y-1.5">
<button <button
type="button" type="button"
@@ -397,7 +606,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
aria-busy={isExecutionActive} aria-busy={isExecutionActive}
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60" className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
> >
Run agent {t("runAgentButton")}
</button> </button>
{executionProgressLine ? ( {executionProgressLine ? (
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{executionProgressLine}</p> <p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{executionProgressLine}</p>
@@ -407,7 +616,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
{clarificationQuestions.length > 0 ? ( {clarificationQuestions.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Clarifications {t("clarificationsLabel")}
</p> </p>
{clarificationQuestions.map((question) => ( {clarificationQuestions.map((question) => (
<div key={question.id} className="space-y-1"> <div key={question.id} className="space-y-1">
@@ -415,7 +624,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
htmlFor={`agent-${id}-clarification-${question.id}`} htmlFor={`agent-${id}-clarification-${question.id}`}
className="text-[11px] text-foreground/90" className="text-[11px] text-foreground/90"
> >
{question.prompt} {resolveClarificationPrompt(question)}
{question.required ? " *" : ""} {question.required ? " *" : ""}
</label> </label>
<input <input
@@ -435,32 +644,37 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
onClick={() => void handleSubmitClarification()} onClick={() => void handleSubmitClarification()}
className="nodrag w-full rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-500/20 dark:text-amber-200" className="nodrag w-full rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-500/20 dark:text-amber-200"
> >
Submit clarification {t("submitClarificationButton")}
</button> </button>
</section> </section>
) : null} ) : null}
<details className="rounded-md border border-border/60 bg-muted/20 px-2 py-1.5">
<summary className="cursor-pointer text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("templateReferenceLabel")}
</summary>
<div className="mt-2 space-y-2">
<section className="space-y-1"> <section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Channels {t("templateReferenceChannelsLabel")} ({template.channels.length})
</p> </p>
<CompactList items={template.channels} /> <CompactList items={template.channels} />
</section> </section>
<section className="space-y-1"> <section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Expected Inputs {t("templateReferenceInputsLabel")} ({template.expectedInputs.length})
</p> </p>
<CompactList items={template.expectedInputs} /> <CompactList items={template.expectedInputs} />
</section> </section>
<section className="space-y-1"> <section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Expected Outputs {t("templateReferenceOutputsLabel")} ({template.expectedOutputs.length})
</p> </p>
<CompactList items={template.expectedOutputs} /> <CompactList items={template.expectedOutputs} />
</section> </section>
</div> </div>
</details>
</div>
</BaseNodeWrapper> </BaseNodeWrapper>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
@@ -19,7 +20,28 @@ type AgentOutputNodeData = {
type AgentOutputNodeType = Node<AgentOutputNodeData, "agent-output">; type AgentOutputNodeType = Node<AgentOutputNodeData, "agent-output">;
function tryFormatJsonBody(body: string): string | null {
const trimmed = body.trim();
if (!trimmed) {
return null;
}
const looksLikeJsonObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const looksLikeJsonArray = trimmed.startsWith("[") && trimmed.endsWith("]");
if (!looksLikeJsonObject && !looksLikeJsonArray) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
return JSON.stringify(parsed, null, 2);
} catch {
return null;
}
}
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) { export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
const t = useTranslations("agentOutputNode");
const nodeData = data as AgentOutputNodeData; const nodeData = data as AgentOutputNodeData;
const isSkeleton = nodeData.isSkeleton === true; const isSkeleton = nodeData.isSkeleton === true;
const hasStepCounter = const hasStepCounter =
@@ -39,7 +61,11 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
const stepCounter = hasStepCounter const stepCounter = hasStepCounter
? `${safeStepIndex + 1}/${safeStepTotal}` ? `${safeStepIndex + 1}/${safeStepTotal}`
: null; : null;
const resolvedTitle = nodeData.title ?? (isSkeleton ? "Planned output" : "Agent output"); const resolvedTitle =
nodeData.title ??
(isSkeleton ? t("plannedOutputDefaultTitle") : t("defaultTitle"));
const body = nodeData.body ?? "";
const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body);
return ( return (
<BaseNodeWrapper <BaseNodeWrapper
@@ -64,51 +90,62 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
</p> </p>
{isSkeleton ? ( {isSkeleton ? (
<span className="shrink-0 rounded-full border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-200"> <span className="shrink-0 rounded-full border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-200">
Skeleton {t("skeletonBadge")}
</span> </span>
) : null} ) : null}
</div> </div>
{isSkeleton ? ( {isSkeleton ? (
<p className="text-[11px] text-amber-700/90 dark:text-amber-300/90"> <p className="text-[11px] text-amber-700/90 dark:text-amber-300/90">
Planned output{stepCounter ? ` - ${stepCounter}` : ""} {t("plannedOutputLabel")}
{stepCounter ? ` - ${stepCounter}` : ""}
{nodeData.stepId ? ` - ${nodeData.stepId}` : ""} {nodeData.stepId ? ` - ${nodeData.stepId}` : ""}
</p> </p>
) : null} ) : null}
</header> </header>
<section className="space-y-1"> <section
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> data-testid="agent-output-meta-strip"
Channel className="grid grid-cols-2 gap-2 rounded-md border border-border/70 bg-muted/30 px-2 py-1.5"
</p> >
<p className="truncate text-xs text-foreground/90" title={nodeData.channel}> <div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
{nodeData.channel ?? "-"} {nodeData.channel ?? "-"}
</p> </p>
</section> </div>
<div className="min-w-0">
<section className="space-y-1"> <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"> <p className="truncate text-xs font-medium text-foreground/90" title={nodeData.outputType}>
Output Type
</p>
<p className="truncate text-xs text-foreground/90" title={nodeData.outputType}>
{nodeData.outputType ?? "-"} {nodeData.outputType ?? "-"}
</p> </p>
</div>
</section> </section>
<section className="space-y-1"> <section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Body {t("bodyLabel")}
</p> </p>
{isSkeleton ? ( {isSkeleton ? (
<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">Planned content</p> <p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{t("plannedContent")}</p>
</div> </div>
) : formattedJsonBody ? (
<pre
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"
>
<code>{formattedJsonBody}</code>
</pre>
) : ( ) : (
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90"> <div
{nodeData.body ?? ""} data-testid="agent-output-text-body"
</p> 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>
</div>
)} )}
</section> </section>
</div> </div>

View File

@@ -14,8 +14,12 @@ import { getNodeDataRecord } from "./ai_node_data";
import { formatTerminalStatusMessage } from "./ai_errors"; import { formatTerminalStatusMessage } from "./ai_errors";
import { import {
areClarificationAnswersComplete, areClarificationAnswersComplete,
buildPreflightClarificationQuestions,
normalizeAgentBriefConstraints,
normalizeAgentExecutionPlan, normalizeAgentExecutionPlan,
normalizeAgentLocale,
normalizeAgentOutputDraft, normalizeAgentOutputDraft,
type AgentLocale,
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
type AgentClarificationQuestion, type AgentClarificationQuestion,
type AgentExecutionStep, type AgentExecutionStep,
@@ -109,6 +113,14 @@ 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<
@@ -130,6 +142,7 @@ type InternalApiShape = {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
modelId: string; modelId: string;
locale: AgentLocale;
userId: string; userId: string;
reservationId?: Id<"creditTransactions">; reservationId?: Id<"creditTransactions">;
shouldDecrementConcurrency: boolean; shouldDecrementConcurrency: boolean;
@@ -143,6 +156,7 @@ type InternalApiShape = {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
modelId: string; modelId: string;
locale: AgentLocale;
userId: string; userId: string;
reservationId?: Id<"creditTransactions">; reservationId?: Id<"creditTransactions">;
shouldDecrementConcurrency: boolean; shouldDecrementConcurrency: boolean;
@@ -401,6 +415,13 @@ function collectIncomingContext(
return lines.length > 0 ? lines.join("\n") : "No incoming nodes connected to this agent."; return lines.length > 0 ? lines.join("\n") : "No incoming nodes connected to this agent.";
} }
function countIncomingContext(
graph: { edges: Doc<"edges">[] },
agentNodeId: Id<"nodes">,
): number {
return graph.edges.filter((edge) => edge.targetNodeId === agentNodeId).length;
}
function getAgentNodeFromGraph( function getAgentNodeFromGraph(
graph: { nodes: Doc<"nodes">[] }, graph: { nodes: Doc<"nodes">[] },
nodeId: Id<"nodes">, nodeId: Id<"nodes">,
@@ -831,6 +852,7 @@ export const analyzeAgent = internalAction({
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
modelId: v.string(), modelId: v.string(),
locale: v.union(v.literal("de"), v.literal("en")),
userId: v.string(), userId: v.string(),
reservationId: v.optional(v.id("creditTransactions")), reservationId: v.optional(v.id("creditTransactions")),
shouldDecrementConcurrency: v.boolean(), shouldDecrementConcurrency: v.boolean(),
@@ -850,7 +872,27 @@ export const analyzeAgent = internalAction({
const agentData = getNodeDataRecord(agentNode.data); const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor"); const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers); const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId); const incomingContext = collectIncomingContext(graph, args.nodeId);
const incomingContextCount = countIncomingContext(graph, args.nodeId);
const preflightClarificationQuestions = buildPreflightClarificationQuestions({
briefConstraints,
incomingContextCount,
});
const hasPreflightRequiredGaps = !areClarificationAnswersComplete(
preflightClarificationQuestions,
existingAnswers,
);
if (preflightClarificationQuestions.length > 0 && hasPreflightRequiredGaps) {
await ctx.runMutation(internalApi.agents.setAgentClarifying, {
nodeId: args.nodeId,
clarificationQuestions: preflightClarificationQuestions,
});
return;
}
const analysis = await generateStructuredObjectViaOpenRouter<{ const analysis = await generateStructuredObjectViaOpenRouter<{
analysisSummary: string; analysisSummary: string;
@@ -864,15 +906,20 @@ export const analyzeAgent = internalAction({
{ {
role: "system", role: "system",
content: content:
[
"You are the LemonSpace Agent Analyzer. Inspect incoming canvas context and decide if clarification is required before execution. Ask only necessary short questions.", "You are the LemonSpace Agent Analyzer. Inspect incoming canvas context and decide if clarification is required before execution. Ask only necessary short questions.",
getOutputLanguageInstruction(locale),
].join(" "),
}, },
{ {
role: "user", role: "user",
content: [ content: [
`Template: ${template?.name ?? "Unknown template"}`, `Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`, `Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
"Incoming node context:", "Incoming node context:",
incomingContext, incomingContext,
`Incoming context node count: ${incomingContextCount}`,
`Current clarification answers: ${JSON.stringify(existingAnswers)}`, `Current clarification answers: ${JSON.stringify(existingAnswers)}`,
"Return structured JSON matching the schema.", "Return structured JSON matching the schema.",
].join("\n\n"), ].join("\n\n"),
@@ -914,6 +961,7 @@ export const analyzeAgent = internalAction({
canvasId: args.canvasId, canvasId: args.canvasId,
nodeId: args.nodeId, nodeId: args.nodeId,
modelId: args.modelId, modelId: args.modelId,
locale,
userId: args.userId, userId: args.userId,
reservationId: args.reservationId, reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency, shouldDecrementConcurrency: args.shouldDecrementConcurrency,
@@ -934,6 +982,7 @@ export const executeAgent = internalAction({
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
modelId: v.string(), modelId: v.string(),
locale: v.union(v.literal("de"), v.literal("en")),
userId: v.string(), userId: v.string(),
reservationId: v.optional(v.id("creditTransactions")), reservationId: v.optional(v.id("creditTransactions")),
shouldDecrementConcurrency: v.boolean(), shouldDecrementConcurrency: v.boolean(),
@@ -954,6 +1003,8 @@ export const executeAgent = internalAction({
const agentData = getNodeDataRecord(agentNode.data); const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor"); const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers); const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId); const incomingContext = collectIncomingContext(graph, args.nodeId);
const executionPlanSummary = trimText(agentData.executionPlanSummary); const executionPlanSummary = trimText(agentData.executionPlanSummary);
const executionSteps = normalizeExecutionSteps(agentData.executionSteps); const executionSteps = normalizeExecutionSteps(agentData.executionSteps);
@@ -975,13 +1026,17 @@ export const executeAgent = internalAction({
{ {
role: "system", role: "system",
content: content:
[
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.", "You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.",
getOutputLanguageInstruction(locale),
].join(" "),
}, },
{ {
role: "user", role: "user",
content: [ content: [
`Template: ${template?.name ?? "Unknown template"}`, `Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`, `Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
`Analyze summary: ${executionPlanSummary}`, `Analyze summary: ${executionPlanSummary}`,
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`, `Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
`Execution steps: ${JSON.stringify( `Execution steps: ${JSON.stringify(
@@ -1066,6 +1121,7 @@ export const runAgent = action({
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
modelId: v.string(), modelId: v.string(),
locale: v.union(v.literal("de"), v.literal("en")),
}, },
handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => { handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => {
const canvas = await ctx.runQuery(api.canvases.get, { const canvas = await ctx.runQuery(api.canvases.get, {
@@ -1126,6 +1182,7 @@ export const runAgent = action({
canvasId: args.canvasId, canvasId: args.canvasId,
nodeId: args.nodeId, nodeId: args.nodeId,
modelId: selectedModel.id, modelId: selectedModel.id,
locale: normalizeAgentLocale(args.locale),
userId: canvas.ownerId, userId: canvas.ownerId,
reservationId: reservationId ?? undefined, reservationId: reservationId ?? undefined,
shouldDecrementConcurrency: usageIncremented, shouldDecrementConcurrency: usageIncremented,
@@ -1155,6 +1212,7 @@ export const resumeAgent = action({
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
clarificationAnswers: v.record(v.string(), v.string()), clarificationAnswers: v.record(v.string(), v.string()),
locale: v.union(v.literal("de"), v.literal("en")),
}, },
handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => { handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => {
const canvas = await ctx.runQuery(api.canvases.get, { const canvas = await ctx.runQuery(api.canvases.get, {
@@ -1211,6 +1269,7 @@ export const resumeAgent = action({
canvasId: args.canvasId, canvasId: args.canvasId,
nodeId: args.nodeId, nodeId: args.nodeId,
modelId, modelId,
locale: normalizeAgentLocale(args.locale),
userId: canvas.ownerId, userId: canvas.ownerId,
reservationId, reservationId,
shouldDecrementConcurrency, shouldDecrementConcurrency,

View File

@@ -25,6 +25,16 @@ export type AgentExecutionPlan = {
steps: AgentExecutionStep[]; steps: AgentExecutionStep[];
}; };
export type AgentBriefConstraints = {
briefing: string;
audience: string;
tone: string;
targetChannels: string[];
hardConstraints: string[];
};
export type AgentLocale = "de" | "en";
export type AgentAnalyzeResult = { export type AgentAnalyzeResult = {
clarificationQuestions: AgentClarificationQuestion[]; clarificationQuestions: AgentClarificationQuestion[];
executionPlan: AgentExecutionPlan | null; executionPlan: AgentExecutionPlan | null;
@@ -46,6 +56,102 @@ function normalizeStepId(value: unknown): string {
.replace(/\s+/g, "-"); .replace(/\s+/g, "-");
} }
function normalizeStringArray(raw: unknown, options?: { lowerCase?: boolean }): string[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const item of raw) {
const trimmed = trimString(item);
if (trimmed === "") {
continue;
}
const value = options?.lowerCase ? trimmed.toLowerCase() : trimmed;
if (seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
export function normalizeAgentBriefConstraints(raw: unknown): AgentBriefConstraints {
const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: null;
return {
briefing: trimString(rawRecord?.briefing),
audience: trimString(rawRecord?.audience),
tone: trimString(rawRecord?.tone),
targetChannels: normalizeStringArray(rawRecord?.targetChannels, { lowerCase: true }),
hardConstraints: normalizeStringArray(rawRecord?.hardConstraints),
};
}
export function normalizeAgentLocale(raw: unknown): AgentLocale {
if (raw === "de" || raw === "en") {
return raw;
}
return "de";
}
export type PreflightClarificationInput = {
briefConstraints: AgentBriefConstraints | unknown;
incomingContextCount: number;
};
const BRIEFING_REQUIRED_QUESTION: AgentClarificationQuestion = {
id: "briefing",
prompt: "What should the agent produce? Provide the brief in one or two sentences.",
required: true,
};
const TARGET_CHANNELS_REQUIRED_QUESTION: AgentClarificationQuestion = {
id: "target-channels",
prompt: "Which channels should this run target? List at least one channel.",
required: true,
};
const INCOMING_CONTEXT_REQUIRED_QUESTION: AgentClarificationQuestion = {
id: "incoming-context",
prompt: "No context was provided. What source context should the agent use?",
required: true,
};
export function buildPreflightClarificationQuestions(
input: PreflightClarificationInput,
): AgentClarificationQuestion[] {
const normalizedBriefConstraints = normalizeAgentBriefConstraints(input.briefConstraints);
const incomingContextCount = Number.isFinite(input.incomingContextCount)
? Math.max(0, Math.trunc(input.incomingContextCount))
: 0;
const questions: AgentClarificationQuestion[] = [];
if (normalizedBriefConstraints.briefing === "") {
questions.push(BRIEFING_REQUIRED_QUESTION);
}
if (normalizedBriefConstraints.targetChannels.length === 0) {
questions.push(TARGET_CHANNELS_REQUIRED_QUESTION);
}
if (incomingContextCount === 0) {
questions.push(INCOMING_CONTEXT_REQUIRED_QUESTION);
}
return questions;
}
export function normalizeAgentExecutionPlan(raw: unknown): AgentExecutionPlan { export function normalizeAgentExecutionPlan(raw: unknown): AgentExecutionPlan {
const rawRecord = const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw) raw && typeof raw === "object" && !Array.isArray(raw)

View File

@@ -176,6 +176,52 @@
"creditMeta": "{credits} Credits", "creditMeta": "{credits} Credits",
"errorFallback": "Video-Generierung fehlgeschlagen" "errorFallback": "Video-Generierung fehlgeschlagen"
}, },
"agentNode": {
"templates": {
"campaignDistributor": {
"name": "Campaign Distributor",
"description": "Entwickelt und verteilt LemonSpace-Kampagneninhalte kanal- und plattformgerecht."
}
},
"modelLabel": "Modell",
"modelCreditMeta": "{model} - {credits} Cr",
"briefingLabel": "Briefing",
"briefingPlaceholder": "Beschreibe Aufgabe und gewuenschtes Ergebnis kurz und konkret.",
"constraintsLabel": "Vorgaben",
"audienceLabel": "Zielgruppe",
"toneLabel": "Tonfall",
"targetChannelsLabel": "Zielkanaele",
"targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"hardConstraintsLabel": "Harte Constraints",
"hardConstraintsPlaceholder": "Keine Emojis\nMaximal 120 Woerter",
"runAgentButton": "Agent starten",
"clarificationsLabel": "Rueckfragen",
"submitClarificationButton": "Rueckfragen bestaetigen",
"templateReferenceLabel": "Template-Referenz",
"templateReferenceChannelsLabel": "Kanaele",
"templateReferenceInputsLabel": "Inputs",
"templateReferenceOutputsLabel": "Outputs",
"executingStepFallback": "Schritt {current}/{total} wird ausgefuehrt",
"executingPlannedTotalFallback": "Geplante Outputs werden erstellt ({total} gesamt)",
"executingPlannedFallback": "Geplante Outputs werden erstellt",
"offlineTitle": "Offline derzeit nicht verfuegbar",
"offlineDescription": "Ein Agent-Lauf benoetigt eine aktive Verbindung.",
"clarificationPrompts": {
"briefing": "Was soll der Agent liefern? Bitte formuliere das Briefing in ein bis zwei Saetzen.",
"targetChannels": "Welche Kanaele soll dieser Lauf bedienen? Bitte nenne mindestens einen Kanal.",
"incomingContext": "Es wurde kein Kontext verbunden. Welche Quelle soll der Agent verwenden?"
}
},
"agentOutputNode": {
"defaultTitle": "Agent-Output",
"plannedOutputDefaultTitle": "Geplanter Output",
"skeletonBadge": "SKELETON",
"plannedOutputLabel": "Geplanter Output",
"channelLabel": "Kanal",
"typeLabel": "Typ",
"bodyLabel": "Inhalt",
"plannedContent": "Geplanter Inhalt"
},
"credits": { "credits": {
"balance": "Guthaben", "balance": "Guthaben",
"available": "Verfügbar", "available": "Verfügbar",

View File

@@ -176,6 +176,52 @@
"creditMeta": "{credits} credits", "creditMeta": "{credits} credits",
"errorFallback": "Video generation failed" "errorFallback": "Video generation failed"
}, },
"agentNode": {
"templates": {
"campaignDistributor": {
"name": "Campaign Distributor",
"description": "Develops and distributes LemonSpace campaign content across social media and messenger channels."
}
},
"modelLabel": "Model",
"modelCreditMeta": "{model} - {credits} Cr",
"briefingLabel": "Briefing",
"briefingPlaceholder": "Describe the core task and desired output.",
"constraintsLabel": "Constraints",
"audienceLabel": "Audience",
"toneLabel": "Tone",
"targetChannelsLabel": "Target channels",
"targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"hardConstraintsLabel": "Hard constraints",
"hardConstraintsPlaceholder": "No emojis\nMax 120 words",
"runAgentButton": "Run agent",
"clarificationsLabel": "Clarifications",
"submitClarificationButton": "Submit clarification",
"templateReferenceLabel": "Template reference",
"templateReferenceChannelsLabel": "Channels",
"templateReferenceInputsLabel": "Inputs",
"templateReferenceOutputsLabel": "Outputs",
"executingStepFallback": "Executing step {current}/{total}",
"executingPlannedTotalFallback": "Executing planned outputs ({total} total)",
"executingPlannedFallback": "Executing planned outputs",
"offlineTitle": "Offline currently not supported",
"offlineDescription": "Agent run requires an active connection.",
"clarificationPrompts": {
"briefing": "What should the agent produce? Provide the brief in one or two sentences.",
"targetChannels": "Which channels should this run target? List at least one channel.",
"incomingContext": "No context was provided. What source context should the agent use?"
}
},
"agentOutputNode": {
"defaultTitle": "Agent output",
"plannedOutputDefaultTitle": "Planned output",
"skeletonBadge": "Skeleton",
"plannedOutputLabel": "Planned output",
"channelLabel": "Channel",
"typeLabel": "Type",
"bodyLabel": "Body",
"plannedContent": "Planned content"
},
"credits": { "credits": {
"balance": "Balance", "balance": "Balance",
"available": "Available", "available": "Available",

View File

@@ -89,6 +89,56 @@ 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),
})); }));
const translations: Record<string, string> = {
"agentNode.templates.campaignDistributor.name": "Campaign Distributor",
"agentNode.templates.campaignDistributor.description":
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
"agentNode.modelLabel": "Model",
"agentNode.modelCreditMeta": "{model} - {credits} Cr",
"agentNode.briefingLabel": "Briefing",
"agentNode.briefingPlaceholder": "Describe the core task and desired output.",
"agentNode.constraintsLabel": "Constraints",
"agentNode.audienceLabel": "Audience",
"agentNode.toneLabel": "Tone",
"agentNode.targetChannelsLabel": "Target channels",
"agentNode.targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"agentNode.hardConstraintsLabel": "Hard constraints",
"agentNode.hardConstraintsPlaceholder": "No emojis\nMax 120 words",
"agentNode.runAgentButton": "Run agent",
"agentNode.clarificationsLabel": "Clarifications",
"agentNode.submitClarificationButton": "Submit clarification",
"agentNode.templateReferenceLabel": "Template reference",
"agentNode.templateReferenceChannelsLabel": "Channels",
"agentNode.templateReferenceInputsLabel": "Inputs",
"agentNode.templateReferenceOutputsLabel": "Outputs",
"agentNode.executingStepFallback": "Executing step {current}/{total}",
"agentNode.executingPlannedTotalFallback": "Executing planned outputs ({total} total)",
"agentNode.executingPlannedFallback": "Executing planned outputs",
"agentNode.offlineTitle": "Offline currently not supported",
"agentNode.offlineDescription": "Agent run requires an active connection.",
"agentNode.clarificationPrompts.briefing":
"What should the agent produce? Provide the brief in one or two sentences.",
"agentNode.clarificationPrompts.targetChannels":
"Which channels should this run target? List at least one channel.",
"agentNode.clarificationPrompts.incomingContext":
"No context was provided. What source context should the agent use?",
};
vi.mock("next-intl", () => ({
useLocale: () => "de",
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
@@ -143,8 +193,15 @@ describe("AgentNode runtime", () => {
canvasId: "canvas-1", canvasId: "canvas-1",
templateId: "campaign-distributor", templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini", modelId: "openai/gpt-5.4-mini",
briefConstraints: {
briefing: "Draft channel-ready campaign copy",
audience: "SaaS founders",
tone: "Confident and practical",
targetChannels: ["LinkedIn", "Instagram Feed"],
hardConstraints: ["No emojis", "Max 120 words"],
},
clarificationQuestions: [ clarificationQuestions: [
{ id: "audience", prompt: "Target audience?", required: true }, { id: "briefing", prompt: "RAW_BRIEFING_PROMPT", required: true },
], ],
clarificationAnswers: {}, clarificationAnswers: {},
} as Record<string, unknown>, } as Record<string, unknown>,
@@ -167,9 +224,27 @@ describe("AgentNode runtime", () => {
expect(container.textContent).toContain("GPT-5.4 Mini"); expect(container.textContent).toContain("GPT-5.4 Mini");
expect(container.textContent).toContain("15 Cr"); expect(container.textContent).toContain("15 Cr");
expect(container.textContent).toContain("Channels"); expect(container.textContent).toContain("Briefing");
expect(container.textContent).toContain("Expected Inputs"); expect(container.textContent).toContain("Constraints");
expect(container.textContent).toContain("Expected Outputs"); expect(container.textContent).toContain("Template reference");
const briefingTextarea = container.querySelector('textarea[name="agent-briefing"]');
if (!(briefingTextarea instanceof HTMLTextAreaElement)) {
throw new Error("Briefing textarea not found");
}
expect(briefingTextarea.value).toBe("Draft channel-ready campaign copy");
const targetChannelsInput = container.querySelector('input[name="agent-target-channels"]');
if (!(targetChannelsInput instanceof HTMLInputElement)) {
throw new Error("Target channels input not found");
}
expect(targetChannelsInput.value).toBe("LinkedIn, Instagram Feed");
const hardConstraintsInput = container.querySelector('textarea[name="agent-hard-constraints"]');
if (!(hardConstraintsInput instanceof HTMLTextAreaElement)) {
throw new Error("Hard constraints textarea not found");
}
expect(hardConstraintsInput.value).toBe("No emojis\nMax 120 words");
await act(async () => { await act(async () => {
modelSelect.value = "openai/gpt-5.4"; modelSelect.value = "openai/gpt-5.4";
@@ -183,7 +258,71 @@ describe("AgentNode runtime", () => {
}), }),
); );
const clarificationInput = container.querySelector('input[name="clarification-audience"]'); await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(briefingTextarea, "Adapt this launch to each channel");
briefingTextarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
briefing: "Adapt this launch to each channel",
}),
}),
}),
);
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(targetChannelsInput, "LinkedIn, X, TikTok");
targetChannelsInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
targetChannels: ["LinkedIn", "X", "TikTok"],
}),
}),
}),
);
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(hardConstraintsInput, "No emojis\nMax 80 words, include CTA");
hardConstraintsInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
hardConstraints: ["No emojis", "Max 80 words", "include CTA"],
}),
}),
}),
);
expect(container.textContent).toContain(
"What should the agent produce? Provide the brief in one or two sentences.",
);
const clarificationInput = container.querySelector('input[name="clarification-briefing"]');
if (!(clarificationInput instanceof HTMLInputElement)) { if (!(clarificationInput instanceof HTMLInputElement)) {
throw new Error("Clarification input not found"); throw new Error("Clarification input not found");
} }
@@ -201,7 +340,7 @@ describe("AgentNode runtime", () => {
expect.objectContaining({ expect.objectContaining({
nodeId: "agent-1", nodeId: "agent-1",
data: expect.objectContaining({ data: expect.objectContaining({
clarificationAnswers: expect.objectContaining({ audience: "SaaS founders" }), clarificationAnswers: expect.objectContaining({ briefing: "SaaS founders" }),
}), }),
}), }),
); );
@@ -221,6 +360,7 @@ describe("AgentNode runtime", () => {
canvasId: "canvas-1", canvasId: "canvas-1",
nodeId: "agent-1", nodeId: "agent-1",
modelId: "openai/gpt-5.4", modelId: "openai/gpt-5.4",
locale: "de",
}); });
const submitButton = Array.from(container.querySelectorAll("button")).find((element) => const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
@@ -237,7 +377,8 @@ describe("AgentNode runtime", () => {
expect(mocks.resumeAgent).toHaveBeenCalledWith({ expect(mocks.resumeAgent).toHaveBeenCalledWith({
canvasId: "canvas-1", canvasId: "canvas-1",
nodeId: "agent-1", nodeId: "agent-1",
clarificationAnswers: { audience: "SaaS founders" }, clarificationAnswers: { briefing: "SaaS founders" },
locale: "de",
}); });
}); });

View File

@@ -22,6 +22,45 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
})); }));
const translations: Record<string, string> = {
"agentNode.templates.campaignDistributor.name": "Campaign Distributor",
"agentNode.templates.campaignDistributor.description":
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
"agentNode.modelLabel": "Model",
"agentNode.modelCreditMeta": "{model} - {credits} Cr",
"agentNode.briefingLabel": "Briefing",
"agentNode.briefingPlaceholder": "Describe the core task and desired output.",
"agentNode.constraintsLabel": "Constraints",
"agentNode.audienceLabel": "Audience",
"agentNode.toneLabel": "Tone",
"agentNode.targetChannelsLabel": "Target channels",
"agentNode.targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"agentNode.hardConstraintsLabel": "Hard constraints",
"agentNode.hardConstraintsPlaceholder": "No emojis\nMax 120 words",
"agentNode.runAgentButton": "Run agent",
"agentNode.clarificationsLabel": "Clarifications",
"agentNode.submitClarificationButton": "Submit clarification",
"agentNode.templateReferenceLabel": "Template reference",
"agentNode.templateReferenceChannelsLabel": "Channels",
"agentNode.templateReferenceInputsLabel": "Inputs",
"agentNode.templateReferenceOutputsLabel": "Outputs",
};
vi.mock("next-intl", () => ({
useLocale: () => "de",
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
import AgentNode from "@/components/canvas/nodes/agent-node"; import AgentNode from "@/components/canvas/nodes/agent-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -73,8 +112,9 @@ describe("AgentNode", () => {
}); });
expect(container.textContent).toContain("Campaign Distributor"); expect(container.textContent).toContain("Campaign Distributor");
expect(container.textContent).toContain("Instagram Feed"); expect(container.textContent).toContain("Briefing");
expect(container.textContent).toContain("Caption-Pakete"); expect(container.textContent).toContain("Constraints");
expect(container.textContent).toContain("Template reference");
expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1); expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1); expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
}); });

View File

@@ -22,6 +22,31 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
})); }));
const translations: Record<string, string> = {
"agentOutputNode.defaultTitle": "Agent output",
"agentOutputNode.plannedOutputDefaultTitle": "Planned output",
"agentOutputNode.skeletonBadge": "Skeleton",
"agentOutputNode.plannedOutputLabel": "Planned output",
"agentOutputNode.channelLabel": "Channel",
"agentOutputNode.typeLabel": "Type",
"agentOutputNode.bodyLabel": "Body",
"agentOutputNode.plannedContent": "Planned content",
};
vi.mock("next-intl", () => ({
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
import AgentOutputNode from "@/components/canvas/nodes/agent-output-node"; import AgentOutputNode from "@/components/canvas/nodes/agent-output-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -79,6 +104,45 @@ describe("AgentOutputNode", () => {
expect(container.textContent).toContain("instagram-feed"); expect(container.textContent).toContain("instagram-feed");
expect(container.textContent).toContain("caption"); expect(container.textContent).toContain("caption");
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-text-body"]')).not.toBeNull();
});
it("renders parseable json body in a pretty-printed code block", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-4",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "JSON output",
channel: "api",
outputType: "payload",
body: '{"post":"Hello","tags":["launch","news"]}',
_status: "done",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const jsonBody = container.querySelector('[data-testid="agent-output-json-body"]');
expect(jsonBody).not.toBeNull();
expect(jsonBody?.textContent).toContain('"post": "Hello"');
expect(jsonBody?.textContent).toContain('"tags": [');
expect(container.querySelector('[data-testid="agent-output-text-body"]')).toBeNull();
}); });
it("renders input-only handle agent-output-in", async () => { it("renders input-only handle agent-output-in", async () => {

View File

@@ -2,10 +2,14 @@ import { describe, expect, it } from "vitest";
import { import {
areClarificationAnswersComplete, areClarificationAnswersComplete,
buildPreflightClarificationQuestions,
normalizeAgentLocale,
normalizeAgentExecutionPlan, normalizeAgentExecutionPlan,
normalizeAgentBriefConstraints,
normalizeAgentOutputDraft, normalizeAgentOutputDraft,
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
type AgentClarificationQuestion, type AgentClarificationQuestion,
type AgentBriefConstraints,
type AgentExecutionPlan, type AgentExecutionPlan,
} from "@/lib/agent-run-contract"; } from "@/lib/agent-run-contract";
@@ -178,4 +182,108 @@ 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"]);
}); });
}); });
describe("normalizeAgentBriefConstraints", () => {
it("trims fields and normalizes target channels", () => {
const normalized = normalizeAgentBriefConstraints({
briefing: " Build a launch sequence ",
audience: " SaaS founders ",
tone: " concise ",
targetChannels: [" Email ", "", "LinkedIn", "email", " "],
hardConstraints: [
" Do not mention pricing ",
"",
"Keep under 120 words",
"Keep under 120 words",
],
});
expect(normalized).toEqual<AgentBriefConstraints>({
briefing: "Build a launch sequence",
audience: "SaaS founders",
tone: "concise",
targetChannels: ["email", "linkedin"],
hardConstraints: ["Do not mention pricing", "Keep under 120 words"],
});
});
it("falls back to safe empty values for invalid payloads", () => {
const normalized = normalizeAgentBriefConstraints({
briefing: null,
audience: undefined,
tone: 3,
targetChannels: [" ", null, 1],
hardConstraints: "must be array",
});
expect(normalized).toEqual<AgentBriefConstraints>({
briefing: "",
audience: "",
tone: "",
targetChannels: [],
hardConstraints: [],
});
});
});
describe("buildPreflightClarificationQuestions", () => {
it("builds deterministic required questions for missing preflight requirements", () => {
const questions = buildPreflightClarificationQuestions({
briefConstraints: {
briefing: "",
audience: "Founders",
tone: "confident",
targetChannels: [],
hardConstraints: [],
},
incomingContextCount: 0,
});
expect(questions).toEqual<AgentClarificationQuestion[]>([
{
id: "briefing",
prompt: "What should the agent produce? Provide the brief in one or two sentences.",
required: true,
},
{
id: "target-channels",
prompt: "Which channels should this run target? List at least one channel.",
required: true,
},
{
id: "incoming-context",
prompt: "No context was provided. What source context should the agent use?",
required: true,
},
]);
});
it("returns an empty list when all preflight requirements are satisfied", () => {
const questions = buildPreflightClarificationQuestions({
briefConstraints: {
briefing: "Create 3 posts",
audience: "Marketers",
tone: "friendly",
targetChannels: ["x"],
hardConstraints: ["No emojis"],
},
incomingContextCount: 2,
});
expect(questions).toEqual([]);
});
});
describe("normalizeAgentLocale", () => {
it("returns supported locale values", () => {
expect(normalizeAgentLocale("de")).toBe("de");
expect(normalizeAgentLocale("en")).toBe("en");
});
it("falls back to de for unsupported values", () => {
expect(normalizeAgentLocale("fr")).toBe("de");
expect(normalizeAgentLocale(undefined)).toBe("de");
expect(normalizeAgentLocale(null)).toBe("de");
});
});
}); });