feat: add "Multi-Turn Chat" learning recipe with structured conversation outputs

- Introduced "Multi-Turn Chat" recipe to generate structured user-assistant conversations with domain/topic-based goals and constraints.
- Added `conversation.json` with model configuration, sampling strategies, and LLM prompts.
- Updated UI nodes, layout, and graph rendering logic to support new recipe.
- Enhanced `recipe-studio` fit view logic to improve editor layout responsiveness.
This commit is contained in:
Shine1i 2026-02-24 02:38:36 +01:00
commit ba000dc0f2
5 changed files with 278 additions and 4 deletions

View file

@ -0,0 +1,236 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "model_1",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_1",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 2048
}
}
],
"tool_configs": [],
"columns": [
{
"column_type": "sampler",
"name": "domain",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"Tech Support",
"Personal Finance",
"Learning"
]
}
},
{
"column_type": "sampler",
"name": "topic",
"drop": true,
"sampler_type": "subcategory",
"params": {
"category": "domain",
"values": {
"Tech Support": [
"Wi-Fi keeps disconnecting",
"Laptop running very slow",
"Cannot install app update"
],
"Personal Finance": [
"Monthly budget planning",
"Credit card debt payoff",
"Emergency fund setup"
],
"Learning": [
"Exam study plan",
"Learn Python basics",
"Improve English writing"
]
}
}
},
{
"column_type": "sampler",
"name": "conversation_length",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"4",
"6"
]
}
},
{
"column_type": "llm-text",
"name": "user_goal",
"drop": false,
"model_alias": "model_1",
"prompt": "Write one user goal for a chat assistant.\nDomain: {{ domain }}\nTopic: {{ topic }}\nConversation length target: {{ conversation_length }} messages total.\nRules:\n- 1 sentence.\n- Specific and practical.\n- Output only the goal text.",
"system_prompt": "You write realistic user goals for assistant conversations.\n",
"with_trace": "none"
},
{
"column_type": "llm-structured",
"name": "output_format",
"drop": false,
"model_alias": "model_1",
"prompt": "Generate a realistic multi-turn conversation.\nUser goal:\n{{ user_goal }}\nConstraints:\n- Exactly {{ conversation_length }} messages total.\n- Alternate roles strictly: user, assistant, user, assistant...\n- First message must be user.\n- Last message must be assistant.\n- Keep responses grounded in {{ domain }} / {{ topic }}.\n- End naturally with resolution or clear next step.\n- No markdown, no extra keys.",
"output_format": {
"type": "object",
"properties": {
"conversation": {
"type": "array",
"minItems": 4,
"maxItems": 6,
"items": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": [
"user",
"assistant"
]
},
"content": {
"type": "string",
"minLength": 1
}
},
"required": [
"role",
"content"
],
"additionalProperties": false
}
}
},
"required": [
"conversation"
],
"additionalProperties": false
}
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "domain",
"x": 0,
"y": 140,
"width": 400
},
{
"id": "topic",
"x": 0,
"y": 280,
"width": 400
},
{
"id": "conversation_length",
"x": 466.61510192672256,
"y": 139.68271861864798,
"width": 400
},
{
"id": "user_goal",
"x": 1.412158386197035,
"y": 508.77123580445596,
"width": 400
},
{
"id": "output_format",
"x": 1.1486983549970375,
"y": 754.4221089431811,
"width": 400
},
{
"id": "provider_1",
"x": -1056.848383841495,
"y": 519.6373927070263,
"width": 400
},
{
"id": "model_1",
"x": -543.7221365246206,
"y": 488.2975724283656,
"width": 400
}
],
"edges": [
{
"from": "domain",
"to": "topic",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "domain",
"to": "conversation_length",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "topic",
"to": "user_goal",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "user_goal",
"to": "output_format",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "provider_1",
"to": "model_1",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "semantic-in"
},
{
"from": "model_1",
"to": "user_goal",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "data-in"
},
{
"from": "model_1",
"to": "output_format",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in"
}
],
"layout_direction": "LR"
}
}

View file

