feat: enhance markdown note blocks with style options and double-click config access

- 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.
This commit is contained in:
Shine1i 2026-02-24 03:47:42 +01:00
commit b80796a7cd
13 changed files with 280 additions and 34 deletions

View file

@ -23,7 +23,7 @@ function MarkdownPreviewImpl({
<div
className={cn(
plain
? "nodrag h-full w-full overflow-auto p-2 text-xs leading-relaxed"
? "h-full w-full overflow-auto p-2 text-xs leading-relaxed pointer-events-none select-none"
: "nodrag max-h-56 overflow-auto rounded-md border border-border/60 bg-muted/20 p-2 text-xs leading-relaxed",
className,
)}

View file

@ -5,7 +5,6 @@
"name": "openai_provider",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"api_key": "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",
"extra_headers": {},
"extra_body": {}
}
@ -42,20 +41,11 @@
"name": "generated_instruction",
"drop": false,
"model_alias": "ministral",
"prompt": "Based on this target answer: \n{{ output }}\n\nWrite one high-quality plain text short and brief user instruction that this answer would satisfy.\nReturn only the instruction.",
"prompt": "Based on this target answer:\n{{ output }}\n\nWrite one high-quality plain text short and brief user instruction that this answer would satisfy.\nReturn only the instruction.",
"with_trace": "none"
}
],
"processors": [
{
"processor_type": "drop_columns",
"name": "drop_seed_columns",
"column_names": [
"instruction",
"input"
]
}
]
"processors": []
},
"run": {
"rows": 5,
@ -66,6 +56,28 @@
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": -567.3566303099885,
"y": 38.88875727651093,
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "#### Hugginface seed block\nThis recipe uses a ** HuggingFace dataset ** as seed data.\nYou provide dataset identity, load columns, then generate new fields from seed columns.\n\n##### Setup:\n\n1. Paste dataset id as `org/repo` (example: `unsloth/alpaca-cleaned`)\n2. Add token only if dataset is gated/private\n3. Load columns + preview rows so variables are available in prompts\n\n##### Why this matters:\n- Seed columns can drive generation quality\n- You can reference seed values directly in prompts (for example `{{ output }}`)",
"note_color": "#d6fff8",
"note_opacity": "35"
},
{
"id": "note_2",
"x": -74.04047072330651,
"y": -265.3540670633283,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "##### Drop columns behavior:\n\n- You can mark specific seed columns to **drop from final output**\n- Those columns are still used during generation\n- They are removed only from exported final dataset\n\n##### Example:\n- Keep `generated_instruction` from llm-text block\n- Drop original `instruction`, `input`, `output` from the hugginface dataset from final artifact\n- Result: clean training output while still using source columns as generation context\n",
"note_color": "#d6fff8",
"note_opacity": "35"
},
{
"id": "seed",
"x": -76.07288662013991,
@ -80,8 +92,8 @@
},
{
"id": "ministral",
"x": 461,
"y": -190.00000000000009,
"x": 463.272022949692,
"y": -191.13601147484601,
"width": 400
},
{
@ -93,31 +105,35 @@
],
"edges": [
{
"from": "openai_provider",
"to": "ministral",
"type": "semantic"
"from": "seed",
"to": "generated_instruction",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "ministral",
"to": "generated_instruction",
"type": "semantic"
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "seed",
"to": "generated_instruction",
"type": "canvas"
"from": "openai_provider",
"to": "ministral",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "semantic-in-top"
}
],
"layout_direction": "LR",
"seed_source_type": "hf",
"seed_columns": [
"instruction",
"input",
"output"
],
"seed_drop_columns": [
"instruction",
"input"
],
"seed_drop_columns": [],
"seed_preview_rows": [
{
"instruction": "Give three tips for staying healthy.",

View file

@ -5,7 +5,6 @@
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"api_key": "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",
"extra_headers": {},
"extra_body": {}
}
@ -71,6 +70,39 @@
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": -180.01113025994076,
"y": 43.27382247773167,
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "This recipe uses **seed data** from external documents.\nInstead of starting from empty generation, we load real source text first.\n\nIn this flow, the seed source is **Unstructured Documents**:\n\n- Upload: `.pdf`, `.docx`, `.txt`\n- Text is extracted and split on client into chunks\n- Each chunk becomes a row-like seed record (`chunk_text`) that you can reference in prompts with `{{ chunk_text }} `",
"note_color": "#30f9fd",
"note_opacity": "10"
},
{
"id": "note_2",
"x": 283.8131688769869,
"y": -333.10847089567505,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "##### Chunking settings:\n\n- **Chunk size**: how much text per chunk\n- **Chunk overlap**: shared text between neighboring chunks to preserve context\n\n##### Sampling settings:\n\n- **Ordered**: keep original document order\n- **Shuffle**: randomize chunk order\n- **Selection index / selection settings**: choose which part/subset of seed data to use",
"note_color": "#2bf9fd",
"note_opacity": "10"
},
{
"id": "note_3",
"x": 303.52241671566657,
"y": 299.62272507131615,
"width": 400,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "- LLM prompt: `{{ chunk_text }}`\n- Expression block: combine/format values using `{{ chunk_text }}`\n- Processor templates: use `{{ chunk_text }}` during transforms\n\nTip:\n- Start with medium chunk size + small overlap.\n- Increase overlap only if answers lose context between chunks.",
"note_color": "#8bf0fd",
"note_opacity": "20"
},
{
"id": "seed",
"x": 295.56977201312833,
@ -97,26 +129,34 @@
}
],
"edges": [
{
"from": "seed",
"to": "llm_structured_1",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "provider_1",
"to": "provider_column",
"type": "semantic"
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "semantic-in-top"
},
{
"from": "provider_column",
"to": "llm_structured_1",
"type": "semantic"
},
{
"from": "seed",
"to": "llm_structured_1",
"type": "canvas"
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
}
],
"layout_direction": "LR",
"seed_source_type": "unstructured",
"seed_columns": [
"chunk_text"
],
"seed_drop_columns": [],
"seed_preview_rows": [
{
"chunk_text": "[Citation Needed] The Best of Wikipedias Worst Writing Conor Lastowka and Josh Fruhlinger Boring Legal Fine Print Each entry in this book contains material from Wikipedia, although the text we use may not represent the current version of any article. The URL at the bottom of each page will direct you to the source Wikipedia article; use the articles History tab to find a list of contributors. All material in this book that is taken from Wikipedia is licensed under the Creative Commons-Attribution Share Alike 3.0 license. Heres a quick human-readable summary of your rights to use this content: You are free: to Share—to copy, distribute and transmit the work, and to Remix—to adapt the work Under the following conditions: Attribution—You must attribute the work in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the work.) Share Alike—If you alter, transform, or build upon this work, you may distribute the resulting work only under the same, similar or a compatible license. With the understanding that: Waiver—Any of the above conditions can be waived if you get permission from the copyright holder. Other Rights—In"

View file

@ -166,6 +166,50 @@
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": 1084.767431711644,
"y": -293.4482850247655,
"width": 782,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "## Expression columns \nAre like lightweight spreadsheet formulas.\nUse them when you want to transform existing columns quickly, without calling an LLM.\n\n### What you can do:\n\n- Use values from other columns: `{{ first_name }} {{ last_name }}`\n- Clean/format text: `{{ city | upper }}`, `{{ product_name | trim }}`\n- Conditional logic:\n - `{% if order_total >= 100 %}VIP{% elif order_total >= 50 %}Standard{% else %}Starter{% endif %}`\n- Simple math:\n - `{{ quantity * unit_price }}`\n - `{{ (subtotal - discount) | round(2) }}`\n\n### Good rule:\n- If the value can be computed from existing data, use Expression first.\n- Use LLM only when you need true language generation.",
"note_color": "#fffbeb",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 1944,
"y": 760.9999999999999,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "### LLM Structured block\nGenerates JSON that matches your Output Format schema.\nThink of Output Format as a contract for what the model must return.\n\n#### Prompt tips:\n\n- Reference existing columns with Jinja: `{{ column_name }}`\n- You can reference nested values too: `{{ customer.first_name }}`\n- Be explicit about what each field should contain.\n\n#### Example prompt pattern:\n\n```text\nCreate a support ticket summary.\nCustomer: {{ customer_name }}\nIssue text: {{ issue_text }}\n\nReturn data for:\n- priority\n- short_title\n- resolution_steps\n```",
"note_color": "#f4efdc",
"note_opacity": "35"
},
{
"id": "note_3",
"x": 2381.178207301403,
"y": 790.2196835690842,
"width": 638,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "## Example output format shape (concept):\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"priority\": { \"type\": \"string\" },\n \"short_title\": { \"type\": \"string\" },\n \"resolution_steps\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n },\n \"required\": [\"priority\", \"short_title\", \"resolution_steps\"]\n}\n```",
"note_color": "#fbf6e5",
"note_opacity": "35"
},
{
"id": "note_4",
"x": 2405.796928768747,
"y": -362.26583299682716,
"width": 399,
"node_type": "markdown_note",
"name": "note_4",
"markdown": "### Model provider & Config\nEvery LLM block needs a model alias.\nThat alias comes from a Model Config.\nModel Config points to a Model Provider.\n\n#### Minimum setup:\n\n1. Create **Model Provider**\n - Set endpoint/provider type\n - Prefer env var auth (`api_key_env`) over hardcoded keys\n\n2. Create **Model Config**\n - Set alias (example: `model_1`)\n - Set model id\n - Link to provider\n - Tune params (temperature, max_tokens, etc.)\n\n3. In each LLM block\n - Set `model_alias` to that alias\n\nIf alias/provider link is missing, validation/run will fail.",
"note_color": "#f6f4ef",
"note_opacity": "35"
},
{
"id": "provider_column",
"x": 1947.2039072039072,

View file

@ -150,6 +150,28 @@
"x": 2040.9251520522098,
"y": -13.362336454344792,
"width": 400
},
{
"id": "note_1",
"x": 1482.1328175027095,
"y": 242.4370179053253,
"width": 568,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "The **LLM Code** block is where Python code is generated from your instruction/prompt.\n\n##### How it works in this recipe:\n\n- You provide a clear prompt (often using Jinja references from earlier columns)\n- The model returns a response\n- The block extracts code content directly for the output column\n\n##### Current status:\n\n- We are **not** running Python lint/syntax validation in this recipe yet (Soon)\n- Validation support is planned and will be added\n\n##### What this means:\n\n- You may get mostly correct code, but some rows can still have syntax/style issues\n- Keep prompts specific and constrained to reduce bad outputs\n\n##### Tip:\n\n- Ask for one self-contained function/script\n- Ask for required imports\n- Ask for no markdown fences if you want cleaner extraction\n",
"note_color": "#FDE68A",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 2513.2527820497985,
"y": -235.2544980991115,
"width": 471,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "The **LLM Judge** block evaluates generated outputs with rubric-style scores.\n\n##### Important:\n\n- A judge can have **one or many scores**\n- Each score has:\n - a name (for example: `Correctness`)\n - a description\n - options (value + meaning)\n\n##### Example multi-score setup:\n\n- Correctness\n- Readability\n- Efficiency\n\n##### Why use multiple scores:\n\n- You get richer quality signals than a single pass/fail\n- Easier filtering and weighting later in training data prep\n\n##### Practical pattern:\n\n1. Generate code with LLM Code\n2. Judge with 2-4 focused scores\n3. Keep high-quality rows based on score thresholds\n",
"note_color": "#FDE68A",
"note_opacity": "35"
}
],
"edges": [

View file

@ -54,6 +54,27 @@ 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",
@ -353,6 +374,40 @@ function RecipeGraphNodeBase({
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" ||

View file

@ -1,3 +1,4 @@
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { MarkdownNoteConfig } from "../../types";
@ -14,10 +15,46 @@ export function MarkdownNoteDialog({
onUpdate,
}: MarkdownNoteDialogProps): ReactElement {
const markdownId = `${config.id}-markdown`;
const colorId = `${config.id}-note-color`;
const opacity =
Number.parseInt(config.note_opacity ?? "35", 10) > 0
? Math.max(0, Math.min(100, Number.parseInt(config.note_opacity ?? "35", 10)))
: 35;
return (
<div className="space-y-4">
<NameField value={config.name} onChange={(value) => onUpdate({ name: value })} />
<div className="grid gap-3">
<FieldLabel
label="Note style"
htmlFor={colorId}
hint="Pick a color and opacity for this note block."
/>
<div className="flex items-center gap-3">
<input
id={colorId}
type="color"
className="nodrag h-9 w-14 cursor-pointer rounded-md border border-border/60 bg-transparent p-1"
value={config.note_color ?? "#FDE68A"}
onChange={(event) => onUpdate({ note_color: event.target.value })}
/>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs tabular-nums text-muted-foreground">{opacity}%</span>
</div>
<Slider
min={5}
max={100}
step={1}
value={[opacity]}
onValueChange={([value]) =>
onUpdate({ note_opacity: String(Math.round(value)) })
}
/>
</div>
</div>
</div>
<div className="grid gap-2">
<FieldLabel
label="Markdown"

View file

@ -114,6 +114,7 @@ export function RecipeStudioPage({
addExpressionNode,
addMarkdownNoteNode,
selectConfig,
openConfig,
updateConfig,
isValidConnection,
setSheetView,
@ -152,6 +153,7 @@ export function RecipeStudioPage({
addExpressionNode: state.addExpressionNode,
addMarkdownNoteNode: state.addMarkdownNoteNode,
selectConfig: state.selectConfig,
openConfig: state.openConfig,
updateConfig: state.updateConfig,
isValidConnection: state.isValidConnection,
setSheetView: state.setSheetView,
@ -233,6 +235,19 @@ export function RecipeStudioPage({
[selectConfig],
);
const handleNodeDoubleClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
const nodeConfig = configs[node.id];
if (nodeConfig?.kind === "markdown_note") {
openConfig(node.id);
}
},
[configs, openConfig],
);
const handleNodesChange = useCallback(
(changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
@ -408,6 +423,7 @@ export function RecipeStudioPage({
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}

View file

@ -222,6 +222,10 @@ export type MarkdownNoteConfig = {
kind: "markdown_note";
name: string;
markdown: string;
// ui-only
note_color?: string;
// ui-only (0-100 as string for slider/input ergonomics)
note_opacity?: string;
};
export type SeedConfig = {

View file

@ -287,6 +287,8 @@ export function makeMarkdownNoteConfig(
kind: "markdown_note",
name: nextName(existing, "note"),
markdown: "## Note\n\nAdd markdown here.",
note_color: "#FDE68A",
note_opacity: "35",
};
}

View file

@ -44,6 +44,8 @@ type UiInput = {
type UiMarkdownNoteNode = {
name: string;
markdown: string;
note_color?: string;
note_opacity?: string;
};
function readStringNumber(value: unknown): string | undefined {
@ -247,6 +249,8 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
noteNodes.push({
name: name.trim(),
markdown: readString(node.markdown) ?? "",
note_color: readString(node.note_color) ?? undefined,
note_opacity: readStringNumber(node.note_opacity) ?? undefined,
});
}
return noteNodes;
@ -342,6 +346,8 @@ export function importRecipePayload(input: string): ImportResult {
kind: "markdown_note",
name: note.name,
markdown: note.markdown,
note_color: note.note_color ?? "#FDE68A",
note_opacity: note.note_opacity ?? "35",
};
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);

View file

@ -202,6 +202,8 @@ export function buildRecipePayload(
node_type: "markdown_note" as const,
name: config.name,
markdown: config.markdown,
note_color: config.note_color,
note_opacity: config.note_opacity,
},
];
}

View file

@ -38,6 +38,8 @@ export type RecipePayload = {
node_type?: "markdown_note";
name?: string;
markdown?: string;
note_color?: string;
note_opacity?: string;
}>;
edges: {
from: string;