Files
lemonspace_app/components/canvas/nodes/agent-node.tsx

403 lines
12 KiB
TypeScript

"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;
};
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">
{items.slice(0, 4).map((item) => (
<li key={item} className="truncate text-[11px] text-foreground/90" title={item}>
- {item}
</li>
))}
</ul>
);
}
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;
}
return (
<BaseNodeWrapper
nodeType="agent"
selected={selected}
status={nodeData._status}
statusMessage={nodeData._statusMessage}
className="min-w-[300px] border-amber-500/30"
>
<Handle
type="target"
position={Position.Left}
id="agent-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">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
<Bot className="h-3.5 w-3.5" />
<span>{template.emoji}</span>
<span>{template.name}</span>
</div>
<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
</p>
<CompactList items={template.channels} />
</section>
<section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Expected Inputs
</p>
<CompactList items={template.expectedInputs} />
</section>
<section className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Expected Outputs
</p>
<CompactList items={template.expectedOutputs} />
</section>
</div>
</BaseNodeWrapper>
);
}