feat(agent): localize generated agent workflow
This commit is contained in:
@@ -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,31 +644,36 @@ 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}
|
||||||
|
|
||||||
<section className="space-y-1">
|
<details className="rounded-md border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<summary className="cursor-pointer text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Channels
|
{t("templateReferenceLabel")}
|
||||||
</p>
|
</summary>
|
||||||
<CompactList items={template.channels} />
|
<div className="mt-2 space-y-2">
|
||||||
</section>
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<section className="space-y-1">
|
{t("templateReferenceChannelsLabel")} ({template.channels.length})
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
</p>
|
||||||
Expected Inputs
|
<CompactList items={template.channels} />
|
||||||
</p>
|
</section>
|
||||||
<CompactList items={template.expectedInputs} />
|
<section className="space-y-1">
|
||||||
</section>
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("templateReferenceInputsLabel")} ({template.expectedInputs.length})
|
||||||
<section className="space-y-1">
|
</p>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<CompactList items={template.expectedInputs} />
|
||||||
Expected Outputs
|
</section>
|
||||||
</p>
|
<section className="space-y-1">
|
||||||
<CompactList items={template.expectedOutputs} />
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
</section>
|
{t("templateReferenceOutputsLabel")} ({template.expectedOutputs.length})
|
||||||
|
</p>
|
||||||
|
<CompactList items={template.expectedOutputs} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -63,52 +89,63 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
|||||||
{resolvedTitle}
|
{resolvedTitle}
|
||||||
</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}
|
||||||
|
</div>
|
||||||
|
{isSkeleton ? (
|
||||||
|
<p className="text-[11px] text-amber-700/90 dark:text-amber-300/90">
|
||||||
|
{t("plannedOutputLabel")}
|
||||||
|
{stepCounter ? ` - ${stepCounter}` : ""}
|
||||||
|
{nodeData.stepId ? ` - ${nodeData.stepId}` : ""}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
{isSkeleton ? (
|
|
||||||
<p className="text-[11px] text-amber-700/90 dark:text-amber-300/90">
|
|
||||||
Planned output{stepCounter ? ` - ${stepCounter}` : ""}
|
|
||||||
{nodeData.stepId ? ` - ${nodeData.stepId}` : ""}
|
|
||||||
</p>
|
|
||||||
) : 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">
|
||||||
{nodeData.channel ?? "-"}
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
|
||||||
</p>
|
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
|
||||||
|
{nodeData.channel ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("typeLabel")}</p>
|
||||||
|
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.outputType}>
|
||||||
|
{nodeData.outputType ?? "-"}
|
||||||
|
</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">
|
||||||
Output Type
|
{t("bodyLabel")}
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-foreground/90" title={nodeData.outputType}>
|
|
||||||
{nodeData.outputType ?? "-"}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-1">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Body
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,20 +906,25 @@ 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 ?? ""}`,
|
||||||
"Incoming node context:",
|
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
|
||||||
incomingContext,
|
"Incoming node context:",
|
||||||
`Current clarification answers: ${JSON.stringify(existingAnswers)}`,
|
incomingContext,
|
||||||
"Return structured JSON matching the schema.",
|
`Incoming context node count: ${incomingContextCount}`,
|
||||||
].join("\n\n"),
|
`Current clarification answers: ${JSON.stringify(existingAnswers)}`,
|
||||||
},
|
"Return structured JSON matching the schema.",
|
||||||
],
|
].join("\n\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const clarificationQuestions = normalizeClarificationQuestions(
|
const clarificationQuestions = normalizeClarificationQuestions(
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user