diff --git a/studio/frontend/src/features/data-recipes/learning-recipes/conversation.json b/studio/frontend/src/features/data-recipes/learning-recipes/conversation.json new file mode 100644 index 0000000000..1c14bb160e --- /dev/null +++ b/studio/frontend/src/features/data-recipes/learning-recipes/conversation.json @@ -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" + } +} \ No newline at end of file diff --git a/studio/frontend/src/features/data-recipes/learning-recipes/index.ts b/studio/frontend/src/features/data-recipes/learning-recipes/index.ts index 4e5dc815f3..6c898e8228 100644 --- a/studio/frontend/src/features/data-recipes/learning-recipes/index.ts +++ b/studio/frontend/src/features/data-recipes/learning-recipes/index.ts @@ -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 { 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), + }, ]; diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx index da662909bc..2eb777fed4 100644 --- a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx @@ -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", }, ]; diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index dc49a8c331..b7ad23eafe 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -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("editor"); const [processorsOpen, setProcessorsOpen] = useState(false); const [interactive, setInteractive] = useState(true); + const [reactFlowInstance, setReactFlowInstance] = useState< + ReactFlowInstance, 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 (
@@ -368,7 +392,8 @@ export function RecipeStudioPage({ />
{activeView === "editor" ? ( - , Edge> + onInit={setReactFlowInstance} nodes={displayGraph.nodes} edges={displayGraph.edges} nodeTypes={NODE_TYPES} diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts index e6d0f66b2c..ca947998ba 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -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((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((set, get) => ({ activeConfigId: null, dialogOpen: false, sheetView: "root", + fitViewTick: state.fitViewTick + 1, })), setAuxNodePosition: (id, position) => set((state) => {