feat(agent): implement phase 2 runtime and inline clarification
This commit is contained in:
@@ -1,14 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bot } from "lucide-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 { 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";
|
||||
|
||||
type AgentNodeData = {
|
||||
templateId?: string;
|
||||
canvasId?: string;
|
||||
modelId?: string;
|
||||
clarificationQuestions?: AgentClarificationQuestion[];
|
||||
clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>;
|
||||
_status?: string;
|
||||
_statusMessage?: string;
|
||||
};
|
||||
@@ -17,6 +47,68 @@ type AgentNodeType = Node<AgentNodeData, "agent">;
|
||||
|
||||
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[] }) {
|
||||
return (
|
||||
<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 template =
|
||||
getAgentTemplate(nodeData.templateId ?? 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) {
|
||||
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>
|
||||
</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">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user