MAESTRO: Implement PromptEditor component with Jinja2 syntax highlighting, variable sidebar, and preview
Built standalone PromptEditor with transparent-textarea overlay for syntax highlighting of Jinja2 expressions, statements, and comments. Includes clickable variable sidebar for insertion and preview panel with sample data substitution. Integrated into ExperimentPage PipelineStageCard. 27 tests added.
This commit is contained in:
parent
405bbf8206
commit
f2e6baa56f
5 changed files with 562 additions and 56 deletions
|
|
@ -14,9 +14,11 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
|
|||
- [x] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments.
|
||||
<!-- Implemented in ProjectsPage.tsx. Shows loading/error/empty states. Card grid with name, description, last activity timestamp, experiment & best score indicators. "New Project" button opens creation modal with name + description fields, validation, and error handling. Cards navigate to /experiments/:id on click. 12 tests added. -->
|
||||
|
||||
- [ ] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep.
|
||||
- [x] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep.
|
||||
<!-- Implemented in ExperimentPage.tsx (existing file convention). All six sections: BasicInfoSection (name + description), SampleDataSection (text/JSON/file upload modes with JSON validation), PipelineSection (add/remove stages, prompt template editor with variable hints and preview, model selector from endpoints), ScoringSection (5 scorers with enable checkboxes and weight sliders), ParameterSpaceSection (add/remove params, fixed/range/options types), ActionButtons (Save Draft, Run Single, Start Sweep). Supports new + edit modes via route param. Loads endpoints for model selectors. 20 tests added. App.test.tsx updated for new page behavior. -->
|
||||
|
||||
- [ ] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data.
|
||||
- [x] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data.
|
||||
<!-- Implemented PromptEditor with transparent-textarea-over-highlighted-backdrop approach for Jinja2 syntax highlighting ({{ expressions }}, {% statements %}, {# comments #}). Variable sidebar with clickable insert buttons. Preview panel renders template with sample data substitution. Integrated into ExperimentPage's PipelineStageCard, replacing the old inline textarea. 27 tests added, existing ExperimentPage tests updated. -->
|
||||
|
||||
- [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error).
|
||||
|
||||
|
|
|
|||
270
frontend/src/components/PromptEditor.test.tsx
Normal file
270
frontend/src/components/PromptEditor.test.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import PromptEditor, { tokenize, renderPreview } from "./PromptEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit: tokenize()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tokenize", () => {
|
||||
it("returns single text token for plain text", () => {
|
||||
const tokens = tokenize("Hello world");
|
||||
expect(tokens).toEqual([{ type: "text", value: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("tokenizes {{ expression }} blocks", () => {
|
||||
const tokens = tokenize("Hello {{ name }}!");
|
||||
expect(tokens).toEqual([
|
||||
{ type: "text", value: "Hello " },
|
||||
{ type: "expression", value: "{{ name }}" },
|
||||
{ type: "text", value: "!" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tokenizes {% statement %} blocks", () => {
|
||||
const tokens = tokenize("{% if x %}yes{% endif %}");
|
||||
expect(tokens).toEqual([
|
||||
{ type: "statement", value: "{% if x %}" },
|
||||
{ type: "text", value: "yes" },
|
||||
{ type: "statement", value: "{% endif %}" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tokenizes {# comment #} blocks", () => {
|
||||
const tokens = tokenize("{# this is a comment #}");
|
||||
expect(tokens).toEqual([
|
||||
{ type: "comment", value: "{# this is a comment #}" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mixed delimiters", () => {
|
||||
const tokens = tokenize("{{ a }}{# b #}{% c %}");
|
||||
expect(tokens).toHaveLength(3);
|
||||
expect(tokens[0].type).toBe("expression");
|
||||
expect(tokens[1].type).toBe("comment");
|
||||
expect(tokens[2].type).toBe("statement");
|
||||
});
|
||||
|
||||
it("treats unclosed delimiter as text", () => {
|
||||
const tokens = tokenize("Hello {{ name");
|
||||
expect(tokens).toEqual([
|
||||
{ type: "text", value: "Hello " },
|
||||
{ type: "text", value: "{{ name" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(tokenize("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles adjacent delimiters", () => {
|
||||
const tokens = tokenize("{{ a }}{{ b }}");
|
||||
expect(tokens).toEqual([
|
||||
{ type: "expression", value: "{{ a }}" },
|
||||
{ type: "expression", value: "{{ b }}" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit: renderPreview()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderPreview", () => {
|
||||
it("replaces known variables with sample data", () => {
|
||||
expect(
|
||||
renderPreview("Hello {{ name }}, welcome!", { name: "Alice" }),
|
||||
).toBe("Hello Alice, welcome!");
|
||||
});
|
||||
|
||||
it("replaces unknown variables with [varName] placeholder", () => {
|
||||
expect(renderPreview("Hello {{ name }}", {})).toBe("Hello [name]");
|
||||
});
|
||||
|
||||
it("handles multiple variables", () => {
|
||||
expect(
|
||||
renderPreview("{{ greeting }} {{ name }}", {
|
||||
greeting: "Hi",
|
||||
name: "Bob",
|
||||
}),
|
||||
).toBe("Hi Bob");
|
||||
});
|
||||
|
||||
it("handles templates with no variables", () => {
|
||||
expect(renderPreview("No variables here", {})).toBe("No variables here");
|
||||
});
|
||||
|
||||
it("handles whitespace variations in delimiters", () => {
|
||||
expect(renderPreview("{{name}}", { name: "X" })).toBe("X");
|
||||
expect(renderPreview("{{ name }}", { name: "Y" })).toBe("Y");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PromptEditor", () => {
|
||||
const defaultProps = {
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
variables: ["input_data", "previous_stage_output", "stage_index"],
|
||||
sampleData: { input_data: "Hello world", previous_stage_output: "Summary" },
|
||||
"data-testid": "editor",
|
||||
};
|
||||
|
||||
it("renders the textarea", () => {
|
||||
render(<PromptEditor {...defaultProps} />);
|
||||
expect(screen.getByTestId("editor-textarea")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with provided value", () => {
|
||||
render(<PromptEditor {...defaultProps} value="Hello {{ input_data }}" />);
|
||||
expect(screen.getByTestId("editor-textarea")).toHaveValue(
|
||||
"Hello {{ input_data }}",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onChange when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<PromptEditor {...defaultProps} onChange={onChange} />);
|
||||
const textarea = screen.getByTestId("editor-textarea");
|
||||
await userEvent.type(textarea, "H");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders variable sidebar when variables are provided", () => {
|
||||
render(<PromptEditor {...defaultProps} />);
|
||||
const sidebar = screen.getByTestId("editor-variables");
|
||||
expect(sidebar).toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("{{ input_data }}")).toBeInTheDocument();
|
||||
expect(
|
||||
within(sidebar).getByText("{{ previous_stage_output }}"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("{{ stage_index }}")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render sidebar when no variables provided", () => {
|
||||
render(<PromptEditor {...defaultProps} variables={[]} />);
|
||||
expect(screen.queryByTestId("editor-variables")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("inserts variable on sidebar button click", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor {...defaultProps} value="" onChange={onChange} />,
|
||||
);
|
||||
const btn = screen.getByText("{{ input_data }}");
|
||||
await userEvent.click(btn);
|
||||
expect(onChange).toHaveBeenCalledWith("{{ input_data }}");
|
||||
});
|
||||
|
||||
it("shows preview button", () => {
|
||||
render(<PromptEditor {...defaultProps} />);
|
||||
expect(screen.getByTestId("editor-preview-btn")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent(
|
||||
"Preview",
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles preview panel on button click", async () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
value="Test {{ input_data }}"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Preview panel hidden initially
|
||||
expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument();
|
||||
|
||||
// Click to show
|
||||
await userEvent.click(screen.getByTestId("editor-preview-btn"));
|
||||
expect(screen.getByTestId("editor-preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
|
||||
"Test Hello world",
|
||||
);
|
||||
|
||||
// Button text changes
|
||||
expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent(
|
||||
"Hide Preview",
|
||||
);
|
||||
|
||||
// Click to hide
|
||||
await userEvent.click(screen.getByTestId("editor-preview-btn"));
|
||||
expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders preview with sample data substitution", async () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
value="Input: {{ input_data }}, Prev: {{ previous_stage_output }}"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("editor-preview-btn"));
|
||||
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
|
||||
"Input: Hello world, Prev: Summary",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows placeholder for unknown variables in preview", async () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
value="Hello {{ unknown_var }}"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("editor-preview-btn"));
|
||||
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
|
||||
"Hello [unknown_var]",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows (empty template) when value is empty in preview", async () => {
|
||||
render(<PromptEditor {...defaultProps} value="" />);
|
||||
await userEvent.click(screen.getByTestId("editor-preview-btn"));
|
||||
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
|
||||
"(empty template)",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders syntax highlighting spans in backdrop", () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
value="Hello {{ input_data }}!"
|
||||
/>,
|
||||
);
|
||||
const highlight = screen.getByTestId("editor-highlight");
|
||||
const spans = highlight.querySelectorAll("span");
|
||||
// At least 3 spans: "Hello ", "{{ input_data }}", "!"
|
||||
expect(spans.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("applies expression color class to {{ }} tokens", () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
value="{{ input_data }}"
|
||||
/>,
|
||||
);
|
||||
const highlight = screen.getByTestId("editor-highlight");
|
||||
const exprSpan = highlight.querySelector("span");
|
||||
expect(exprSpan?.className).toContain("text-amber");
|
||||
});
|
||||
|
||||
it("uses custom placeholder", () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
{...defaultProps}
|
||||
placeholder="Custom placeholder text"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("editor-textarea")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Custom placeholder text",
|
||||
);
|
||||
});
|
||||
});
|
||||
272
frontend/src/components/PromptEditor.tsx
Normal file
272
frontend/src/components/PromptEditor.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useState, useRef, useCallback, useMemo } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptEditorProps {
|
||||
/** Current template value */
|
||||
value: string;
|
||||
/** Called when the template text changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Available template variables to show in sidebar */
|
||||
variables?: string[];
|
||||
/** Sample data for preview rendering */
|
||||
sampleData?: Record<string, string>;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Number of visible rows */
|
||||
rows?: number;
|
||||
/** Test id */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jinja2 syntax highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Segment types produced by the tokenizer. */
|
||||
type TokenType = "text" | "expression" | "statement" | "comment";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a Jinja2 template into segments for syntax highlighting.
|
||||
* Handles {{ expr }}, {% stmt %}, and {# comment #} blocks.
|
||||
*/
|
||||
export function tokenize(template: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let pos = 0;
|
||||
|
||||
const patterns: { open: string; close: string; type: TokenType }[] = [
|
||||
{ open: "{{", close: "}}", type: "expression" },
|
||||
{ open: "{%", close: "%}", type: "statement" },
|
||||
{ open: "{#", close: "#}", type: "comment" },
|
||||
];
|
||||
|
||||
while (pos < template.length) {
|
||||
// Find the nearest opening delimiter
|
||||
let nearestIdx = template.length;
|
||||
let matched: (typeof patterns)[0] | null = null;
|
||||
|
||||
for (const pat of patterns) {
|
||||
const idx = template.indexOf(pat.open, pos);
|
||||
if (idx !== -1 && idx < nearestIdx) {
|
||||
nearestIdx = idx;
|
||||
matched = pat;
|
||||
}
|
||||
}
|
||||
|
||||
// Push any leading plain text
|
||||
if (nearestIdx > pos) {
|
||||
tokens.push({ type: "text", value: template.slice(pos, nearestIdx) });
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// No more delimiters — done
|
||||
break;
|
||||
}
|
||||
|
||||
// Find closing delimiter
|
||||
const closeIdx = template.indexOf(
|
||||
matched.close,
|
||||
nearestIdx + matched.open.length,
|
||||
);
|
||||
|
||||
if (closeIdx === -1) {
|
||||
// Unclosed delimiter — treat rest as text
|
||||
tokens.push({ type: "text", value: template.slice(nearestIdx) });
|
||||
pos = template.length;
|
||||
break;
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
type: matched.type,
|
||||
value: template.slice(nearestIdx, closeIdx + matched.close.length),
|
||||
});
|
||||
pos = closeIdx + matched.close.length;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Map token type to Tailwind colour classes. */
|
||||
function tokenClass(type: TokenType): string {
|
||||
switch (type) {
|
||||
case "expression":
|
||||
return "text-amber-600 dark:text-amber-400";
|
||||
case "statement":
|
||||
return "text-violet-600 dark:text-violet-400";
|
||||
case "comment":
|
||||
return "text-slate-400 dark:text-slate-500 italic";
|
||||
default:
|
||||
return "text-slate-800 dark:text-slate-200";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a Jinja2-style template by replacing {{ var }} with sample values.
|
||||
* Only handles simple variable substitution — not full Jinja2.
|
||||
*/
|
||||
export function renderPreview(
|
||||
template: string,
|
||||
sampleData: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(
|
||||
/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g,
|
||||
(_match, varName: string) => {
|
||||
return varName in sampleData ? sampleData[varName] : `[${varName}]`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = [],
|
||||
sampleData = {},
|
||||
placeholder = "Enter your prompt template using {{ variable }} syntax...",
|
||||
rows = 6,
|
||||
"data-testid": testId,
|
||||
}: PromptEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Tokenize the current value for the highlight overlay
|
||||
const tokens = useMemo(() => tokenize(value), [value]);
|
||||
|
||||
// Sync scroll between textarea and highlight layer
|
||||
const handleScroll = useCallback(() => {
|
||||
if (textareaRef.current && highlightRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Insert a variable at cursor position
|
||||
function insertVariable(varName: string) {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
|
||||
const insertion = `{{ ${varName} }}`;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const newValue =
|
||||
value.slice(0, start) + insertion + value.slice(end);
|
||||
onChange(newValue);
|
||||
|
||||
// Restore cursor after insertion
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const newCursor = start + insertion.length;
|
||||
ta.setSelectionRange(newCursor, newCursor);
|
||||
});
|
||||
}
|
||||
|
||||
const preview = useMemo(
|
||||
() => renderPreview(value, sampleData),
|
||||
[value, sampleData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className="space-y-2">
|
||||
<div className="flex gap-3">
|
||||
{/* Editor area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="relative rounded-lg border border-slate-300 dark:border-slate-600 overflow-hidden">
|
||||
{/* Highlighted backdrop */}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 overflow-hidden whitespace-pre-wrap break-words px-3 py-2 text-sm font-mono leading-relaxed pointer-events-none"
|
||||
data-testid={testId ? `${testId}-highlight` : undefined}
|
||||
>
|
||||
{tokens.map((tok, i) => (
|
||||
<span key={i} className={tokenClass(tok.type)}>
|
||||
{tok.value}
|
||||
</span>
|
||||
))}
|
||||
{/* Ensure the highlight div is at least as tall as rows */}
|
||||
{value === "" && (
|
||||
<span className="text-transparent">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transparent textarea on top */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="relative w-full bg-transparent px-3 py-2 text-sm font-mono leading-relaxed text-transparent caret-slate-900 dark:caret-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 resize-none z-10"
|
||||
style={{ caretColor: "inherit" }}
|
||||
data-testid={testId ? `${testId}-textarea` : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variable sidebar */}
|
||||
{variables.length > 0 && (
|
||||
<div
|
||||
className="w-44 shrink-0 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 p-3"
|
||||
data-testid={testId ? `${testId}-variables` : undefined}
|
||||
>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
Variables
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{variables.map((v) => (
|
||||
<li key={v}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertVariable(v)}
|
||||
className="w-full rounded px-2 py-1 text-left text-xs font-mono text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition truncate"
|
||||
title={`Insert {{ ${v} }}`}
|
||||
>
|
||||
{`{{ ${v} }}`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview toggle and panel */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 transition"
|
||||
data-testid={testId ? `${testId}-preview-btn` : undefined}
|
||||
>
|
||||
{showPreview ? "Hide Preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
{showPreview && (
|
||||
<pre
|
||||
className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 p-3 text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap break-words"
|
||||
data-testid={testId ? `${testId}-preview` : undefined}
|
||||
>
|
||||
{preview || "(empty template)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -275,14 +275,12 @@ describe("ExperimentPage", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Add Stage" }));
|
||||
|
||||
const stage = screen.getByTestId("pipeline-stage-0");
|
||||
const templateInput = within(stage).getByPlaceholderText(
|
||||
/Enter your prompt template/,
|
||||
);
|
||||
const templateInput = within(stage).getByTestId("stage-editor-0-textarea");
|
||||
await user.type(templateInput, "Summarize: {{{{ input_data }}}}");
|
||||
|
||||
await user.click(within(stage).getByText("Preview"));
|
||||
expect(screen.getByTestId("stage-preview-0")).toHaveTextContent(
|
||||
"Summarize: [input_data]",
|
||||
await user.click(within(stage).getByTestId("stage-editor-0-preview-btn"));
|
||||
expect(screen.getByTestId("stage-editor-0-preview")).toHaveTextContent(
|
||||
"Summarize: (sample input)",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ExperimentUpdate,
|
||||
EndpointResponse,
|
||||
} from "../api/client";
|
||||
import PromptEditor from "../components/PromptEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -301,19 +302,6 @@ function PipelineStageCard({
|
|||
onUpdate: (s: PipelineStage) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
function renderPreview(): string {
|
||||
let result = stage.prompt_template;
|
||||
TEMPLATE_VARIABLES.forEach((v) => {
|
||||
result = result.replace(
|
||||
new RegExp(`\\{\\{\\s*${v}\\s*\\}\\}`, "g"),
|
||||
`[${v}]`,
|
||||
);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`pipeline-stage-${index}`}
|
||||
|
|
@ -340,26 +328,19 @@ function PipelineStageCard({
|
|||
<label className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||
Prompt Template
|
||||
</label>
|
||||
<textarea
|
||||
<PromptEditor
|
||||
value={stage.prompt_template}
|
||||
onChange={(e) =>
|
||||
onUpdate({ ...stage, prompt_template: e.target.value })
|
||||
}
|
||||
onChange={(v) => onUpdate({ ...stage, prompt_template: v })}
|
||||
variables={TEMPLATE_VARIABLES}
|
||||
sampleData={{
|
||||
input_data: "(sample input)",
|
||||
previous_stage_output: "(previous output)",
|
||||
stage_index: String(index),
|
||||
experiment_name: "(experiment)",
|
||||
}}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 px-3 py-2 text-sm font-mono text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition resize-none"
|
||||
placeholder="Enter your prompt template using {{ variable }} syntax..."
|
||||
data-testid={`stage-editor-${index}`}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">Variables:</span>
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="rounded bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 text-xs font-mono text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{`{{ ${v} }}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
|
|
@ -388,24 +369,7 @@ function PipelineStageCard({
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Preview button */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 transition"
|
||||
>
|
||||
{showPreview ? "Hide Preview" : "Preview"}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<pre
|
||||
data-testid={`stage-preview-${index}`}
|
||||
className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-900 p-3 text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap"
|
||||
>
|
||||
{renderPreview() || "(empty template)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{/* Preview is now integrated in PromptEditor above */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue