- Added support for configuring markdown note block styles, including color and opacity. - Enabled double-click on markdown notes to open their configuration dialog. - Adjusted layout styles in markdown previews for better interaction control. - Updated relevant payloads, types, and UI logic to support added styling features. - Integrated multiple example notes in learning recipes for better visualization.
588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
BalanceScaleIcon,
|
|
Clock01Icon,
|
|
CodeIcon,
|
|
CodeSimpleIcon,
|
|
DiceFaces03Icon,
|
|
EqualSignIcon,
|
|
FingerPrintIcon,
|
|
FunctionIcon,
|
|
Parabola02Icon,
|
|
PencilEdit02Icon,
|
|
Plant01Icon,
|
|
Shield02Icon,
|
|
Tag01Icon,
|
|
TagsIcon,
|
|
UserAccountIcon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import {
|
|
Handle,
|
|
NodeResizer,
|
|
Position,
|
|
useUpdateNodeInternals,
|
|
type NodeProps,
|
|
} from "@xyflow/react";
|
|
import { memo, type ReactElement, useEffect } from "react";
|
|
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
|
|
import { useRecipeStudioStore } from "../stores/recipe-studio";
|
|
import type {
|
|
RecipeNode as RecipeGraphNodeType,
|
|
LlmType,
|
|
LayoutDirection,
|
|
NodeConfig,
|
|
SamplerType,
|
|
} from "../types";
|
|
import { NODE_HANDLE_CLASS } from "../utils/handle-layout";
|
|
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
|
|
import { InlineCategoryBadges } from "./inline/inline-category-badges";
|
|
import { InlineExpression } from "./inline/inline-expression";
|
|
import { InlineLlm } from "./inline/inline-llm";
|
|
import { InlineModel } from "./inline/inline-model";
|
|
import { isInlineConfig } from "./inline/inline-policy";
|
|
import { InlineSampler } from "./inline/inline-sampler";
|
|
import {
|
|
BaseNode,
|
|
BaseNodeContent,
|
|
BaseNodeHeader,
|
|
BaseNodeHeaderTitle,
|
|
} from "./rf-ui/base-node";
|
|
import { LabeledHandle } from "./rf-ui/labeled-handle";
|
|
|
|
type IconType = typeof CodeIcon;
|
|
|
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
const normalized = hex.trim().replace("#", "");
|
|
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
|
|
return null;
|
|
}
|
|
const int = Number.parseInt(normalized, 16);
|
|
return {
|
|
r: (int >> 16) & 255,
|
|
g: (int >> 8) & 255,
|
|
b: int & 255,
|
|
};
|
|
}
|
|
|
|
function parseNoteOpacity(value: string | undefined): number {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return 0.35;
|
|
}
|
|
return Math.max(0.05, Math.min(1, parsed / 100));
|
|
}
|
|
|
|
const NODE_META = {
|
|
sampler: {
|
|
tone: "bg-emerald-50 text-emerald-600 border-emerald-100",
|
|
},
|
|
llm: {
|
|
tone: "bg-sky-50 text-sky-600 border-sky-100",
|
|
},
|
|
expression: {
|
|
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
|
|
},
|
|
note: {
|
|
tone: "bg-violet-50 text-violet-700 border-violet-100",
|
|
},
|
|
seed: {
|
|
tone: "bg-lime-50 text-lime-700 border-lime-100",
|
|
},
|
|
model_provider: {
|
|
tone: "bg-amber-50 text-amber-600 border-amber-100",
|
|
},
|
|
model_config: {
|
|
tone: "bg-orange-50 text-orange-600 border-orange-100",
|
|
},
|
|
} as const;
|
|
|
|
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
|
|
category: Tag01Icon,
|
|
subcategory: TagsIcon,
|
|
uniform: EqualSignIcon,
|
|
gaussian: Parabola02Icon,
|
|
bernoulli: EqualSignIcon,
|
|
datetime: Clock01Icon,
|
|
timedelta: Clock01Icon,
|
|
uuid: FingerPrintIcon,
|
|
person: UserAccountIcon,
|
|
person_from_faker: UserAccountIcon,
|
|
};
|
|
|
|
const LLM_ICONS: Record<LlmType, IconType> = {
|
|
text: PencilEdit02Icon,
|
|
structured: CodeIcon,
|
|
code: CodeSimpleIcon,
|
|
judge: BalanceScaleIcon,
|
|
};
|
|
|
|
function resolveNodeIcon(
|
|
kind: RecipeGraphNodeType["data"]["kind"],
|
|
blockType: RecipeGraphNodeType["data"]["blockType"],
|
|
): IconType {
|
|
if (kind === "sampler" && blockType in SAMPLER_ICONS) {
|
|
return SAMPLER_ICONS[blockType as SamplerType];
|
|
}
|
|
if (kind === "llm" && blockType in LLM_ICONS) {
|
|
return LLM_ICONS[blockType as LlmType];
|
|
}
|
|
if (kind === "expression") {
|
|
return FunctionIcon;
|
|
}
|
|
if (kind === "note") {
|
|
return PencilEdit02Icon;
|
|
}
|
|
if (kind === "model_provider") {
|
|
return Shield02Icon;
|
|
}
|
|
if (kind === "model_config") {
|
|
return Plant01Icon;
|
|
}
|
|
if (kind === "seed") {
|
|
return Plant01Icon;
|
|
}
|
|
return DiceFaces03Icon;
|
|
}
|
|
|
|
function getConfigSummary(config: NodeConfig | undefined): string {
|
|
if (!config) {
|
|
return "Open details for config";
|
|
}
|
|
|
|
if (config.kind === "sampler") {
|
|
if (config.sampler_type === "category") {
|
|
const count = config.values?.length ?? 0;
|
|
return `${count} values`;
|
|
}
|
|
if (config.sampler_type === "subcategory") {
|
|
if (config.subcategory_parent?.trim()) {
|
|
return `Parent: ${config.subcategory_parent}`;
|
|
}
|
|
return "Select parent category";
|
|
}
|
|
if (config.sampler_type === "datetime") {
|
|
const start = config.datetime_start?.trim() || "?";
|
|
const end = config.datetime_end?.trim() || "?";
|
|
return `${start} -> ${end}`;
|
|
}
|
|
if (config.sampler_type === "timedelta") {
|
|
if (config.reference_column_name?.trim()) {
|
|
return `Ref: ${config.reference_column_name}`;
|
|
}
|
|
return "Pick datetime reference";
|
|
}
|
|
if (
|
|
config.sampler_type === "person" ||
|
|
config.sampler_type === "person_from_faker"
|
|
) {
|
|
const locale = config.person_locale?.trim() || "any locale";
|
|
const city = config.person_city?.trim();
|
|
if (city) {
|
|
return `${locale} · ${city}`;
|
|
}
|
|
return locale;
|
|
}
|
|
return "Open details for config";
|
|
}
|
|
|
|
if (config.kind === "llm") {
|
|
if (config.llm_type === "structured") {
|
|
return "Structured output schema in details";
|
|
}
|
|
if (config.llm_type === "judge") {
|
|
const scoreCount = config.scores?.length ?? 0;
|
|
return `${scoreCount} scorers`;
|
|
}
|
|
return "Prompt/system via linked input nodes";
|
|
}
|
|
|
|
if (config.kind === "seed") {
|
|
const seedSourceType = config.seed_source_type ?? "hf";
|
|
if (seedSourceType === "hf" && config.hf_repo_id.trim()) {
|
|
return config.hf_repo_id.trim();
|
|
}
|
|
if (seedSourceType === "local" && config.local_file_name?.trim()) {
|
|
return config.local_file_name.trim();
|
|
}
|
|
if (
|
|
seedSourceType === "unstructured" &&
|
|
config.unstructured_file_name?.trim()
|
|
) {
|
|
return config.unstructured_file_name.trim();
|
|
}
|
|
if (config.hf_path.trim()) {
|
|
return config.hf_path.trim();
|
|
}
|
|
if (seedSourceType === "hf") {
|
|
return "Set HF dataset repo";
|
|
}
|
|
if (seedSourceType === "local") {
|
|
return "Upload CSV/JSON file";
|
|
}
|
|
return "Upload PDF/DOCX/TXT file";
|
|
}
|
|
|
|
if (config.kind === "markdown_note") {
|
|
if (config.markdown.trim()) {
|
|
return "Markdown preview";
|
|
}
|
|
return "Add markdown content";
|
|
}
|
|
|
|
return "Open details for config";
|
|
}
|
|
|
|
function renderNodeBody(
|
|
config: NodeConfig | undefined,
|
|
summary: string,
|
|
updateConfig: (id: string, patch: Partial<NodeConfig>) => void,
|
|
): ReactElement {
|
|
if (config?.kind === "markdown_note") {
|
|
return <MarkdownPreview markdown={config.markdown} />;
|
|
}
|
|
|
|
if (config && isInlineConfig(config)) {
|
|
const onUpdate = (patch: Partial<NodeConfig>) => updateConfig(config.id, patch);
|
|
|
|
if (config.kind === "sampler") {
|
|
return <InlineSampler config={config} onUpdate={onUpdate} />;
|
|
}
|
|
if (config.kind === "model_provider" || config.kind === "model_config") {
|
|
return <InlineModel config={config} onUpdate={onUpdate} />;
|
|
}
|
|
if (config.kind === "llm") {
|
|
return <InlineLlm config={config} onUpdate={onUpdate} />;
|
|
}
|
|
if (config.kind === "expression") {
|
|
return <InlineExpression config={config} onUpdate={onUpdate} />;
|
|
}
|
|
}
|
|
|
|
if (config?.kind === "sampler" && config.sampler_type === "category") {
|
|
return <InlineCategoryBadges values={config.values ?? []} />;
|
|
}
|
|
|
|
return <p className="text-xs text-muted-foreground">{summary}</p>;
|
|
}
|
|
|
|
type LlmInputHandleItem = {
|
|
id: string;
|
|
label: string;
|
|
};
|
|
|
|
function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleItem[] {
|
|
if (!(config && config.kind === "llm")) {
|
|
return [];
|
|
}
|
|
const items: LlmInputHandleItem[] = [];
|
|
if (config.system_prompt.trim()) {
|
|
items.push({ id: HANDLE_IDS.llmSystemIn, label: "System" });
|
|
}
|
|
if (config.prompt.trim()) {
|
|
items.push({ id: HANDLE_IDS.llmPromptIn, label: "Prompt" });
|
|
}
|
|
if (config.llm_type === "judge") {
|
|
(config.scores ?? []).forEach((score, index) => {
|
|
items.push({
|
|
id: getLlmJudgeScoreHandleId(index),
|
|
label: score.name.trim() || `Score ${index + 1}`,
|
|
});
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
type LlmInputHandlesProps = {
|
|
items: LlmInputHandleItem[];
|
|
layoutDirection: LayoutDirection;
|
|
};
|
|
|
|
function LlmInputHandles({
|
|
items,
|
|
layoutDirection,
|
|
}: LlmInputHandlesProps): ReactElement | null {
|
|
if (items.length === 0) {
|
|
return null;
|
|
}
|
|
const isTopBottom = layoutDirection === "TB";
|
|
|
|
if (isTopBottom) {
|
|
return (
|
|
<div className="flex flex-wrap gap-2 pb-1">
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
|
|
>
|
|
<Handle
|
|
id={item.id}
|
|
type="target"
|
|
position={Position.Top}
|
|
className={NODE_HANDLE_CLASS}
|
|
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
|
|
/>
|
|
<span className="text-[10px] text-muted-foreground">{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1 pb-1">
|
|
{items.map((item) => (
|
|
<div key={item.id} className="pointer-events-none relative min-w-0 pl-3">
|
|
<Handle
|
|
id={item.id}
|
|
type="target"
|
|
position={Position.Left}
|
|
className={NODE_HANDLE_CLASS}
|
|
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
|
|
/>
|
|
<span className="block truncate text-[10px] text-muted-foreground">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecipeGraphNodeBase({
|
|
id,
|
|
data,
|
|
selected,
|
|
}: NodeProps<RecipeGraphNodeType>): ReactElement {
|
|
const meta = NODE_META[data.kind];
|
|
const icon = resolveNodeIcon(data.kind, data.blockType);
|
|
const layoutDirection = data.layoutDirection ?? "LR";
|
|
const config = useRecipeStudioStore((state) => state.configs[id]);
|
|
const openConfig = useRecipeStudioStore((state) => state.openConfig);
|
|
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
|
|
const llmAuxVisible = useRecipeStudioStore(
|
|
(state) => state.llmAuxVisibility[id] ?? false,
|
|
);
|
|
const setLlmAuxVisibility = useRecipeStudioStore(
|
|
(state) => state.setLlmAuxVisibility,
|
|
);
|
|
const updateNodeInternals = useUpdateNodeInternals();
|
|
|
|
useEffect(() => {
|
|
updateNodeInternals(id);
|
|
}, [id, layoutDirection, config, updateNodeInternals]);
|
|
|
|
if (config?.kind === "markdown_note") {
|
|
const rgb = hexToRgb(config.note_color ?? "#FDE68A");
|
|
const alpha = parseNoteOpacity(config.note_opacity);
|
|
const noteStyle = rgb
|
|
? {
|
|
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`,
|
|
borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.min(1, Math.max(alpha + 0.15, 0.3))})`,
|
|
}
|
|
: undefined;
|
|
|
|
return (
|
|
<BaseNode
|
|
className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm"
|
|
style={noteStyle}
|
|
>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={MIN_NODE_WIDTH}
|
|
minHeight={80}
|
|
maxWidth={MAX_NODE_WIDTH}
|
|
maxHeight={520}
|
|
color="var(--primary)"
|
|
lineClassName="!border-transparent !shadow-none"
|
|
lineStyle={{ opacity: 0 }}
|
|
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
|
|
handleStyle={{ opacity: 0 }}
|
|
/>
|
|
<BaseNodeContent className="px-3 py-2">
|
|
<MarkdownPreview markdown={config.markdown} plain={true} />
|
|
</BaseNodeContent>
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|
|
const showDataHandles =
|
|
data.kind === "llm" ||
|
|
data.kind === "expression" ||
|
|
data.kind === "sampler" ||
|
|
data.kind === "seed";
|
|
const showSemanticIn = data.kind === "model_config";
|
|
const showSemanticOut =
|
|
data.kind === "model_config" || data.kind === "model_provider";
|
|
const summary = getConfigSummary(config);
|
|
const nodeBody = renderNodeBody(config, summary, updateConfig);
|
|
const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
|
|
const canShowLlmAux =
|
|
config?.kind === "llm" &&
|
|
(Boolean(config.prompt.trim()) ||
|
|
Boolean(config.system_prompt.trim()) ||
|
|
Boolean((config.scores?.length ?? 0) > 0));
|
|
|
|
return (
|
|
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={MIN_NODE_WIDTH}
|
|
minHeight={120}
|
|
maxWidth={MAX_NODE_WIDTH}
|
|
maxHeight={520}
|
|
color="var(--primary)"
|
|
lineClassName="!border-transparent !shadow-none"
|
|
lineStyle={{ opacity: 0 }}
|
|
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
|
|
handleStyle={{ opacity: 0 }}
|
|
/>
|
|
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
"corner-squircle flex size-7 items-center justify-center rounded-md border",
|
|
meta.tone,
|
|
)}
|
|
>
|
|
<HugeiconsIcon icon={icon} className="size-3.5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<BaseNodeHeaderTitle className="truncate text-sm">
|
|
{data.name}
|
|
</BaseNodeHeaderTitle>
|
|
<p className="truncate text-[11px] text-muted-foreground">
|
|
{data.subtype} · {data.title}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{canShowLlmAux && (
|
|
<Button
|
|
type="button"
|
|
size="xs"
|
|
variant="ghost"
|
|
className="nodrag"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setLlmAuxVisibility(id, !llmAuxVisible);
|
|
}}
|
|
>
|
|
{llmAuxVisible ? "Hide inputs" : "Show inputs"}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="xs"
|
|
variant="ghost"
|
|
className="nodrag"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openConfig(id);
|
|
}}
|
|
>
|
|
Configure
|
|
</Button>
|
|
</div>
|
|
</BaseNodeHeader>
|
|
|
|
<BaseNodeContent className="gap-2 px-3 py-2">
|
|
<LlmInputHandles items={llmInputHandles} layoutDirection={layoutDirection} />
|
|
{nodeBody}
|
|
</BaseNodeContent>
|
|
|
|
{showDataHandles && (
|
|
<>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.dataIn}
|
|
title="Data input"
|
|
type="target"
|
|
position={Position.Left}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.dataInTop}
|
|
title="Data input"
|
|
type="target"
|
|
position={Position.Top}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.dataOut}
|
|
title="Data output"
|
|
type="source"
|
|
position={Position.Right}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.dataOutBottom}
|
|
title="Data output"
|
|
type="source"
|
|
position={Position.Bottom}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{showSemanticIn && (
|
|
<>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.semanticIn}
|
|
title="Semantic input"
|
|
type="target"
|
|
position={Position.Left}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.semanticInTop}
|
|
title="Semantic input"
|
|
type="target"
|
|
position={Position.Top}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{showSemanticOut && (
|
|
<>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.semanticOut}
|
|
title="Semantic output"
|
|
type="source"
|
|
position={Position.Right}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
<LabeledHandle
|
|
id={HANDLE_IDS.semanticOutBottom}
|
|
title="Semantic output"
|
|
type="source"
|
|
position={Position.Bottom}
|
|
className="absolute inset-0 pointer-events-none"
|
|
labelClassName="sr-only"
|
|
handleClassName={NODE_HANDLE_CLASS}
|
|
/>
|
|
</>
|
|
)}
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|
|
export const RecipeNode = memo(RecipeGraphNodeBase);
|