@ -12,6 +12,7 @@ const instructionFromAnswerUrl = new URL(
).href;
const textToPythonUrl = new URL("./text-to-python.json", import.meta.url).href;
const textToSqlUrl = new URL("./text-to-sql.json", import.meta.url).href;
const conversationUrl = new URL("./conversation.json", import.meta.url).href;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@ -123,4 +124,11 @@ export const LEARNING_RECIPES: LearningRecipeDef[] = [
"Generate SQL tasks and runnable SQL outputs with prompt-driven generation.",
loadPayload: () => loadPayloadFromUrl(textToSqlUrl),
},
{
id: "conversation",
title: "Multi-Turn Chat",
description:
"Generate realistic user-assistant conversations with structured message output.",
loadPayload: () => loadPayloadFromUrl(conversationUrl),
},
];

View file

@ -144,8 +144,8 @@ const TEMPLATE_CARDS: TemplateCard[] = [
description:
"Role-based multi-turn conversations for assistant behavior, memory, and response quality.",
icon: AiChat02Icon,
difficulty: "Advanced",
learningBadges: ["LLM Text", "Conversation Design"],
difficulty: "Easy",
learningBadges: ["Structured LLM", "LLM Text"],
surfaceClassName:
"from-rose-500/15 via-pink-500/5 to-transparent",
shineColor: [
@ -153,6 +153,7 @@ const TEMPLATE_CARDS: TemplateCard[] = [
"rgb(236 72 153 / 0.4)",
"rgb(251 113 133 / 0.45)",
],
learningRecipeId: "conversation",
},
];

View file

@ -9,6 +9,7 @@ import {
type NodeTypes,
Panel,
ReactFlow,
type ReactFlowInstance,
} from "@xyflow/react";
import {
CookBookIcon,
@ -101,6 +102,7 @@ export function RecipeStudioPage({
activeConfigId,
dialogOpen,
layoutDirection,
fitViewTick,
onNodesChange,
onEdgesChange,
onConnect,
@ -137,6 +139,7 @@ export function RecipeStudioPage({
activeConfigId: state.activeConfigId,
dialogOpen: state.dialogOpen,
layoutDirection: state.layoutDirection,
fitViewTick: state.fitViewTick,
onNodesChange: state.onNodesChange,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
@ -169,6 +172,9 @@ export function RecipeStudioPage({
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [reactFlowInstance, setReactFlowInstance] = useState<
ReactFlowInstance<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge> | null
>(null);
const handleExecutionStart = useCallback(() => {
setActiveView("executions");
}, []);
@ -347,6 +353,24 @@ export function RecipeStudioPage({
const runDialogLoading =
runDialogKind === "preview" ? previewLoading : fullLoading;
useEffect(() => {
if (!reactFlowInstance || activeView !== "editor" || fitViewTick === 0) {
return;
}
let frame2 = 0;
const frame1 = window.requestAnimationFrame(() => {
frame2 = window.requestAnimationFrame(() => {
reactFlowInstance.fitView({ duration: 250 });
});
});
return () => {
window.cancelAnimationFrame(frame1);
if (frame2) {
window.cancelAnimationFrame(frame2);
}
};
}, [activeView, fitViewTick, reactFlowInstance]);
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
@ -368,7 +392,8 @@ export function RecipeStudioPage({
/>
<div className="h-[75vh] w-full rounded-t-none">
{activeView === "editor" ? (
<ReactFlow
<ReactFlow<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge>
onInit={setReactFlowInstance}
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}

View file

@ -56,6 +56,7 @@ type RecipeStudioState = {
layoutDirection: LayoutDirection;
nextId: number;
nextY: number;
fitViewTick: number;
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
setDialogOpen: (open: boolean) => void;
@ -103,6 +104,7 @@ const INITIAL_STATE = {
layoutDirection: "LR",
nextId: 3,
nextY: 280,
fitViewTick: 0,
} satisfies Pick<
RecipeStudioState,
| "nodes"
@ -118,6 +120,7 @@ const INITIAL_STATE = {
| "layoutDirection"
| "nextId"
| "nextY"
| "fitViewTick"
>;
function buildAddedNodeState(
@ -436,7 +439,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
addExpressionNode: () =>
set((state) => buildAddedNodeState(state, "expression", "expression")),
loadRecipe: (snapshot) =>
set(() => ({
set((state) => ({
configs: snapshot.configs,
nodes: applyLayoutDirectionToNodes(
snapshot.nodes,
@ -454,6 +457,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
fitViewTick: state.fitViewTick + 1,
})),
setAuxNodePosition: (id, position) =>
set((state) => {