feat(agent): implement phase 2 runtime and inline clarification
This commit is contained in:
@@ -17,6 +17,7 @@ import DetailAdjustNode from "./nodes/detail-adjust-node";
|
|||||||
import RenderNode from "./nodes/render-node";
|
import RenderNode from "./nodes/render-node";
|
||||||
import CropNode from "./nodes/crop-node";
|
import CropNode from "./nodes/crop-node";
|
||||||
import AgentNode from "./nodes/agent-node";
|
import AgentNode from "./nodes/agent-node";
|
||||||
|
import AgentOutputNode from "./nodes/agent-output-node";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node-Type-Map für React Flow.
|
* Node-Type-Map für React Flow.
|
||||||
@@ -45,4 +46,5 @@ export const nodeTypes = {
|
|||||||
crop: CropNode,
|
crop: CropNode,
|
||||||
render: RenderNode,
|
render: RenderNode,
|
||||||
agent: AgentNode,
|
agent: AgentNode,
|
||||||
|
"agent-output": AgentOutputNode,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Bot } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
import type { FunctionReference } from "convex/server";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||||
|
import {
|
||||||
|
DEFAULT_AGENT_MODEL_ID,
|
||||||
|
getAgentModel,
|
||||||
|
getAvailableAgentModels,
|
||||||
|
type AgentModelId,
|
||||||
|
} from "@/lib/agent-models";
|
||||||
|
import {
|
||||||
|
type AgentClarificationAnswerMap,
|
||||||
|
type AgentClarificationQuestion,
|
||||||
|
} from "@/lib/agent-run-contract";
|
||||||
import { getAgentTemplate } from "@/lib/agent-templates";
|
import { getAgentTemplate } from "@/lib/agent-templates";
|
||||||
|
import { normalizePublicTier } from "@/lib/tier-credits";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
type AgentNodeData = {
|
type AgentNodeData = {
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
canvasId?: string;
|
canvasId?: string;
|
||||||
|
modelId?: string;
|
||||||
|
clarificationQuestions?: AgentClarificationQuestion[];
|
||||||
|
clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>;
|
||||||
_status?: string;
|
_status?: string;
|
||||||
_statusMessage?: string;
|
_statusMessage?: string;
|
||||||
};
|
};
|
||||||
@@ -17,6 +47,68 @@ type AgentNodeType = Node<AgentNodeData, "agent">;
|
|||||||
|
|
||||||
const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor";
|
const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor";
|
||||||
|
|
||||||
|
function useSafeCanvasSync() {
|
||||||
|
try {
|
||||||
|
return useCanvasSync();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
queueNodeDataUpdate: async () => undefined,
|
||||||
|
status: { isOffline: false, isSyncing: false, pendingCount: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSafeSubscription() {
|
||||||
|
try {
|
||||||
|
return useAuthQuery(api.credits.getSubscription);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSafeAction(reference: FunctionReference<"action", "public", any, unknown>) {
|
||||||
|
try {
|
||||||
|
return useAction(reference);
|
||||||
|
} catch {
|
||||||
|
return async (_args: any) => undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClarificationAnswers(raw: AgentNodeData["clarificationAnswers"]): AgentClarificationAnswerMap {
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const entries = raw
|
||||||
|
.filter((item) => typeof item?.id === "string" && typeof item?.value === "string")
|
||||||
|
.map((item) => [item.id, item.value] as const);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areAnswerMapsEqual(
|
||||||
|
left: AgentClarificationAnswerMap,
|
||||||
|
right: AgentClarificationAnswerMap,
|
||||||
|
): boolean {
|
||||||
|
const leftEntries = Object.entries(left);
|
||||||
|
const rightEntries = Object.entries(right);
|
||||||
|
|
||||||
|
if (leftEntries.length !== rightEntries.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of leftEntries) {
|
||||||
|
if (right[key] !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function CompactList({ items }: { items: readonly string[] }) {
|
function CompactList({ items }: { items: readonly string[] }) {
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -29,11 +121,164 @@ function CompactList({ items }: { items: readonly string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentNode({ data, selected }: NodeProps<AgentNodeType>) {
|
export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeType>) {
|
||||||
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) ??
|
||||||
getAgentTemplate(DEFAULT_AGENT_TEMPLATE_ID);
|
getAgentTemplate(DEFAULT_AGENT_TEMPLATE_ID);
|
||||||
|
const { queueNodeDataUpdate, status } = useSafeCanvasSync();
|
||||||
|
const subscription = useSafeSubscription();
|
||||||
|
const userTier = normalizePublicTier(subscription?.tier ?? "free");
|
||||||
|
|
||||||
|
const availableModels = useMemo(() => getAvailableAgentModels(userTier), [userTier]);
|
||||||
|
const [modelId, setModelId] = useState(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID);
|
||||||
|
const [clarificationAnswers, setClarificationAnswers] = useState<AgentClarificationAnswerMap>(
|
||||||
|
normalizeClarificationAnswers(nodeData.clarificationAnswers),
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentActionsApi = api as unknown as {
|
||||||
|
agents: {
|
||||||
|
runAgent: FunctionReference<
|
||||||
|
"action",
|
||||||
|
"public",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
modelId: string;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
resumeAgent: FunctionReference<
|
||||||
|
"action",
|
||||||
|
"public",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
clarificationAnswers: AgentClarificationAnswerMap;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAgent = useSafeAction(agentActionsApi.agents.runAgent);
|
||||||
|
const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setModelId(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID);
|
||||||
|
}, [nodeData.modelId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalized = normalizeClarificationAnswers(nodeData.clarificationAnswers);
|
||||||
|
setClarificationAnswers((current) => {
|
||||||
|
if (areAnswerMapsEqual(current, normalized)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}, [nodeData.clarificationAnswers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (availableModels.some((model) => model.id === modelId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextModelId = availableModels[0]!.id;
|
||||||
|
setModelId(nextModelId);
|
||||||
|
}, [availableModels, modelId]);
|
||||||
|
|
||||||
|
const selectedModel =
|
||||||
|
getAgentModel(modelId) ??
|
||||||
|
availableModels[0] ??
|
||||||
|
getAgentModel(DEFAULT_AGENT_MODEL_ID);
|
||||||
|
const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID;
|
||||||
|
const creditCost = selectedModel?.creditCost ?? 0;
|
||||||
|
const clarificationQuestions = nodeData.clarificationQuestions ?? [];
|
||||||
|
|
||||||
|
const persistNodeData = useCallback(
|
||||||
|
(patch: Partial<AgentNodeData>) => {
|
||||||
|
const raw = data as Record<string, unknown>;
|
||||||
|
const { _status, _statusMessage, ...rest } = raw;
|
||||||
|
void _status;
|
||||||
|
void _statusMessage;
|
||||||
|
|
||||||
|
return queueNodeDataUpdate({
|
||||||
|
nodeId: id as Id<"nodes">,
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[data, id, queueNodeDataUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModelChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setModelId(value);
|
||||||
|
void persistNodeData({ modelId: value });
|
||||||
|
},
|
||||||
|
[persistNodeData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClarificationAnswerChange = useCallback(
|
||||||
|
(questionId: string, value: string) => {
|
||||||
|
setClarificationAnswers((prev) => {
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
[questionId]: value,
|
||||||
|
};
|
||||||
|
void persistNodeData({ clarificationAnswers: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[persistNodeData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRunAgent = useCallback(async () => {
|
||||||
|
if (status.isOffline) {
|
||||||
|
toast.warning(
|
||||||
|
"Offline aktuell nicht unterstuetzt",
|
||||||
|
"Agent-Lauf benoetigt eine aktive Verbindung.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasId = nodeData.canvasId as Id<"canvases"> | undefined;
|
||||||
|
if (!canvasId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAgent({
|
||||||
|
canvasId,
|
||||||
|
nodeId: id as Id<"nodes">,
|
||||||
|
modelId: resolvedModelId,
|
||||||
|
});
|
||||||
|
}, [nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]);
|
||||||
|
|
||||||
|
const handleSubmitClarification = useCallback(async () => {
|
||||||
|
if (status.isOffline) {
|
||||||
|
toast.warning(
|
||||||
|
"Offline aktuell nicht unterstuetzt",
|
||||||
|
"Agent-Lauf benoetigt eine aktive Verbindung.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasId = nodeData.canvasId as Id<"canvases"> | undefined;
|
||||||
|
if (!canvasId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resumeAgent({
|
||||||
|
canvasId,
|
||||||
|
nodeId: id as Id<"nodes">,
|
||||||
|
clarificationAnswers,
|
||||||
|
});
|
||||||
|
}, [clarificationAnswers, nodeData.canvasId, id, resumeAgent, status.isOffline]);
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return null;
|
return null;
|
||||||
@@ -64,6 +309,73 @@ export default function AgentNode({ data, selected }: NodeProps<AgentNodeType>)
|
|||||||
<p className="line-clamp-2 text-xs text-muted-foreground">{template.description}</p>
|
<p className="line-clamp-2 text-xs text-muted-foreground">{template.description}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="space-y-1.5">
|
||||||
|
<Label htmlFor={`agent-model-${id}`} className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Model
|
||||||
|
</Label>
|
||||||
|
<Select value={resolvedModelId} onValueChange={handleModelChange}>
|
||||||
|
<SelectTrigger id={`agent-model-${id}`} className="nodrag nowheel w-full" size="sm">
|
||||||
|
<SelectValue placeholder="Model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="nodrag">
|
||||||
|
{(availableModels.length > 0 ? availableModels : [selectedModel]).filter(Boolean).map((model) => (
|
||||||
|
<SelectItem key={model!.id} value={model!.id}>
|
||||||
|
{model!.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{selectedModel?.label ?? resolvedModelId} - {creditCost} Cr
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRunAgent()}
|
||||||
|
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Run agent
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{clarificationQuestions.length > 0 ? (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Clarifications
|
||||||
|
</p>
|
||||||
|
{clarificationQuestions.map((question) => (
|
||||||
|
<div key={question.id} className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor={`agent-${id}-clarification-${question.id}`}
|
||||||
|
className="text-[11px] text-foreground/90"
|
||||||
|
>
|
||||||
|
{question.prompt}
|
||||||
|
{question.required ? " *" : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`agent-${id}-clarification-${question.id}`}
|
||||||
|
name={`clarification-${question.id}`}
|
||||||
|
type="text"
|
||||||
|
value={clarificationAnswers[question.id] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleClarificationAnswerChange(question.id, event.target.value)
|
||||||
|
}
|
||||||
|
className="nodrag nowheel w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Submit clarification
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<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
|
Channels
|
||||||
|
|||||||
72
components/canvas/nodes/agent-output-node.tsx
Normal file
72
components/canvas/nodes/agent-output-node.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
|
||||||
|
type AgentOutputNodeData = {
|
||||||
|
title?: string;
|
||||||
|
channel?: string;
|
||||||
|
outputType?: string;
|
||||||
|
body?: string;
|
||||||
|
_status?: string;
|
||||||
|
_statusMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentOutputNodeType = Node<AgentOutputNodeData, "agent-output">;
|
||||||
|
|
||||||
|
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
|
||||||
|
const nodeData = data as AgentOutputNodeData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="agent-output"
|
||||||
|
selected={selected}
|
||||||
|
status={nodeData._status}
|
||||||
|
statusMessage={nodeData._statusMessage}
|
||||||
|
className="min-w-[300px] border-amber-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="agent-output-in"
|
||||||
|
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-foreground" title={nodeData.title}>
|
||||||
|
{nodeData.title ?? "Agent output"}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Channel
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-foreground/90" title={nodeData.channel}>
|
||||||
|
{nodeData.channel ?? "-"}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Output Type
|
||||||
|
</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 className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90">
|
||||||
|
{nodeData.body ?? ""}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -8,6 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type * as agents from "../agents.js";
|
||||||
import type * as ai from "../ai.js";
|
import type * as ai from "../ai.js";
|
||||||
import type * as ai_errors from "../ai_errors.js";
|
import type * as ai_errors from "../ai_errors.js";
|
||||||
import type * as ai_node_data from "../ai_node_data.js";
|
import type * as ai_node_data from "../ai_node_data.js";
|
||||||
@@ -41,6 +42,7 @@ import type {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
agents: typeof agents;
|
||||||
ai: typeof ai;
|
ai: typeof ai;
|
||||||
ai_errors: typeof ai_errors;
|
ai_errors: typeof ai_errors;
|
||||||
ai_node_data: typeof ai_node_data;
|
ai_node_data: typeof ai_node_data;
|
||||||
|
|||||||
951
convex/agents.ts
Normal file
951
convex/agents.ts
Normal file
@@ -0,0 +1,951 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import type { FunctionReference } from "convex/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
action,
|
||||||
|
type ActionCtx,
|
||||||
|
internalAction,
|
||||||
|
internalMutation,
|
||||||
|
} from "./_generated/server";
|
||||||
|
import { api, internal } from "./_generated/api";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
import { generateStructuredObjectViaOpenRouter } from "./openrouter";
|
||||||
|
import { getNodeDataRecord } from "./ai_node_data";
|
||||||
|
import { formatTerminalStatusMessage } from "./ai_errors";
|
||||||
|
import {
|
||||||
|
areClarificationAnswersComplete,
|
||||||
|
normalizeAgentOutputDraft,
|
||||||
|
type AgentClarificationAnswerMap,
|
||||||
|
type AgentClarificationQuestion,
|
||||||
|
type AgentOutputDraft,
|
||||||
|
} from "../lib/agent-run-contract";
|
||||||
|
import {
|
||||||
|
DEFAULT_AGENT_MODEL_ID,
|
||||||
|
getAgentModel,
|
||||||
|
isAgentModelAvailableForTier,
|
||||||
|
type AgentModel,
|
||||||
|
} from "../lib/agent-models";
|
||||||
|
import { getAgentTemplate } from "../lib/agent-templates";
|
||||||
|
import { normalizePublicTier } from "../lib/tier-credits";
|
||||||
|
|
||||||
|
const ANALYZE_SCHEMA: Record<string, unknown> = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["analysisSummary", "clarificationQuestions"],
|
||||||
|
properties: {
|
||||||
|
analysisSummary: { type: "string" },
|
||||||
|
clarificationQuestions: {
|
||||||
|
type: "array",
|
||||||
|
maxItems: 6,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "prompt", "required"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
required: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXECUTE_SCHEMA: Record<string, unknown> = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["summary", "outputs"],
|
||||||
|
properties: {
|
||||||
|
summary: { type: "string" },
|
||||||
|
outputs: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 6,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["title", "channel", "outputType", "body"],
|
||||||
|
properties: {
|
||||||
|
title: { type: "string" },
|
||||||
|
channel: { type: "string" },
|
||||||
|
outputType: { type: "string" },
|
||||||
|
body: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalApiShape = {
|
||||||
|
canvasGraph: {
|
||||||
|
getInternal: FunctionReference<
|
||||||
|
"query",
|
||||||
|
"internal",
|
||||||
|
{ canvasId: Id<"canvases">; userId: string },
|
||||||
|
{
|
||||||
|
canvas: Doc<"canvases">;
|
||||||
|
nodes: Doc<"nodes">[];
|
||||||
|
edges: Doc<"edges">[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
agents: {
|
||||||
|
analyzeAgent: FunctionReference<
|
||||||
|
"action",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
modelId: string;
|
||||||
|
userId: string;
|
||||||
|
reservationId?: Id<"creditTransactions">;
|
||||||
|
shouldDecrementConcurrency: boolean;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
executeAgent: FunctionReference<
|
||||||
|
"action",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
modelId: string;
|
||||||
|
userId: string;
|
||||||
|
analysisSummary: string;
|
||||||
|
reservationId?: Id<"creditTransactions">;
|
||||||
|
shouldDecrementConcurrency: boolean;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
setAgentAnalyzing: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
modelId: string;
|
||||||
|
reservationId?: Id<"creditTransactions">;
|
||||||
|
shouldDecrementConcurrency: boolean;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
upsertClarificationAnswers: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
clarificationAnswers: AgentClarificationAnswerMap;
|
||||||
|
},
|
||||||
|
{ answers: AgentClarificationAnswerMap; questions: AgentClarificationQuestion[] }
|
||||||
|
>;
|
||||||
|
setAgentError: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
statusMessage: string;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
setAgentClarifying: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
clarificationQuestions: AgentClarificationQuestion[];
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
setAgentExecuting: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{ nodeId: Id<"nodes">; statusMessage?: string },
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
finalizeAgentSuccessWithOutputs: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
outputs: AgentOutputDraft[];
|
||||||
|
summary: string;
|
||||||
|
},
|
||||||
|
{ outputNodeIds: Id<"nodes">[] }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
credits: {
|
||||||
|
commitInternal: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{ transactionId: Id<"creditTransactions">; actualCost: number; openRouterCost?: number },
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
releaseInternal: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{ transactionId: Id<"creditTransactions"> },
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
checkAbuseLimits: FunctionReference<"mutation", "internal", {}, unknown>;
|
||||||
|
incrementUsage: FunctionReference<"mutation", "internal", {}, unknown>;
|
||||||
|
decrementConcurrency: FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"internal",
|
||||||
|
{ userId?: string },
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalApi = internal as unknown as InternalApiShape;
|
||||||
|
|
||||||
|
function trimText(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnswerMap(raw: unknown): AgentClarificationAnswerMap {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: AgentClarificationAnswerMap = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
const id = trimText(key);
|
||||||
|
if (!id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized[id] = trimText(value);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClarificationQuestions(raw: unknown): AgentClarificationQuestion[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const questions: AgentClarificationQuestion[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < raw.length; index += 1) {
|
||||||
|
const item = raw[index];
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemRecord = item as Record<string, unknown>;
|
||||||
|
const prompt = trimText(itemRecord.prompt);
|
||||||
|
if (!prompt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId = trimText(itemRecord.id).replace(/\s+/g, "-").toLowerCase();
|
||||||
|
const fallbackId = `q-${index + 1}`;
|
||||||
|
const id = rawId || fallbackId;
|
||||||
|
|
||||||
|
if (seenIds.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
questions.push({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
required: itemRecord.required !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodeDataForPrompt(data: unknown): string {
|
||||||
|
if (data === undefined) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data).slice(0, 1200);
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIncomingContext(
|
||||||
|
graph: { nodes: Doc<"nodes">[]; edges: Doc<"edges">[] },
|
||||||
|
agentNodeId: Id<"nodes">,
|
||||||
|
): string {
|
||||||
|
const nodeById = new Map(graph.nodes.map((node) => [node._id, node] as const));
|
||||||
|
const incomingEdges = graph.edges.filter((edge) => edge.targetNodeId === agentNodeId);
|
||||||
|
|
||||||
|
if (incomingEdges.length === 0) {
|
||||||
|
return "No incoming nodes connected to this agent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const edge of incomingEdges) {
|
||||||
|
const source = nodeById.get(edge.sourceNodeId);
|
||||||
|
if (!source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`- nodeId=${source._id}, type=${source.type}, status=${source.status}, data=${serializeNodeDataForPrompt(source.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines.join("\n") : "No incoming nodes connected to this agent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentNodeFromGraph(
|
||||||
|
graph: { nodes: Doc<"nodes">[] },
|
||||||
|
nodeId: Id<"nodes">,
|
||||||
|
): Doc<"nodes"> {
|
||||||
|
const agentNode = graph.nodes.find((node) => node._id === nodeId);
|
||||||
|
if (!agentNode) {
|
||||||
|
throw new Error("Agent node not found");
|
||||||
|
}
|
||||||
|
if (agentNode.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
return agentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseInternalReservationBestEffort(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
reservationId: Id<"creditTransactions"> | undefined,
|
||||||
|
) {
|
||||||
|
if (!reservationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ctx.runMutation(internalApi.credits.releaseInternal, {
|
||||||
|
transactionId: reservationId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep terminal node updates resilient even when cleanup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releasePublicReservationBestEffort(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
reservationId: Id<"creditTransactions"> | null,
|
||||||
|
) {
|
||||||
|
if (!reservationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ctx.runMutation(api.credits.release, {
|
||||||
|
transactionId: reservationId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Prefer surfacing orchestration errors over cleanup issues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decrementConcurrencyIfNeeded(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
shouldDecrementConcurrency: boolean,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
if (!shouldDecrementConcurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ctx.runMutation(internalApi.credits.decrementConcurrency, {
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedModelOrThrow(modelId: string): AgentModel {
|
||||||
|
const selectedModel = getAgentModel(modelId);
|
||||||
|
if (!selectedModel) {
|
||||||
|
throw new Error(`Unknown agent model: ${modelId}`);
|
||||||
|
}
|
||||||
|
return selectedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAgentModelTier(model: AgentModel, tier: string | undefined): void {
|
||||||
|
const normalizedTier = normalizePublicTier(tier);
|
||||||
|
if (!isAgentModelAvailableForTier(normalizedTier, model.id)) {
|
||||||
|
throw new Error(`Model ${model.id} requires ${model.minTier} tier`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAgentAnalyzing = internalMutation({
|
||||||
|
args: {
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
modelId: v.string(),
|
||||||
|
reservationId: v.optional(v.id("creditTransactions")),
|
||||||
|
shouldDecrementConcurrency: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
if (node.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
status: "analyzing",
|
||||||
|
statusMessage: "Step 1/2 - analyzing inputs",
|
||||||
|
retryCount: 0,
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
modelId: args.modelId,
|
||||||
|
reservationId: args.reservationId,
|
||||||
|
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setAgentClarifying = internalMutation({
|
||||||
|
args: {
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
clarificationQuestions: v.array(
|
||||||
|
v.object({
|
||||||
|
id: v.string(),
|
||||||
|
prompt: v.string(),
|
||||||
|
required: v.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
const answers = normalizeAnswerMap(prev.clarificationAnswers);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
status: "clarifying",
|
||||||
|
statusMessage: "Clarification required before execution",
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
clarificationQuestions: args.clarificationQuestions,
|
||||||
|
clarificationAnswers: answers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setAgentExecuting = internalMutation({
|
||||||
|
args: {
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
statusMessage: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
status: "executing",
|
||||||
|
statusMessage: args.statusMessage ?? "Step 2/2 - generating outputs",
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
clarificationQuestions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setAgentError = internalMutation({
|
||||||
|
args: {
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
statusMessage: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
status: "error",
|
||||||
|
statusMessage: args.statusMessage,
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
reservationId: undefined,
|
||||||
|
shouldDecrementConcurrency: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upsertClarificationAnswers = internalMutation({
|
||||||
|
args: {
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
clarificationAnswers: v.record(v.string(), v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
if (node.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
const currentAnswers = normalizeAnswerMap(prev.clarificationAnswers);
|
||||||
|
const nextAnswers: AgentClarificationAnswerMap = {
|
||||||
|
...currentAnswers,
|
||||||
|
...normalizeAnswerMap(args.clarificationAnswers),
|
||||||
|
};
|
||||||
|
const questions = normalizeClarificationQuestions(prev.clarificationQuestions);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
clarificationAnswers: nextAnswers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
answers: nextAnswers,
|
||||||
|
questions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const finalizeAgentSuccessWithOutputs = internalMutation({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
outputs: v.array(
|
||||||
|
v.object({
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
channel: v.optional(v.string()),
|
||||||
|
outputType: v.optional(v.string()),
|
||||||
|
body: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
summary: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const node = await ctx.db.get(args.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
if (node.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
if (node.canvasId !== args.canvasId) {
|
||||||
|
throw new Error("Agent node does not belong to canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = getNodeDataRecord(node.data);
|
||||||
|
const existingOutputNodeIds = Array.isArray(prev.outputNodeIds)
|
||||||
|
? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const baseX = node.positionX + node.width + 120;
|
||||||
|
const baseY = node.positionY;
|
||||||
|
const outputNodeIds: Id<"nodes">[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < args.outputs.length; index += 1) {
|
||||||
|
const normalized = normalizeAgentOutputDraft(args.outputs[index] ?? {});
|
||||||
|
const outputNodeId = await ctx.db.insert("nodes", {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
type: "agent-output",
|
||||||
|
positionX: baseX,
|
||||||
|
positionY: baseY + index * 220,
|
||||||
|
width: 360,
|
||||||
|
height: 260,
|
||||||
|
status: "done",
|
||||||
|
retryCount: 0,
|
||||||
|
data: {
|
||||||
|
title: normalized.title,
|
||||||
|
channel: normalized.channel,
|
||||||
|
outputType: normalized.outputType,
|
||||||
|
body: normalized.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outputNodeIds.push(outputNodeId);
|
||||||
|
|
||||||
|
await ctx.db.insert("edges", {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
sourceNodeId: args.nodeId,
|
||||||
|
targetNodeId: outputNodeId,
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "agent-output-in",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.nodeId, {
|
||||||
|
status: "done",
|
||||||
|
statusMessage: undefined,
|
||||||
|
retryCount: 0,
|
||||||
|
data: {
|
||||||
|
...prev,
|
||||||
|
clarificationQuestions: [],
|
||||||
|
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
|
||||||
|
lastRunSummary: trimText(args.summary),
|
||||||
|
reservationId: undefined,
|
||||||
|
shouldDecrementConcurrency: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.canvasId, {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputNodeIds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const analyzeAgent = internalAction({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
modelId: v.string(),
|
||||||
|
userId: v.string(),
|
||||||
|
reservationId: v.optional(v.id("creditTransactions")),
|
||||||
|
shouldDecrementConcurrency: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("OPENROUTER_API_KEY is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const graph = await ctx.runQuery(internalApi.canvasGraph.getInternal, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
userId: args.userId,
|
||||||
|
});
|
||||||
|
const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
|
||||||
|
const agentData = getNodeDataRecord(agentNode.data);
|
||||||
|
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
|
||||||
|
const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
|
||||||
|
const incomingContext = collectIncomingContext(graph, args.nodeId);
|
||||||
|
|
||||||
|
const analysis = await generateStructuredObjectViaOpenRouter<{
|
||||||
|
analysisSummary: string;
|
||||||
|
clarificationQuestions: AgentClarificationQuestion[];
|
||||||
|
}>(apiKey, {
|
||||||
|
model: args.modelId,
|
||||||
|
schemaName: "agent_analyze_result",
|
||||||
|
schema: ANALYZE_SCHEMA,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"You are the LemonSpace Agent Analyzer. Inspect incoming canvas context and decide if clarification is required before execution. Ask only necessary short questions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
`Template: ${template?.name ?? "Unknown template"}`,
|
||||||
|
`Template description: ${template?.description ?? ""}`,
|
||||||
|
"Incoming node context:",
|
||||||
|
incomingContext,
|
||||||
|
`Current clarification answers: ${JSON.stringify(existingAnswers)}`,
|
||||||
|
"Return structured JSON matching the schema.",
|
||||||
|
].join("\n\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const clarificationQuestions = normalizeClarificationQuestions(
|
||||||
|
analysis.clarificationQuestions,
|
||||||
|
);
|
||||||
|
const hasRequiredGaps = !areClarificationAnswersComplete(
|
||||||
|
clarificationQuestions,
|
||||||
|
existingAnswers,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clarificationQuestions.length > 0 && hasRequiredGaps) {
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentClarifying, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
clarificationQuestions,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentExecuting, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internalApi.agents.executeAgent, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
modelId: args.modelId,
|
||||||
|
userId: args.userId,
|
||||||
|
analysisSummary: trimText(analysis.analysisSummary),
|
||||||
|
reservationId: args.reservationId,
|
||||||
|
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
statusMessage: formatTerminalStatusMessage(error),
|
||||||
|
});
|
||||||
|
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const executeAgent = internalAction({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
modelId: v.string(),
|
||||||
|
userId: v.string(),
|
||||||
|
analysisSummary: v.string(),
|
||||||
|
reservationId: v.optional(v.id("creditTransactions")),
|
||||||
|
shouldDecrementConcurrency: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("OPENROUTER_API_KEY is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModel = getSelectedModelOrThrow(args.modelId);
|
||||||
|
const graph = await ctx.runQuery(internalApi.canvasGraph.getInternal, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
userId: args.userId,
|
||||||
|
});
|
||||||
|
const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
|
||||||
|
const agentData = getNodeDataRecord(agentNode.data);
|
||||||
|
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
|
||||||
|
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
|
||||||
|
const incomingContext = collectIncomingContext(graph, args.nodeId);
|
||||||
|
|
||||||
|
const execution = await generateStructuredObjectViaOpenRouter<{
|
||||||
|
summary: string;
|
||||||
|
outputs: AgentOutputDraft[];
|
||||||
|
}>(apiKey, {
|
||||||
|
model: args.modelId,
|
||||||
|
schemaName: "agent_execute_result",
|
||||||
|
schema: EXECUTE_SCHEMA,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Output concise, actionable drafts.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
`Template: ${template?.name ?? "Unknown template"}`,
|
||||||
|
`Template description: ${template?.description ?? ""}`,
|
||||||
|
`Analyze summary: ${trimText(args.analysisSummary)}`,
|
||||||
|
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
|
||||||
|
"Incoming node context:",
|
||||||
|
incomingContext,
|
||||||
|
"Return structured JSON matching the schema.",
|
||||||
|
].join("\n\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputs = Array.isArray(execution.outputs) ? execution.outputs : [];
|
||||||
|
if (outputs.length === 0) {
|
||||||
|
throw new Error("Agent execution returned no outputs");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.runMutation(internalApi.agents.finalizeAgentSuccessWithOutputs, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
outputs,
|
||||||
|
summary: execution.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.reservationId) {
|
||||||
|
await ctx.runMutation(internalApi.credits.commitInternal, {
|
||||||
|
transactionId: args.reservationId,
|
||||||
|
actualCost: selectedModel.creditCost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
|
||||||
|
} catch (error) {
|
||||||
|
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
statusMessage: formatTerminalStatusMessage(error),
|
||||||
|
});
|
||||||
|
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const runAgent = action({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
modelId: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => {
|
||||||
|
const canvas = await ctx.runQuery(api.canvases.get, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
});
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error("Canvas not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await ctx.runQuery(api.nodes.get, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
includeStorageUrl: false,
|
||||||
|
});
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
if (node.canvasId !== args.canvasId) {
|
||||||
|
throw new Error("Node does not belong to canvas");
|
||||||
|
}
|
||||||
|
if (node.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModel = getSelectedModelOrThrow(args.modelId);
|
||||||
|
const subscription = await ctx.runQuery(api.credits.getSubscription, {});
|
||||||
|
assertAgentModelTier(selectedModel, subscription?.tier);
|
||||||
|
|
||||||
|
await ctx.runMutation(internalApi.credits.checkAbuseLimits, {});
|
||||||
|
|
||||||
|
const internalCreditsEnabled = process.env.INTERNAL_CREDITS_ENABLED === "true";
|
||||||
|
let usageIncremented = false;
|
||||||
|
const reservationId: Id<"creditTransactions"> | null = internalCreditsEnabled
|
||||||
|
? await ctx.runMutation(api.credits.reserve, {
|
||||||
|
estimatedCost: selectedModel.creditCost,
|
||||||
|
description: `Agent-Lauf - ${selectedModel.label}`,
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
model: selectedModel.id,
|
||||||
|
provider: "openrouter",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!internalCreditsEnabled) {
|
||||||
|
await ctx.runMutation(internalApi.credits.incrementUsage, {});
|
||||||
|
usageIncremented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheduled = false;
|
||||||
|
try {
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentAnalyzing, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
modelId: selectedModel.id,
|
||||||
|
reservationId: reservationId ?? undefined,
|
||||||
|
shouldDecrementConcurrency: usageIncremented,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internalApi.agents.analyzeAgent, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
modelId: selectedModel.id,
|
||||||
|
userId: canvas.ownerId,
|
||||||
|
reservationId: reservationId ?? undefined,
|
||||||
|
shouldDecrementConcurrency: usageIncremented,
|
||||||
|
});
|
||||||
|
scheduled = true;
|
||||||
|
return { queued: true, nodeId: args.nodeId };
|
||||||
|
} catch (error) {
|
||||||
|
await releasePublicReservationBestEffort(ctx, reservationId);
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
statusMessage: formatTerminalStatusMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (usageIncremented && !scheduled) {
|
||||||
|
await ctx.runMutation(internalApi.credits.decrementConcurrency, {
|
||||||
|
userId: canvas.ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resumeAgent = action({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
nodeId: v.id("nodes"),
|
||||||
|
clarificationAnswers: v.record(v.string(), v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<{ queued: true; nodeId: Id<"nodes"> }> => {
|
||||||
|
const canvas = await ctx.runQuery(api.canvases.get, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
});
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error("Canvas not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await ctx.runQuery(api.nodes.get, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
includeStorageUrl: false,
|
||||||
|
});
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Node not found");
|
||||||
|
}
|
||||||
|
if (node.canvasId !== args.canvasId) {
|
||||||
|
throw new Error("Node does not belong to canvas");
|
||||||
|
}
|
||||||
|
if (node.type !== "agent") {
|
||||||
|
throw new Error("Node must be an agent node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserted = await ctx.runMutation(internalApi.agents.upsertClarificationAnswers, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
clarificationAnswers: args.clarificationAnswers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!areClarificationAnswersComplete(upserted.questions, upserted.answers)) {
|
||||||
|
throw new Error("Please answer all required clarification questions before resuming");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = getNodeDataRecord(node.data);
|
||||||
|
const modelId = trimText(nodeData.modelId) || DEFAULT_AGENT_MODEL_ID;
|
||||||
|
const selectedModel = getSelectedModelOrThrow(modelId);
|
||||||
|
const reservationId =
|
||||||
|
typeof nodeData.reservationId === "string"
|
||||||
|
? (nodeData.reservationId as Id<"creditTransactions">)
|
||||||
|
: undefined;
|
||||||
|
const shouldDecrementConcurrency = nodeData.shouldDecrementConcurrency === true;
|
||||||
|
|
||||||
|
const subscription = await ctx.runQuery(api.credits.getSubscription, {});
|
||||||
|
assertAgentModelTier(selectedModel, subscription?.tier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentAnalyzing, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
modelId,
|
||||||
|
reservationId,
|
||||||
|
shouldDecrementConcurrency,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internalApi.agents.analyzeAgent, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
modelId,
|
||||||
|
userId: canvas.ownerId,
|
||||||
|
reservationId,
|
||||||
|
shouldDecrementConcurrency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { queued: true, nodeId: args.nodeId };
|
||||||
|
} catch (error) {
|
||||||
|
await releasePublicReservationBestEffort(ctx, reservationId ?? null);
|
||||||
|
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
statusMessage: formatTerminalStatusMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { query, type QueryCtx } from "./_generated/server";
|
import { internalQuery, query, type QueryCtx } from "./_generated/server";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
|
|
||||||
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
||||||
@@ -64,3 +64,16 @@ export const get = query({
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getInternal = internalQuery({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
userId: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { canvasId, userId }) => {
|
||||||
|
return loadCanvasGraph(ctx, {
|
||||||
|
canvasId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,67 @@ import { ConvexError } from "convex/values";
|
|||||||
|
|
||||||
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||||
|
|
||||||
|
export async function generateStructuredObjectViaOpenRouter<T>(
|
||||||
|
apiKey: string,
|
||||||
|
args: {
|
||||||
|
model: string;
|
||||||
|
messages: Array<{
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
schemaName: string;
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://app.lemonspace.io",
|
||||||
|
"X-Title": "LemonSpace",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: args.model,
|
||||||
|
messages: args.messages,
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: args.schemaName,
|
||||||
|
strict: true,
|
||||||
|
schema: args.schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new ConvexError({
|
||||||
|
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||||
|
status: response.status,
|
||||||
|
message: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data?.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (typeof content !== "string" || content.trim() === "") {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenRouterModel {
|
export interface OpenRouterModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
77
lib/agent-models.ts
Normal file
77
lib/agent-models.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export type AgentModelId =
|
||||||
|
| "openai/gpt-5.4-nano"
|
||||||
|
| "openai/gpt-5.4-mini"
|
||||||
|
| "openai/gpt-5.4"
|
||||||
|
| "openai/gpt-5.4-pro";
|
||||||
|
|
||||||
|
export type AgentModelMinTier = "starter" | "max";
|
||||||
|
export type AgentModelAccessTier = "free" | "starter" | "pro" | "max" | "business";
|
||||||
|
|
||||||
|
export interface AgentModel {
|
||||||
|
id: AgentModelId;
|
||||||
|
label: string;
|
||||||
|
minTier: AgentModelMinTier;
|
||||||
|
creditCost: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_MODELS = {
|
||||||
|
"openai/gpt-5.4-nano": {
|
||||||
|
id: "openai/gpt-5.4-nano",
|
||||||
|
label: "GPT-5.4 Nano",
|
||||||
|
minTier: "starter",
|
||||||
|
creditCost: 6,
|
||||||
|
description: "Fastest option for lightweight agent runs",
|
||||||
|
},
|
||||||
|
"openai/gpt-5.4-mini": {
|
||||||
|
id: "openai/gpt-5.4-mini",
|
||||||
|
label: "GPT-5.4 Mini",
|
||||||
|
minTier: "starter",
|
||||||
|
creditCost: 15,
|
||||||
|
description: "Balanced quality and latency for default use",
|
||||||
|
},
|
||||||
|
"openai/gpt-5.4": {
|
||||||
|
id: "openai/gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
minTier: "starter",
|
||||||
|
creditCost: 38,
|
||||||
|
description: "Higher reasoning quality for complex tasks",
|
||||||
|
},
|
||||||
|
"openai/gpt-5.4-pro": {
|
||||||
|
id: "openai/gpt-5.4-pro",
|
||||||
|
label: "GPT-5.4 Pro",
|
||||||
|
minTier: "max",
|
||||||
|
creditCost: 180,
|
||||||
|
description: "Top-tier capability for hardest workflows",
|
||||||
|
},
|
||||||
|
} as const satisfies Record<AgentModelId, AgentModel>;
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_MODEL_ID: AgentModelId = "openai/gpt-5.4-mini";
|
||||||
|
|
||||||
|
const AGENT_MODEL_IDS = Object.keys(AGENT_MODELS) as AgentModelId[];
|
||||||
|
|
||||||
|
const AGENT_MODEL_TIER_ORDER: Record<AgentModelAccessTier, number> = {
|
||||||
|
free: 0,
|
||||||
|
starter: 1,
|
||||||
|
pro: 2,
|
||||||
|
max: 3,
|
||||||
|
business: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAgentModel(id: string): AgentModel | undefined {
|
||||||
|
return AGENT_MODELS[id as AgentModelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentModelAvailableForTier(
|
||||||
|
tier: AgentModelAccessTier,
|
||||||
|
modelId: AgentModelId,
|
||||||
|
): boolean {
|
||||||
|
const model = AGENT_MODELS[modelId];
|
||||||
|
return AGENT_MODEL_TIER_ORDER[model.minTier] <= AGENT_MODEL_TIER_ORDER[tier];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableAgentModels(tier: AgentModelAccessTier): AgentModel[] {
|
||||||
|
return AGENT_MODEL_IDS.map((id) => AGENT_MODELS[id]).filter((model) =>
|
||||||
|
isAgentModelAvailableForTier(tier, model.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
71
lib/agent-run-contract.ts
Normal file
71
lib/agent-run-contract.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export type AgentClarificationQuestion = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentClarificationAnswerMap = Partial<Record<string, string>>;
|
||||||
|
|
||||||
|
export type AgentOutputDraft = {
|
||||||
|
title?: string;
|
||||||
|
channel?: string;
|
||||||
|
outputType?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentExecutionPlan = {
|
||||||
|
steps: string[];
|
||||||
|
outputs: AgentOutputDraft[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentAnalyzeResult = {
|
||||||
|
clarificationQuestions: AgentClarificationQuestion[];
|
||||||
|
executionPlan: AgentExecutionPlan | null;
|
||||||
|
outputDrafts: AgentOutputDraft[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_FALLBACK_TITLE = "Untitled";
|
||||||
|
const SAFE_FALLBACK_CHANNEL = "general";
|
||||||
|
const SAFE_FALLBACK_OUTPUT_TYPE = "text";
|
||||||
|
|
||||||
|
function trimString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areClarificationAnswersComplete(
|
||||||
|
questions: AgentClarificationQuestion[],
|
||||||
|
answers: AgentClarificationAnswerMap,
|
||||||
|
): boolean {
|
||||||
|
for (const question of questions) {
|
||||||
|
if (!question.required) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimString(answers[question.id]) === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAgentOutputDraft(
|
||||||
|
draft: AgentOutputDraft,
|
||||||
|
): AgentOutputDraft & {
|
||||||
|
title: string;
|
||||||
|
channel: string;
|
||||||
|
outputType: string;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
const title = trimString(draft.title) || SAFE_FALLBACK_TITLE;
|
||||||
|
const channel = trimString(draft.channel) || SAFE_FALLBACK_CHANNEL;
|
||||||
|
const outputType = trimString(draft.outputType) || SAFE_FALLBACK_OUTPUT_TYPE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
title,
|
||||||
|
channel,
|
||||||
|
outputType,
|
||||||
|
body: trimString(draft.body),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ export type CanvasConnectionValidationReason =
|
|||||||
| "compare-incoming-limit"
|
| "compare-incoming-limit"
|
||||||
| "adjustment-target-forbidden"
|
| "adjustment-target-forbidden"
|
||||||
| "render-source-invalid"
|
| "render-source-invalid"
|
||||||
| "agent-source-invalid";
|
| "agent-source-invalid"
|
||||||
|
| "agent-output-source-invalid";
|
||||||
|
|
||||||
export function validateCanvasConnectionPolicy(args: {
|
export function validateCanvasConnectionPolicy(args: {
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
@@ -74,6 +75,10 @@ export function validateCanvasConnectionPolicy(args: {
|
|||||||
}): CanvasConnectionValidationReason | null {
|
}): CanvasConnectionValidationReason | null {
|
||||||
const { sourceType, targetType, targetIncomingCount } = args;
|
const { sourceType, targetType, targetIncomingCount } = args;
|
||||||
|
|
||||||
|
if (targetType === "agent-output" && sourceType !== "agent") {
|
||||||
|
return "agent-output-source-invalid";
|
||||||
|
}
|
||||||
|
|
||||||
if (targetType === "ai-video" && sourceType !== "video-prompt") {
|
if (targetType === "ai-video" && sourceType !== "video-prompt") {
|
||||||
return "ai-video-source-invalid";
|
return "ai-video-source-invalid";
|
||||||
}
|
}
|
||||||
@@ -152,6 +157,8 @@ export function getCanvasConnectionValidationMessage(
|
|||||||
return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Crop- oder Adjustment-Input.";
|
return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Crop- oder Adjustment-Input.";
|
||||||
case "agent-source-invalid":
|
case "agent-source-invalid":
|
||||||
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
|
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
|
||||||
|
case "agent-output-source-invalid":
|
||||||
|
return "Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.";
|
||||||
default:
|
default:
|
||||||
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
|
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
|||||||
type: "agent-output",
|
type: "agent-output",
|
||||||
label: "Agent-Ausgabe",
|
label: "Agent-Ausgabe",
|
||||||
category: "ai-output",
|
category: "ai-output",
|
||||||
phase: 3,
|
phase: 2,
|
||||||
|
implemented: true,
|
||||||
systemOutput: true,
|
systemOutput: true,
|
||||||
disabledHint: "Wird vom Agenten erzeugt",
|
disabledHint: "Wird vom Agenten erzeugt",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DEFAULT_DETAIL_ADJUST_DATA,
|
DEFAULT_DETAIL_ADJUST_DATA,
|
||||||
DEFAULT_LIGHT_ADJUST_DATA,
|
DEFAULT_LIGHT_ADJUST_DATA,
|
||||||
} from "@/lib/image-pipeline/adjustment-types";
|
} from "@/lib/image-pipeline/adjustment-types";
|
||||||
|
import { DEFAULT_AGENT_MODEL_ID } from "@/lib/agent-models";
|
||||||
import { DEFAULT_CROP_NODE_DATA } from "@/lib/image-pipeline/crop-node-data";
|
import { DEFAULT_CROP_NODE_DATA } from "@/lib/image-pipeline/crop-node-data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,6 +122,7 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
crop: [139, 92, 246],
|
crop: [139, 92, 246],
|
||||||
render: [14, 165, 233],
|
render: [14, 165, 233],
|
||||||
agent: [245, 158, 11],
|
agent: [245, 158, 11],
|
||||||
|
"agent-output": [245, 158, 11],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
||||||
@@ -229,6 +231,7 @@ export const NODE_HANDLE_MAP: Record<
|
|||||||
crop: { source: undefined, target: undefined },
|
crop: { source: undefined, target: undefined },
|
||||||
render: { source: undefined, target: undefined },
|
render: { source: undefined, target: undefined },
|
||||||
agent: { target: "agent-in" },
|
agent: { target: "agent-in" },
|
||||||
|
"agent-output": { target: "agent-output-in" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,7 +284,23 @@ export const NODE_DEFAULTS: Record<
|
|||||||
agent: {
|
agent: {
|
||||||
width: 360,
|
width: 360,
|
||||||
height: 320,
|
height: 320,
|
||||||
data: { templateId: "campaign-distributor" },
|
data: {
|
||||||
|
templateId: "campaign-distributor",
|
||||||
|
modelId: DEFAULT_AGENT_MODEL_ID,
|
||||||
|
clarificationQuestions: [],
|
||||||
|
clarificationAnswers: {},
|
||||||
|
outputNodeIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agent-output": {
|
||||||
|
width: 360,
|
||||||
|
height: 260,
|
||||||
|
data: {
|
||||||
|
title: "",
|
||||||
|
channel: "",
|
||||||
|
outputType: "",
|
||||||
|
body: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
296
tests/agent-node-runtime.test.ts
Normal file
296
tests/agent-node-runtime.test.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
queueNodeDataUpdate: vi.fn(async () => undefined),
|
||||||
|
runAgent: vi.fn(async () => ({ queued: true })),
|
||||||
|
resumeAgent: vi.fn(async () => ({ queued: true })),
|
||||||
|
toastWarning: vi.fn(),
|
||||||
|
subscription: { tier: "starter" as const },
|
||||||
|
isOffline: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("convex/react", () => ({
|
||||||
|
useAction: (reference: unknown) => {
|
||||||
|
if (reference === "agents.resumeAgent") {
|
||||||
|
return mocks.resumeAgent;
|
||||||
|
}
|
||||||
|
return mocks.runAgent;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/convex/_generated/api", () => ({
|
||||||
|
api: {
|
||||||
|
credits: {
|
||||||
|
getSubscription: "credits.getSubscription",
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
runAgent: "agents.runAgent",
|
||||||
|
resumeAgent: "agents.resumeAgent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-auth-query", () => ({
|
||||||
|
useAuthQuery: () => mocks.subscription,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
||||||
|
useCanvasSync: () => ({
|
||||||
|
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||||
|
status: { isOffline: mocks.isOffline, isSyncing: false, pendingCount: 0 },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/toast", () => ({
|
||||||
|
toast: {
|
||||||
|
warning: mocks.toastWarning,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/label", () => ({
|
||||||
|
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) =>
|
||||||
|
React.createElement("label", { htmlFor }, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/select", () => ({
|
||||||
|
Select: ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
"select",
|
||||||
|
{
|
||||||
|
"aria-label": "agent-model",
|
||||||
|
value,
|
||||||
|
onChange: (event: Event) => {
|
||||||
|
onValueChange((event.target as HTMLSelectElement).value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
SelectTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
SelectValue: () => null,
|
||||||
|
SelectContent: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) =>
|
||||||
|
React.createElement("option", { value }, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xyflow/react", () => ({
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Left: "left", Right: "right" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AgentNode from "@/components/canvas/nodes/agent-node";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("AgentNode runtime", () => {
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.subscription = { tier: "starter" };
|
||||||
|
mocks.isOffline = false;
|
||||||
|
mocks.queueNodeDataUpdate.mockClear();
|
||||||
|
mocks.runAgent.mockClear();
|
||||||
|
mocks.resumeAgent.mockClear();
|
||||||
|
mocks.toastWarning.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tier-aware model picker, updates node data, and triggers run/resume actions", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(AgentNode, {
|
||||||
|
id: "agent-1",
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
deletable: true,
|
||||||
|
zIndex: 1,
|
||||||
|
isConnectable: true,
|
||||||
|
type: "agent",
|
||||||
|
data: {
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
templateId: "campaign-distributor",
|
||||||
|
modelId: "openai/gpt-5.4-mini",
|
||||||
|
clarificationQuestions: [
|
||||||
|
{ id: "audience", prompt: "Target audience?", required: true },
|
||||||
|
],
|
||||||
|
clarificationAnswers: {},
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="agent-model"]');
|
||||||
|
if (!(modelSelect instanceof HTMLSelectElement)) {
|
||||||
|
throw new Error("Agent model select not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelOptionValues = Array.from(modelSelect.querySelectorAll("option")).map(
|
||||||
|
(option) => (option as HTMLOptionElement).value,
|
||||||
|
);
|
||||||
|
expect(modelOptionValues).toContain("openai/gpt-5.4-mini");
|
||||||
|
expect(modelOptionValues).not.toContain("openai/gpt-5.4-pro");
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("GPT-5.4 Mini");
|
||||||
|
expect(container.textContent).toContain("15 Cr");
|
||||||
|
expect(container.textContent).toContain("Channels");
|
||||||
|
expect(container.textContent).toContain("Expected Inputs");
|
||||||
|
expect(container.textContent).toContain("Expected Outputs");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
modelSelect.value = "openai/gpt-5.4";
|
||||||
|
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "agent-1",
|
||||||
|
data: expect.objectContaining({ modelId: "openai/gpt-5.4" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clarificationInput = container.querySelector('input[name="clarification-audience"]');
|
||||||
|
if (!(clarificationInput instanceof HTMLInputElement)) {
|
||||||
|
throw new Error("Clarification input not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(clarificationInput, "SaaS founders");
|
||||||
|
clarificationInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "agent-1",
|
||||||
|
data: expect.objectContaining({
|
||||||
|
clarificationAnswers: expect.objectContaining({ audience: "SaaS founders" }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||||
|
element.textContent?.includes("Run agent"),
|
||||||
|
);
|
||||||
|
if (!(runButton instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error("Run button not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
runButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.runAgent).toHaveBeenCalledWith({
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
nodeId: "agent-1",
|
||||||
|
modelId: "openai/gpt-5.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||||
|
element.textContent?.includes("Submit clarification"),
|
||||||
|
);
|
||||||
|
if (!(submitButton instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error("Submit clarification button not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.resumeAgent).toHaveBeenCalledWith({
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
nodeId: "agent-1",
|
||||||
|
clarificationAnswers: { audience: "SaaS founders" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and skips actions when offline", async () => {
|
||||||
|
mocks.isOffline = true;
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(AgentNode, {
|
||||||
|
id: "agent-2",
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
deletable: true,
|
||||||
|
zIndex: 1,
|
||||||
|
isConnectable: true,
|
||||||
|
type: "agent",
|
||||||
|
data: {
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
templateId: "campaign-distributor",
|
||||||
|
modelId: "openai/gpt-5.4-mini",
|
||||||
|
clarificationQuestions: [{ id: "q1", prompt: "Goal?", required: true }],
|
||||||
|
clarificationAnswers: { q1: "More signups" },
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||||
|
element.textContent?.includes("Run agent"),
|
||||||
|
);
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||||
|
element.textContent?.includes("Submit clarification"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(runButton instanceof HTMLButtonElement) || !(submitButton instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error("Runtime action buttons not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
runButton.click();
|
||||||
|
submitButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.runAgent).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.resumeAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
115
tests/agent-output-node.test.ts
Normal file
115
tests/agent-output-node.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const handleCalls: Array<{ type: string; id?: string }> = [];
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xyflow/react", () => ({
|
||||||
|
Handle: ({ type, id }: { type: string; id?: string }) => {
|
||||||
|
handleCalls.push({ type, id });
|
||||||
|
return React.createElement("div", {
|
||||||
|
"data-handle-type": type,
|
||||||
|
"data-handle-id": id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Position: { Left: "left", Right: "right" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AgentOutputNode from "@/components/canvas/nodes/agent-output-node";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("AgentOutputNode", () => {
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
handleCalls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders title, channel, output type, and body", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(AgentOutputNode, {
|
||||||
|
id: "agent-output-1",
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
deletable: true,
|
||||||
|
zIndex: 1,
|
||||||
|
isConnectable: true,
|
||||||
|
type: "agent-output",
|
||||||
|
data: {
|
||||||
|
title: "Instagram Caption",
|
||||||
|
channel: "instagram-feed",
|
||||||
|
outputType: "caption",
|
||||||
|
body: "A short punchy caption with hashtags",
|
||||||
|
_status: "done",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Instagram Caption");
|
||||||
|
expect(container.textContent).toContain("instagram-feed");
|
||||||
|
expect(container.textContent).toContain("caption");
|
||||||
|
expect(container.textContent).toContain("A short punchy caption with hashtags");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders input-only handle agent-output-in", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(AgentOutputNode, {
|
||||||
|
id: "agent-output-2",
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
deletable: true,
|
||||||
|
zIndex: 1,
|
||||||
|
isConnectable: true,
|
||||||
|
type: "agent-output",
|
||||||
|
data: {
|
||||||
|
title: "LinkedIn Post",
|
||||||
|
channel: "linkedin",
|
||||||
|
outputType: "post",
|
||||||
|
body: "Body",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,26 @@ describe("canvas connection policy", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows agent to agent-output", () => {
|
||||||
|
expect(
|
||||||
|
validateCanvasConnectionPolicy({
|
||||||
|
sourceType: "agent",
|
||||||
|
targetType: "agent-output",
|
||||||
|
targetIncomingCount: 0,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks non-agent sources to agent-output", () => {
|
||||||
|
expect(
|
||||||
|
validateCanvasConnectionPolicy({
|
||||||
|
sourceType: "text",
|
||||||
|
targetType: "agent-output",
|
||||||
|
targetIncomingCount: 0,
|
||||||
|
}),
|
||||||
|
).toBe("agent-output-source-invalid");
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks prompt to agent", () => {
|
it("blocks prompt to agent", () => {
|
||||||
expect(
|
expect(
|
||||||
validateCanvasConnectionPolicy({
|
validateCanvasConnectionPolicy({
|
||||||
@@ -197,4 +217,10 @@ describe("canvas connection policy", () => {
|
|||||||
"Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.",
|
"Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("describes invalid agent-output source message", () => {
|
||||||
|
expect(
|
||||||
|
getCanvasConnectionValidationMessage("agent-output-source-invalid"),
|
||||||
|
).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
189
tests/convex/openrouter-structured-output.test.ts
Normal file
189
tests/convex/openrouter-structured-output.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter";
|
||||||
|
|
||||||
|
type MockResponseInit = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json?: unknown;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMockResponse(init: MockResponseInit): Response {
|
||||||
|
return {
|
||||||
|
ok: init.ok,
|
||||||
|
status: init.status,
|
||||||
|
json: vi.fn(async () => init.json),
|
||||||
|
text: vi.fn(async () => init.text ?? JSON.stringify(init.json ?? {})),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateStructuredObjectViaOpenRouter", () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts chat completion request with strict json_schema and parses response content", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '{"title":"LemonSpace","confidence":0.92}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["title", "confidence"],
|
||||||
|
properties: {
|
||||||
|
title: { type: "string" },
|
||||||
|
confidence: { type: "number" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await generateStructuredObjectViaOpenRouter<{
|
||||||
|
title: string;
|
||||||
|
confidence: number;
|
||||||
|
}>("test-api-key", {
|
||||||
|
model: "openai/gpt-5-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Extract a JSON object." },
|
||||||
|
{ role: "user", content: "Title is LemonSpace with confidence 0.92" },
|
||||||
|
],
|
||||||
|
schemaName: "extract_title",
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ title: "LemonSpace", confidence: 0.92 });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://app.lemonspace.io",
|
||||||
|
"X-Title": "LemonSpace",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstCallArgs = fetchMock.mock.calls[0];
|
||||||
|
const init = firstCallArgs?.[1] as RequestInit | undefined;
|
||||||
|
const bodyRaw = init?.body;
|
||||||
|
const body = JSON.parse(typeof bodyRaw === "string" ? bodyRaw : "{}");
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
model: "openai/gpt-5-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Extract a JSON object." },
|
||||||
|
{ role: "user", content: "Title is LemonSpace with confidence 0.92" },
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "extract_title",
|
||||||
|
strict: true,
|
||||||
|
schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConvexError code when response content is missing", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
generateStructuredObjectViaOpenRouter("test-api-key", {
|
||||||
|
model: "openai/gpt-5-mini",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
schemaName: "test_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
data: { code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConvexError code when response content is invalid JSON", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: "not valid json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
generateStructuredObjectViaOpenRouter("test-api-key", {
|
||||||
|
model: "openai/gpt-5-mini",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
schemaName: "test_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
data: { code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConvexError code when OpenRouter responds with non-ok status", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
text: "service unavailable",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
generateStructuredObjectViaOpenRouter("test-api-key", {
|
||||||
|
model: "openai/gpt-5-mini",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
schemaName: "test_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
data: {
|
||||||
|
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||||
|
status: 503,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/lib/agent-models.test.ts
Normal file
61
tests/lib/agent-models.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AGENT_MODELS,
|
||||||
|
DEFAULT_AGENT_MODEL_ID,
|
||||||
|
getAgentModel,
|
||||||
|
getAvailableAgentModels,
|
||||||
|
isAgentModelAvailableForTier,
|
||||||
|
} from "@/lib/agent-models";
|
||||||
|
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
describe("agent models registry", () => {
|
||||||
|
it("contains approved models in stable order", () => {
|
||||||
|
expect(Object.keys(AGENT_MODELS)).toEqual([
|
||||||
|
"openai/gpt-5.4-nano",
|
||||||
|
"openai/gpt-5.4-mini",
|
||||||
|
"openai/gpt-5.4",
|
||||||
|
"openai/gpt-5.4-pro",
|
||||||
|
]);
|
||||||
|
expect(DEFAULT_AGENT_MODEL_ID).toBe("openai/gpt-5.4-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves model lookup and pricing", () => {
|
||||||
|
expect(getAgentModel("openai/gpt-5.4-nano")?.creditCost).toBe(6);
|
||||||
|
expect(getAgentModel("openai/gpt-5.4-mini")?.creditCost).toBe(15);
|
||||||
|
expect(getAgentModel("openai/gpt-5.4")?.creditCost).toBe(38);
|
||||||
|
expect(getAgentModel("openai/gpt-5.4-pro")?.creditCost).toBe(180);
|
||||||
|
expect(getAgentModel("unknown-model")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters models by tier", () => {
|
||||||
|
expect(getAvailableAgentModels("free").map((model) => model.id)).toEqual([]);
|
||||||
|
expect(getAvailableAgentModels("starter").map((model) => model.id)).toEqual([
|
||||||
|
"openai/gpt-5.4-nano",
|
||||||
|
"openai/gpt-5.4-mini",
|
||||||
|
"openai/gpt-5.4",
|
||||||
|
]);
|
||||||
|
expect(getAvailableAgentModels("max").map((model) => model.id)).toEqual([
|
||||||
|
"openai/gpt-5.4-nano",
|
||||||
|
"openai/gpt-5.4-mini",
|
||||||
|
"openai/gpt-5.4",
|
||||||
|
"openai/gpt-5.4-pro",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards access by tier", () => {
|
||||||
|
expect(isAgentModelAvailableForTier("starter", "openai/gpt-5.4")).toBe(true);
|
||||||
|
expect(isAgentModelAvailableForTier("starter", "openai/gpt-5.4-pro")).toBe(false);
|
||||||
|
expect(isAgentModelAvailableForTier("max", "openai/gpt-5.4-pro")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the registry default in agent node defaults", () => {
|
||||||
|
expect(NODE_DEFAULTS.agent?.data).toMatchObject({
|
||||||
|
templateId: "campaign-distributor",
|
||||||
|
modelId: DEFAULT_AGENT_MODEL_ID,
|
||||||
|
clarificationQuestions: [],
|
||||||
|
clarificationAnswers: {},
|
||||||
|
outputNodeIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/lib/agent-run-contract.test.ts
Normal file
96
tests/lib/agent-run-contract.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
areClarificationAnswersComplete,
|
||||||
|
normalizeAgentOutputDraft,
|
||||||
|
type AgentClarificationAnswerMap,
|
||||||
|
type AgentClarificationQuestion,
|
||||||
|
} from "@/lib/agent-run-contract";
|
||||||
|
|
||||||
|
describe("agent run contract helpers", () => {
|
||||||
|
describe("areClarificationAnswersComplete", () => {
|
||||||
|
it("returns true when every required question has a non-empty answer", () => {
|
||||||
|
const questions: AgentClarificationQuestion[] = [
|
||||||
|
{ id: "goal", prompt: "What is the goal?", required: true },
|
||||||
|
{ id: "tone", prompt: "Preferred tone?", required: false },
|
||||||
|
{ id: "audience", prompt: "Who is the audience?", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const answers: AgentClarificationAnswerMap = {
|
||||||
|
goal: "Generate launch captions",
|
||||||
|
audience: "SaaS founders",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(areClarificationAnswersComplete(questions, answers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a required question is missing", () => {
|
||||||
|
const questions: AgentClarificationQuestion[] = [
|
||||||
|
{ id: "goal", prompt: "What is the goal?", required: true },
|
||||||
|
{ id: "audience", prompt: "Who is the audience?", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const answers: AgentClarificationAnswerMap = {
|
||||||
|
goal: "Generate launch captions",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(areClarificationAnswersComplete(questions, answers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when required answers are blank after trimming", () => {
|
||||||
|
const questions: AgentClarificationQuestion[] = [
|
||||||
|
{ id: "goal", prompt: "What is the goal?", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const answers: AgentClarificationAnswerMap = {
|
||||||
|
goal: " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(areClarificationAnswersComplete(questions, answers)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAgentOutputDraft", () => {
|
||||||
|
it("trims draft fields and keeps non-empty values", () => {
|
||||||
|
const normalized = normalizeAgentOutputDraft({
|
||||||
|
title: " Launch Caption Pack ",
|
||||||
|
channel: " Instagram Feed ",
|
||||||
|
outputType: " caption-package ",
|
||||||
|
body: " 3 variants with hook-first copy. ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized).toEqual({
|
||||||
|
title: "Launch Caption Pack",
|
||||||
|
channel: "Instagram Feed",
|
||||||
|
outputType: "caption-package",
|
||||||
|
body: "3 variants with hook-first copy.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses safe fallback values and guarantees body string", () => {
|
||||||
|
const normalized = normalizeAgentOutputDraft({
|
||||||
|
title: " ",
|
||||||
|
channel: "",
|
||||||
|
outputType: " ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized).toEqual({
|
||||||
|
title: "Untitled",
|
||||||
|
channel: "general",
|
||||||
|
outputType: "text",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces non-string body values to empty string", () => {
|
||||||
|
const normalized = normalizeAgentOutputDraft({
|
||||||
|
title: "Recap",
|
||||||
|
channel: "Email",
|
||||||
|
outputType: "summary",
|
||||||
|
body: null as unknown as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.body).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user