diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml
index 0dcebf87..376cbc6a 100644
--- a/.github/workflows/docker-ci.yml
+++ b/.github/workflows/docker-ci.yml
@@ -40,7 +40,7 @@ jobs:
docetl
# Wait for container to start up
- sleep 120
+ sleep 240
# Check if container is still running
if [ "$(docker ps -q -f name=docetl-test)" ]; then
diff --git a/docs/playground/index.md b/docs/playground/index.md
index 89f2eaee..c4192e4a 100644
--- a/docs/playground/index.md
+++ b/docs/playground/index.md
@@ -1,12 +1,15 @@
# Playground
-The playground is a web app that allows you to interactively build DocETL pipelines. The playground is built with Next.js and TypeScript. We use the `docetl` Python package (built from this source code) to process the data with a FastAPI server. We stream out the logs from the FastAPI server to the frontend so you can see the pipeline execution progress and outputs in real time.
+The DocETL Playground is an integrated development environment (IDE) for building and testing document processing pipelines. Built with Next.js and TypeScript, it provides a real-time interface to develop, test and refine your pipelines through a FastAPI backend.
-## Why an interactive playground?
+## Why a Playground? 🤔
-Often, unstructured data analysis tasks are fuzzy and require iteration. You might start with a prompt, see the outputs for a sample, then realize you need to tweak the prompt or change the definition of the task you want the LLM to do. Or, you might want to create a complex pipeline that involves multiple operations, but you are unsure of what prompts you want to use for each step, so you want to build your pipeline one operation at a time.
+This **interactive playground** streamlines development from prototype to production! **Our (in-progress) user studies show 100% of developers** found building pipelines significantly faster and easier with our playground vs traditional approaches.
-The playground allows you to do just that.
+Building complex LLM pipelines for your data often requires experimentation and iteration. The IDE lets you:
+- 🚀 Test prompts and see results instantly
+- ✨ Refine operations based on sample outputs
+- 🔄 Build complex pipelines step-by-step
## Installation
@@ -18,9 +21,9 @@ The easiest way to get started is using Docker:
1. Create the required environment files:
-Create `.env` in the root directory:
+Create `.env` in the root directory (for the FastAPI backend):
```bash
-OPENAI_API_KEY=your_api_key_here
+OPENAI_API_KEY=your_api_key_here # Or your LLM provider's API key
BACKEND_ALLOW_ORIGINS=
BACKEND_HOST=localhost
BACKEND_PORT=8000
@@ -29,11 +32,11 @@ FRONTEND_HOST=localhost
FRONTEND_PORT=3000
```
-Create `.env.local` in the `website` directory:
+Create `.env.local` in the `website` directory (for the frontend) **note that this must be in the `website` directory**:
```bash
-OPENAI_API_KEY=sk-xxx
-OPENAI_API_BASE=https://api.openai.com/v1
-MODEL_NAME=gpt-4o-mini
+OPENAI_API_KEY=sk-xxx # For the AI assistant in the interface
+OPENAI_API_BASE=https://api.openai.com/v1 # For the AI assistant in the interface
+MODEL_NAME=gpt-4o-mini # For the AI assistant in the interface
NEXT_PUBLIC_BACKEND_HOST=localhost
NEXT_PUBLIC_BACKEND_PORT=8000
diff --git a/website/package-lock.json b/website/package-lock.json
index e4240d57..b56e3f00 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -37,6 +37,7 @@
"@tanstack/react-query": "^5.59.15",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
+ "@types/diff": "^6.0.0",
"@types/mime-types": "^2.1.4",
"@types/react-resizable": "^3.0.8",
"ai": "^3.4.29",
@@ -47,6 +48,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"css-loader": "^7.1.2",
+ "diff": "^7.0.0",
"framer-motion": "^11.5.4",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
@@ -3888,6 +3890,12 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/diff": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz",
+ "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==",
+ "license": "MIT"
+ },
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
@@ -6462,6 +6470,15 @@
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
+ "node_modules/diff": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
+ "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
diff --git a/website/package.json b/website/package.json
index 26152b6f..a0c4dc69 100644
--- a/website/package.json
+++ b/website/package.json
@@ -38,6 +38,7 @@
"@tanstack/react-query": "^5.59.15",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
+ "@types/diff": "^6.0.0",
"@types/mime-types": "^2.1.4",
"@types/react-resizable": "^3.0.8",
"ai": "^3.4.29",
@@ -48,6 +49,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"css-loader": "^7.1.2",
+ "diff": "^7.0.0",
"framer-motion": "^11.5.4",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
diff --git a/website/src/app/api/chat/route.ts b/website/src/app/api/chat/route.ts
index 6447b7b0..cac7e49b 100644
--- a/website/src/app/api/chat/route.ts
+++ b/website/src/app/api/chat/route.ts
@@ -6,7 +6,7 @@ export const maxDuration = 60;
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_API_BASE,
- compatibility: 'strict', // strict mode, enable when using the OpenAI API
+ compatibility: "strict", // strict mode, enable when using the OpenAI API
});
export async function POST(req: Request) {
diff --git a/website/src/app/globals.css b/website/src/app/globals.css
index 6418a0e3..09cd89e5 100644
--- a/website/src/app/globals.css
+++ b/website/src/app/globals.css
@@ -13,11 +13,11 @@
@layer base {
:root {
- --background: 211 100% 95%;
+ --background: 211 100% 98%;
--foreground: 211 5% 0%;
- --card: 211 50% 90%;
+ --card: 211 50% 95%;
--card-foreground: 211 5% 10%;
- --popover: 211 100% 95%;
+ --popover: 211 100% 98%;
--popover-foreground: 211 100% 0%;
--primary: 211 100% 50%;
--primary-foreground: 0 0% 100%;
diff --git a/website/src/app/page.tsx b/website/src/app/page.tsx
index 71d1c0d6..576ca3de 100644
--- a/website/src/app/page.tsx
+++ b/website/src/app/page.tsx
@@ -27,16 +27,16 @@ export default function Home() {
- New NotebookLM Podcast! {" "}
+ New IDE Released! {" "}
- Sept 28, 2024
+ Dec 2, 2024
- . Thanks to Shabie from our Discord community!
+ ! Try out our new web-based IDE.
New blog post! {" "}
diff --git a/website/src/app/playground/page.tsx b/website/src/app/playground/page.tsx
index 69da2708..5d73b37c 100644
--- a/website/src/app/playground/page.tsx
+++ b/website/src/app/playground/page.tsx
@@ -256,7 +256,7 @@ const CodeEditorPipelineApp: React.FC = () => {
-
+
File
@@ -316,7 +316,7 @@ const CodeEditorPipelineApp: React.FC = () => {
{
saveProgress();
toast({
@@ -325,7 +325,7 @@ const CodeEditorPipelineApp: React.FC = () => {
duration: 3000,
});
}}
- className={`relative ${
+ className={`relative h-8 px-2 ${
unsavedChanges ? "border-orange-500" : ""
}`}
>
diff --git a/website/src/app/types.ts b/website/src/app/types.ts
index f3873789..9d86ade0 100644
--- a/website/src/app/types.ts
+++ b/website/src/app/types.ts
@@ -52,25 +52,26 @@ export interface SchemaItem {
export interface UserNote {
id: string;
note: string;
+ metadata: {
+ columnId?: string;
+ rowIndex?: number;
+ mainColumnValue?: unknown;
+ rowContent?: Record;
+ operationName?: string;
+ };
}
export interface Bookmark {
id: string;
- text: string;
- source: string;
color: string;
notes: UserNote[];
}
export interface BookmarkContextType {
bookmarks: Bookmark[];
- addBookmark: (
- text: string,
- source: string,
- color: string,
- notes: UserNote[]
- ) => void;
+ addBookmark: (color: string, notes: UserNote[]) => void;
removeBookmark: (id: string) => void;
+ getNotesForRowAndColumn: (rowIndex: number, columnId: string) => UserNote[];
}
export interface OutputType {
diff --git a/website/src/components/BookmarkableText.tsx b/website/src/components/BookmarkableText.tsx
deleted file mode 100644
index e87c8e93..00000000
--- a/website/src/components/BookmarkableText.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import React, { useState, useRef, useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Bookmark, BookmarkPlus, X } from "lucide-react";
-import { useBookmarkContext } from "@/contexts/BookmarkContext";
-import { Textarea } from "@/components/ui/textarea";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { UserNote } from "@/app/types";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
-} from "@/components/ui/form";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { useToast } from "@/hooks/use-toast";
-
-interface BookmarkableTextProps {
- children: React.ReactNode;
- source: string;
- className?: string;
-}
-
-const formSchema = z.object({
- editedText: z.string().min(1, "Edited text is required"),
- color: z.string(),
- note: z.string(),
-});
-
-const BookmarkableText: React.FC = ({
- children,
- source,
- className = "overflow-y-auto",
-}) => {
- const [buttonPosition, setButtonPosition] = useState({ x: 0, y: 0 });
- const [showButton, setShowButton] = useState(false);
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
- const textRef = useRef(null);
- const buttonRef = useRef(null);
- const popoverRef = useRef(null);
- const { addBookmark } = useBookmarkContext();
- const { toast } = useToast();
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- editedText: "",
- color: "#FF0000",
- note: "",
- },
- });
-
- const handleBookmark = (values: z.infer) => {
- const userNotes: UserNote[] = [{ id: "default", note: values.note }];
- addBookmark(values.editedText, source, values.color, userNotes);
- setShowButton(false);
- setIsPopoverOpen(false);
- toast({
- title: "Bookmark Added",
- description: "Your bookmark has been successfully added.",
- });
- };
-
- // Listen for selection changes
- useEffect(() => {
- const handleSelectionChange = () => {
- if (isPopoverOpen) return;
-
- const selection = window.getSelection();
- if (!selection || selection.isCollapsed || !selection.toString().trim()) {
- setShowButton(false);
- }
- };
-
- document.addEventListener("selectionchange", handleSelectionChange);
- document.addEventListener("mousedown", handleSelectionChange);
-
- return () => {
- document.removeEventListener("selectionchange", handleSelectionChange);
- document.removeEventListener("mousedown", handleSelectionChange);
- };
- }, [isPopoverOpen]);
-
- const handleMultiElementSelection = (
- event: React.MouseEvent | React.TouchEvent
- ) => {
- if (isPopoverOpen) return;
-
- const selection = window.getSelection();
- const text = selection?.toString().trim();
-
- if (!selection || !text) {
- setShowButton(false);
- return;
- }
-
- const range = selection.getRangeAt(0);
- const rect = range.getBoundingClientRect();
-
- form.setValue("editedText", text);
- setButtonPosition({
- x: rect.left + rect.width / 2,
- y: rect.top,
- });
- setShowButton(true);
- };
-
- const handlePopoverOpenChange = (open: boolean) => {
- setIsPopoverOpen(open);
- if (!open) {
- const selection = window.getSelection();
- if (!selection || selection.isCollapsed) {
- setShowButton(false);
- }
- }
- };
-
- const handleClosePopover = () => {
- setShowButton(false);
- setIsPopoverOpen(false);
- };
-
- return (
-
- {children}
- {showButton && (
-
-
- {
- e.stopPropagation();
- setIsPopoverOpen(true);
- }}
- >
-
-
-
- {
- if (!buttonRef.current?.contains(e.target as Node)) {
- e.preventDefault();
- }
- }}
- side="top"
- align="start"
- sideOffset={5}
- >
-
-
-
-
Edit Bookmark
-
-
-
-
-
-
-
-
-
- )}
-
- );
-};
-
-export default BookmarkableText;
diff --git a/website/src/components/BookmarksPanel.tsx b/website/src/components/BookmarksPanel.tsx
index d6c7992b..4c5e63c1 100644
--- a/website/src/components/BookmarksPanel.tsx
+++ b/website/src/components/BookmarksPanel.tsx
@@ -8,6 +8,8 @@ import {
Filter,
Trash2,
X,
+ MessageSquare,
+ Maximize2,
} from "lucide-react";
import {
Select,
@@ -22,23 +24,26 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
+import { UserNote } from "@/app/types";
const BookmarksPanel: React.FC = () => {
const { bookmarks, removeBookmark } = useBookmarkContext();
const [searchTerm, setSearchTerm] = useState("");
const [expandedBookmarkId, setExpandedBookmarkId] = useState(
- null,
+ null
);
const [selectedColor, setSelectedColor] = useState("all");
const uniqueColors = Array.from(
- new Set(bookmarks.map((bookmark) => bookmark.color)),
+ new Set(bookmarks.map((bookmark) => bookmark.color))
);
const filteredBookmarks = bookmarks.filter(
(bookmark) =>
- bookmark.text.toLowerCase().includes(searchTerm.toLowerCase()) &&
- (selectedColor === "all" || bookmark.color === selectedColor),
+ bookmark.notes.some((note) =>
+ note.note.toLowerCase().includes(searchTerm.toLowerCase())
+ ) &&
+ (selectedColor === "all" || bookmark.color === selectedColor)
);
const toggleBookmarkExpansion = (id: string) => {
@@ -59,12 +64,36 @@ const BookmarksPanel: React.FC = () => {
setSelectedColor("all");
};
+ const renderNoteContent = (note: UserNote) => {
+ return (
+
+ {note.metadata?.columnId && (
+
+
+ Column: {note.metadata.columnId}
+ {note.metadata.rowIndex !== undefined && (
+ • Row: {note.metadata.rowIndex}
+ )}
+
+ )}
+ {note.metadata?.rowContent && (
+
+
Row Context:
+
+ {JSON.stringify(note.metadata.rowContent, null, 2)}
+
+
+ )}
+
+ );
+ };
+
return (
- Notes
+ Notes & Feedback
{
Clear All
+
+ Tip:
+
+ Click{" "}
+ in
+ any output column to leave feedback
+
+
{
{selectedColor !== "all" && (
- 1
+ {1}
)}
- setSelectedColor(value as string)}
- >
-
-
-
-
- All colors
- {uniqueColors.map((color) => (
-
-
-
- ))}
-
-
+
+
setSelectedColor(value as string)}
+ >
+
+
+
+
+ All colors
+ {uniqueColors.map((color) => (
+
+
+
+ ))}
+
+
+
{filteredBookmarks.map((bookmark) => (
-
+
toggleBookmarkExpansion(bookmark.id)}
>
-
- {bookmark.text}
-
- {expandedBookmarkId === bookmark.id ? (
-
- ) : (
-
- )}
+
+
+ "{bookmark.notes[0]?.note || "No notes"}"
+
+
+
+ {expandedBookmarkId === bookmark.id ? (
+
+ ) : (
+
+ )}
+
{expandedBookmarkId === bookmark.id && (
-
-
Source: {bookmark.source}
+
{bookmark.notes.map((note, index) => (
-
- Note {index + 1}: {note.note}
-
+
+ {renderNoteContent(note)}
+
))}
;
+ currentOperation: string;
+}
+
+const ObservabilityIndicator = React.memo(
+ ({ row, currentOperation }: ObservabilityIndicatorProps) => {
+ const observabilityEntries = Object.entries(row).filter(
+ ([key]) => key === `_observability_${currentOperation}`
+ );
+
+ if (observabilityEntries.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+ LLM Call(s) for {currentOperation}
+
+
+ {observabilityEntries.map(([key, value]) => (
+
+
+ {typeof value === "object"
+ ? JSON.stringify(value, null, 2)
+ : String(value)}
+
+
+ ))}
+
+
+
+
+ );
+ }
+);
+ObservabilityIndicator.displayName = "ObservabilityIndicator";
+
+export interface ColumnDialogProps> {
+ isOpen: boolean;
+ onClose: () => void;
+ columnId: string;
+ columnHeader: string;
+ data: T[];
+ currentIndex: number;
+ onNavigate: (direction: "prev" | "next") => void;
+ onJumpToRow: (index: number) => void;
+ currentOperation: string;
+}
+
+export function ColumnDialog>({
+ isOpen,
+ onClose,
+ columnId,
+ columnHeader,
+ data,
+ currentIndex,
+ onNavigate,
+ onJumpToRow,
+ currentOperation,
+}: ColumnDialogProps) {
+ const [splitView, setSplitView] = useState(false);
+ const [compareIndex, setCompareIndex] = useState(null);
+ const [expandedFields, setExpandedFields] = useState([]);
+ const [isAllExpanded, setIsAllExpanded] = useState(false);
+ const [feedbackColor, setFeedbackColor] = useState("#FF0000");
+ const [showPreviousNotes, setShowPreviousNotes] = useState(false);
+
+ const currentRow = data[currentIndex];
+ const compareRow = compareIndex !== null ? data[compareIndex] : null;
+ const currentValue = currentRow[columnId];
+ const compareValue = compareRow?.[columnId];
+
+ const otherFields = Object.entries(currentRow)
+ .filter(([key]) => key !== columnId && !key.startsWith("_"))
+ .sort((a, b) => a[0].localeCompare(b[0]));
+
+ const toggleField = (fieldKey: string) => {
+ setExpandedFields((prev) =>
+ prev.includes(fieldKey)
+ ? prev.filter((k) => k !== fieldKey)
+ : [...prev, fieldKey]
+ );
+ };
+
+ const toggleAllFields = () => {
+ if (isAllExpanded) {
+ setExpandedFields([]);
+ } else {
+ setExpandedFields(otherFields.map(([key]) => key));
+ }
+ setIsAllExpanded(!isAllExpanded);
+ };
+
+ const renderContent = (value: unknown) => {
+ if (value === null || value === undefined) {
+ return No value ;
+ }
+
+ if (typeof value === "object") {
+ return (
+
+ {(searchTerm) => (searchTerm ? null : )}
+
+ );
+ }
+
+ if (typeof value === "string") {
+ return ;
+ }
+
+ return String(value);
+ };
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLInputElement
+ ) {
+ return; // Don't handle shortcuts when typing in inputs
+ }
+
+ if (e.key === "ArrowLeft") {
+ onNavigate("prev");
+ } else if (e.key === "ArrowRight") {
+ onNavigate("next");
+ }
+ },
+ [onNavigate]
+ );
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [handleKeyDown]);
+
+ const renderRowContent = (row: T | null, value: unknown) => {
+ if (!row) return null;
+ const { addBookmark, getNotesForRowAndColumn } = useBookmarkContext();
+
+ const handleSubmitFeedback = (feedbackText: string) => {
+ if (!feedbackText.trim()) return;
+
+ const filteredRowContent = Object.fromEntries(
+ Object.entries(row).filter(([key]) => !key.startsWith("_observability"))
+ );
+
+ const feedback: UserNote[] = [
+ {
+ id: Date.now().toString(),
+ note: feedbackText,
+ metadata: {
+ columnId,
+ rowIndex: currentIndex,
+ mainColumnValue: row[columnId],
+ rowContent: filteredRowContent,
+ operationName: currentOperation,
+ },
+ },
+ ];
+
+ addBookmark(feedbackColor, feedback);
+ };
+
+ const otherFields = Object.entries(row)
+ .filter(([key]) => key !== columnId && !key.startsWith("_"))
+ .sort((a, b) => a[0].localeCompare(b[0]));
+
+ const existingNotes = getNotesForRowAndColumn(currentIndex, columnId);
+
+ return (
+
+
+
+
+
+
+
+ onNavigate("prev")}
+ className="h-full w-8 rounded-none hover:bg-muted/20 flex items-center justify-center bg-muted/5"
+ disabled={currentIndex === 0}
+ aria-label="Previous example (Left arrow key)"
+ >
+
+
+
+
+
+ Previous example
+
+ Left arrow key
+
+
+
+
+
+
{renderContent(value)}
+
+
+
+
+
+ onNavigate("next")}
+ className="h-full w-8 rounded-none hover:bg-muted/20 flex items-center justify-center bg-muted/5"
+ disabled={currentIndex === data.length - 1}
+ aria-label="Next example (Right arrow key)"
+ >
+
+
+
+
+
+ Next example
+
+ Right arrow key
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other Keys
+
+
+
+ {isAllExpanded ? "Collapse" : "Expand"}
+
+
+
+
+
+ {otherFields.map(([key, value]) => (
+
+
toggleField(key)}
+ className="w-full text-left p-3 hover:bg-muted/30 flex items-center justify-between"
+ >
+
+ {key}
+
+
+
+ {expandedFields.includes(key) && (
+
+ {renderContent(value)}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
Feedback
+
+ Share your thoughts on this output
+
+
+
+ {existingNotes.length > 0 && (
+
+
setShowPreviousNotes(!showPreviousNotes)}
+ className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground mb-2"
+ >
+ Previous Notes ({existingNotes.length})
+
+
+ {showPreviousNotes && (
+
+ {existingNotes.map((note) => (
+
+ "{note.note}"
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {feedbackColor === "#FF0000"
+ ? "Red"
+ : feedbackColor === "#00FF00"
+ ? "Green"
+ : feedbackColor === "#0000FF"
+ ? "Blue"
+ : feedbackColor === "#FFFF00"
+ ? "Yellow"
+ : feedbackColor === "#FF00FF"
+ ? "Magenta"
+ : "Cyan"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const textarea = (e.target as HTMLElement)
+ .closest(".flex-col")
+ ?.querySelector("textarea");
+ if (textarea) {
+ handleSubmitFeedback(textarea.value);
+ textarea.value = "";
+ }
+ }}
+ >
+ Submit Feedback
+
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+ onClose()}>
+
+
+
+
+
{columnHeader}
+
+
+
+
+ Use ← → arrow keys to navigate
+
+ {
+ setSplitView(!splitView);
+ if (!splitView && compareIndex === null) {
+ setCompareIndex(
+ Math.min(currentIndex + 1, data.length - 1)
+ );
+ }
+ }}
+ >
+ {splitView ? "Single View" : "Split View"}
+
+
+
+
+
+
+ {splitView ? (
+
+
+ {renderRowContent(currentRow, currentValue)}
+
+
+
+
+
+
+
+ {
+ setCompareIndex((prev) =>
+ direction === "next"
+ ? Math.min((prev ?? 0) + 1, data.length - 1)
+ : Math.max((prev ?? 0) - 1, 0)
+ );
+ }}
+ onJumpToRow={(index) => setCompareIndex(index)}
+ label="Compare Row"
+ compact={true}
+ />
+
+
+
+ {renderRowContent(compareRow, compareValue)}
+
+
+
+
+ ) : (
+
+ {renderRowContent(currentRow, currentValue)}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/website/src/components/CommandMenu.tsx b/website/src/components/CommandMenu.tsx
deleted file mode 100644
index 1f8789b9..00000000
--- a/website/src/components/CommandMenu.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from "react";
-import { Dialog, DialogContent } from "@/components/ui/dialog";
-import {
- Command,
- CommandInput,
- CommandList,
- CommandEmpty,
- CommandGroup,
- CommandItem,
-} from "@/components/ui/command";
-import { Bookmark, Search } from "lucide-react";
-import { useBookmarkContext } from "@/contexts/BookmarkContext";
-
-interface CommandMenuProps {
- isOpen: boolean;
- onClose: () => void;
- onBookmarkCurrent: () => void;
-}
-
-const CommandMenu: React.FC = ({
- isOpen,
- onClose,
- onBookmarkCurrent,
-}) => {
- const { bookmarks } = useBookmarkContext();
-
- return (
-
-
-
-
-
- No results found.
-
-
-
- Bookmark current selection
-
- {/* Add more actions here */}
-
-
- {bookmarks.map((bookmark) => (
-
- console.log("Navigate to bookmark", bookmark.id)
- }
- >
-
- {bookmark.text.substring(0, 30)}...
-
- ))}
-
-
-
-
-
- );
-};
-
-export default CommandMenu;
diff --git a/website/src/components/DatasetView.tsx b/website/src/components/DatasetView.tsx
index 33659a62..d74e4b78 100644
--- a/website/src/components/DatasetView.tsx
+++ b/website/src/components/DatasetView.tsx
@@ -5,7 +5,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ChevronUp, ChevronDown, Search } from "lucide-react";
-import BookmarkableText from "@/components/BookmarkableText";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader2 } from "lucide-react";
import {
@@ -507,25 +506,21 @@ const DatasetView: React.FC<{ file: File | null }> = ({ file }) => {
: "No matches"}
-
-
- {lines.map((lineContent, index) => (
-
-
- {index + 1}
-
-
-
- {highlightMatches(lineContent, index)}
-
-
+
+ {lines.map((lineContent, index) => (
+
+
+ {index + 1}
+
+
+
+ {highlightMatches(lineContent, index)}
+
- ))}
- {isFetching && (
-
Loading more...
- )}
-
-
+
+ ))}
+ {isFetching &&
Loading more...
}
+
);
};
diff --git a/website/src/components/MarkdownCell.tsx b/website/src/components/MarkdownCell.tsx
new file mode 100644
index 00000000..557041d3
--- /dev/null
+++ b/website/src/components/MarkdownCell.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import ReactMarkdown from "react-markdown";
+
+interface MarkdownCellProps {
+ content: string;
+}
+
+export const MarkdownCell = React.memo(({ content }: MarkdownCellProps) => {
+ return (
+ (
+
+ {children}
+
+ ),
+ h2: ({ children }) => (
+
+ {children}
+
+ ),
+ h3: ({ children }) => (
+
+ {children}
+
+ ),
+ h4: ({ children }) => (
+ {children}
+ ),
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => (
+
+ {children}
+
+ ),
+ li: ({ children }) => (
+ {children}
+ ),
+ code: ({
+ className,
+ children,
+ inline,
+ ...props
+ }: {
+ className?: string;
+ children: React.ReactNode;
+ inline?: boolean;
+ }) => {
+ const match = /language-(\w+)/.exec(className || "");
+ return !inline && match ? (
+
+
+ {children}
+
+
+ ) : (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) => (
+ {children}
+ ),
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {content}
+
+ );
+});
+
+MarkdownCell.displayName = "MarkdownCell";
diff --git a/website/src/components/OperationCard.tsx b/website/src/components/OperationCard.tsx
index 18f3ab29..99e1a612 100644
--- a/website/src/components/OperationCard.tsx
+++ b/website/src/components/OperationCard.tsx
@@ -55,6 +55,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
+import { PromptImprovementDialog } from "@/components/PromptImprovementDialog";
// Separate components
const OperationHeader: React.FC<{
@@ -75,6 +76,7 @@ const OperationHeader: React.FC<{
onAIEdit: (instruction: string) => void;
onToggleExpand: () => void;
onToggleVisibility: () => void;
+ onImprovePrompt: () => void;
}> = React.memo(
({
name,
@@ -94,6 +96,7 @@ const OperationHeader: React.FC<{
onAIEdit,
onToggleExpand,
onToggleVisibility,
+ onImprovePrompt,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState(name);
@@ -197,11 +200,23 @@ const OperationHeader: React.FC<{
Show outputs
+ {llmType === "LLM" && (
+
+
+ Improve prompt
+
+ )}
{/* Centered title */}
@@ -211,12 +226,12 @@ const OperationHeader: React.FC<{
onChange={(e) => setEditedName(e.target.value)}
onBlur={handleEditComplete}
onKeyPress={(e) => e.key === "Enter" && handleEditComplete()}
- className="text-sm font-medium w-1/2 font-mono text-center"
+ className="text-sm font-medium max-w-[200px] font-mono text-center"
autoFocus
/>
) : (
= ({ index }) => {
handleOperationUpdate(updatedOperation);
}, [operation, handleOperationUpdate]);
+ const [showPromptImprovement, setShowPromptImprovement] = useState(false);
+
+ const handlePromptSave = (
+ newPrompt:
+ | string
+ | { comparison_prompt: string; resolution_prompt: string },
+ schemaChanges?: Array<[string, string]>
+ ) => {
+ if (!operation) return;
+
+ let updatedOperation = { ...operation };
+
+ if (operation.type === "resolve") {
+ if (typeof newPrompt === "object") {
+ updatedOperation = {
+ ...updatedOperation,
+ otherKwargs: {
+ ...operation.otherKwargs,
+ comparison_prompt: newPrompt.comparison_prompt,
+ resolution_prompt: newPrompt.resolution_prompt,
+ },
+ };
+ }
+ } else {
+ if (typeof newPrompt === "string") {
+ updatedOperation.prompt = newPrompt;
+ }
+ }
+
+ // Handle schema changes
+ if (schemaChanges?.length && operation.output?.schema) {
+ const updatedSchema = operation.output.schema.map((item) => {
+ const change = schemaChanges.find(([oldKey]) => oldKey === item.key);
+ if (change) {
+ return { ...item, key: change[1] };
+ }
+ return item;
+ });
+
+ updatedOperation.output = {
+ ...operation.output,
+ schema: updatedSchema,
+ };
+ }
+
+ handleOperationUpdate(updatedOperation);
+ toast({
+ title: "Success",
+ description: `Prompt${
+ operation.type === "resolve" ? "s" : ""
+ } and schema updated successfully`,
+ });
+ };
+
if (!operation) {
return ;
}
@@ -905,6 +974,7 @@ export const OperationCard: React.FC<{ index: number }> = ({ index }) => {
onAIEdit={handleAIEdit}
onToggleExpand={() => dispatch({ type: "TOGGLE_EXPAND" })}
onToggleVisibility={handleVisibilityToggle}
+ onImprovePrompt={() => setShowPromptImprovement(true)}
/>
{isExpanded && operation.visibility !== false && (
<>
@@ -946,6 +1016,14 @@ export const OperationCard: React.FC<{ index: number }> = ({ index }) => {
otherKwargs={operation.otherKwargs || {}}
onSettingsSave={handleSettingsSave}
/>
+ {operation.llmType === "LLM" && (
+
+ )}
)}
diff --git a/website/src/components/Output.tsx b/website/src/components/Output.tsx
index 7e5889b8..b2e7609c 100644
--- a/website/src/components/Output.tsx
+++ b/website/src/components/Output.tsx
@@ -2,10 +2,8 @@ import React, { useState, useEffect, useMemo } from "react";
import { ColumnType } from "@/components/ResizableDataTable";
import ResizableDataTable from "@/components/ResizableDataTable";
import { usePipelineContext } from "@/contexts/PipelineContext";
-import { Loader2, Download, ChevronDown, Circle } from "lucide-react";
+import { Loader2, Download, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Progress } from "@/components/ui/progress";
-import BookmarkableText from "@/components/BookmarkableText";
import { Operation, OutputRow } from "@/app/types";
import { Parser } from "json2csv";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -212,8 +210,8 @@ export const Output: React.FC = () => {
if (parsedOutputs.length > 0) {
if ("date" in parsedOutputs[0]) {
parsedOutputs.sort((a, b) => {
- const dateA = (a as any).date;
- const dateB = (b as any).date;
+ const dateA = (a as OutputRow & { date?: string }).date;
+ const dateB = (b as OutputRow & { date?: string }).date;
if (dateA && dateB) {
return new Date(dateB).getTime() - new Date(dateA).getTime();
}
@@ -266,7 +264,7 @@ export const Output: React.FC = () => {
fetchData();
}, [output, isLoadingOutputs]);
- const columns: ColumnType
[] = React.useMemo(() => {
+ const columns: ColumnType[] = React.useMemo(() => {
const importantColumns = operation?.output?.schema
? operation.output.schema.map((field) => field.key)
: [];
@@ -275,7 +273,7 @@ export const Output: React.FC = () => {
? Object.keys(outputs[0]).map((key) => ({
accessorKey: key,
header: key,
- cell: ({ getValue }: { getValue: () => any }) => {
+ cell: ({ getValue }: { getValue: () => unknown }) => {
const value = getValue();
const stringValue =
typeof value === "object" && value !== null
@@ -294,13 +292,17 @@ export const Output: React.FC = () => {
const TableContent = () => (
- {isLoadingOutputs ? (
+ {!opName ? (
+
+
No operation selected.
+
+ ) : isLoadingOutputs ? (
Loading outputs...
) : outputs.length > 0 ? (
-
+
{
startingRowHeight={180}
currentOperation={opName}
/>
-
+
) : (
No outputs available.
@@ -402,7 +404,7 @@ export const Output: React.FC = () => {
const groupedData = useMemo(() => {
const intersectionKeys = new Set(
outputs.flatMap((row) => {
- // @ts-ignore
+ // @ts-expect-error Record type needs refinement
const kvPairs = row[visualizationColumn.name] as Record<
string,
unknown
@@ -419,7 +421,7 @@ export const Output: React.FC = () => {
} = {};
outputs.forEach((row) => {
- // @ts-ignore
+ // @ts-expect-error Record type needs refinement
const kvPairs = row[visualizationColumn.name] as Record<
string,
unknown
@@ -480,7 +482,7 @@ export const Output: React.FC = () => {
JSON.stringify(value, null, 2)
)
)
- // @ts-ignore
+ // @ts-expect-error
).map((str) => JSON.parse(str));
// Calculate percentage of total documents
diff --git a/website/src/components/PipelineGui.tsx b/website/src/components/PipelineGui.tsx
index 17d612bb..25d452cf 100644
--- a/website/src/components/PipelineGui.tsx
+++ b/website/src/components/PipelineGui.tsx
@@ -161,10 +161,6 @@ const PipelineGUI: React.FC = () => {
prompt: undefined,
operationName: undefined,
});
- const [tempSystemPrompt, setTempSystemPrompt] = useState({
- datasetDescription: systemPrompt.datasetDescription || "",
- persona: systemPrompt.persona || "",
- });
const { submitTask } = useOptimizeCheck({
onComplete: (result) => {
@@ -634,7 +630,6 @@ const PipelineGUI: React.FC = () => {
setIsSettingsOpen(false);
setOptimizerModel(tempOptimizerModel);
setAutoOptimizeCheck(tempAutoOptimizeCheck);
- setSystemPrompt(tempSystemPrompt);
};
const handleDragEnd = (result: DropResult) => {
@@ -778,14 +773,16 @@ const PipelineGUI: React.FC = () => {
- setTempSystemPrompt((prev) => ({
- ...prev,
- datasetDescription: e.target.value,
- }))
- }
- onBlur={() => setSystemPrompt(tempSystemPrompt)}
+ defaultValue={systemPrompt.datasetDescription}
+ onBlur={(e) => {
+ const value = e.target.value;
+ setTimeout(() => {
+ setSystemPrompt((prev) => ({
+ ...prev,
+ datasetDescription: value,
+ }));
+ }, 0);
+ }}
className="h-[3.5rem]"
/>
@@ -799,14 +796,16 @@ const PipelineGUI: React.FC = () => {
- setTempSystemPrompt((prev) => ({
- ...prev,
- persona: e.target.value,
- }))
- }
- onBlur={() => setSystemPrompt(tempSystemPrompt)}
+ defaultValue={systemPrompt.persona}
+ onBlur={(e) => {
+ const value = e.target.value;
+ setTimeout(() => {
+ setSystemPrompt((prev) => ({
+ ...prev,
+ persona: value,
+ }));
+ }, 0);
+ }}
className="h-[3.5rem]"
/>
diff --git a/website/src/components/PrettyJSON.tsx b/website/src/components/PrettyJSON.tsx
new file mode 100644
index 00000000..42ab124b
--- /dev/null
+++ b/website/src/components/PrettyJSON.tsx
@@ -0,0 +1,145 @@
+import React, { useState } from "react";
+import { ChevronDown } from "lucide-react";
+
+interface PrettyJSONProps {
+ data: unknown;
+}
+
+export const PrettyJSON = React.memo(({ data }: PrettyJSONProps) => {
+ const [expandedPaths, setExpandedPaths] = useState(() => {
+ const paths: string[] = [];
+
+ const collectPaths = (value: unknown, path: string = "") => {
+ if (Array.isArray(value)) {
+ paths.push(path);
+ value.forEach((item, index) => {
+ if (typeof item === "object" && item !== null) {
+ collectPaths(item, `${path}.${index}`);
+ }
+ });
+ } else if (typeof value === "object" && value !== null) {
+ paths.push(path);
+ Object.entries(value).forEach(([key, val]) => {
+ if (typeof val === "object" && val !== null) {
+ collectPaths(val, `${path}.${key}`);
+ }
+ });
+ }
+ };
+
+ collectPaths(data);
+ return paths;
+ });
+
+ const renderValue = (
+ value: unknown,
+ path: string = "",
+ level: number = 0
+ ): React.ReactNode => {
+ if (value === null)
+ return null ;
+ if (value === undefined)
+ return undefined ;
+
+ if (Array.isArray(value)) {
+ if (value.length === 0)
+ return [] ;
+
+ const isExpanded = expandedPaths.includes(path);
+ return (
+
+
{
+ setExpandedPaths((prev) =>
+ prev.includes(path)
+ ? prev.filter((p) => p !== path)
+ : [...prev, path]
+ );
+ }}
+ className="text-left hover:text-primary transition-colors inline-flex items-center gap-1 font-bold"
+ >
+
+ Array ({value.length} items)
+
+ {isExpanded && (
+
+ {value.map((item, index) => (
+
+ {index}:
+ {renderValue(item, `${path}.${index}`, level + 1)}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ if (typeof value === "object") {
+ const entries = Object.entries(value as Record);
+ if (entries.length === 0)
+ return {} ;
+
+ const isExpanded = expandedPaths.includes(path);
+ return (
+
+
{
+ setExpandedPaths((prev) =>
+ prev.includes(path)
+ ? prev.filter((p) => p !== path)
+ : [...prev, path]
+ );
+ }}
+ className="text-left hover:text-primary transition-colors inline-flex items-center gap-1 font-bold"
+ >
+
+
+ Object ({entries.length} properties)
+
+
+ {isExpanded && (
+
+ {entries.map(([key, val]) => (
+
+ {key}:
+ {renderValue(val, `${path}.${key}`, level + 1)}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ if (typeof value === "string") {
+ return "{value}" ;
+ }
+
+ if (typeof value === "number") {
+ return {value} ;
+ }
+
+ if (typeof value === "boolean") {
+ return (
+ {value.toString()}
+ );
+ }
+
+ return {String(value)} ;
+ };
+
+ return (
+ {renderValue(data)}
+ );
+});
+
+PrettyJSON.displayName = "PrettyJSON";
diff --git a/website/src/components/PromptImprovementDialog.tsx b/website/src/components/PromptImprovementDialog.tsx
new file mode 100644
index 00000000..529c1a36
--- /dev/null
+++ b/website/src/components/PromptImprovementDialog.tsx
@@ -0,0 +1,1013 @@
+import React, { useState, useCallback, useEffect, useRef } from "react";
+import { useChat } from "ai/react";
+import { Operation } from "@/app/types";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Loader2, ArrowLeft } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+import { usePipelineContext } from "@/contexts/PipelineContext";
+import { useBookmarkContext } from "@/contexts/BookmarkContext";
+import { diffLines } from "diff";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Textarea } from "@/components/ui/textarea";
+
+type Step = "select" | "analyze";
+
+interface PromptImprovementDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentOperation: Operation;
+ onSave: (
+ newPrompt:
+ | string
+ | { comparison_prompt: string; resolution_prompt: string },
+ schemaChanges?: Array<[string, string]>
+ ) => void;
+}
+
+interface Revision {
+ messages: {
+ id: string;
+ role: "system" | "user" | "assistant";
+ content: string;
+ }[];
+ prompt: string | null;
+ timestamp: number;
+}
+
+interface FeedbackConnection {
+ fromRevision: number;
+ toRevision: number;
+ feedback: string;
+}
+
+function extractTagContent(text: string, tag: string): string | null {
+ const regex = new RegExp(`<${tag}>(.*?)${tag}>`, "s");
+ const match = text.match(regex);
+ return match ? match[1].trim() : null;
+}
+
+type PromptType = "comparison" | "resolve";
+
+function DiffView({
+ oldText,
+ newText,
+ type,
+}: {
+ oldText: string;
+ newText: string;
+ type?: PromptType;
+}) {
+ const diff = diffLines(oldText, newText);
+
+ return (
+
+ {type && (
+
+ {type === "comparison" ? "Comparison Prompt:" : "Resolution Prompt:"}
+
+ )}
+
+ {diff.map((part, index) => (
+
+ {part.value}
+
+ ))}
+
+
+ );
+}
+
+function removePromptAndSchemaTags(text: string): string {
+ return text
+ .replace(/[\s\S]*?<\/prompt>/g, "")
+ .replace(/[\s\S]*?<\/schema>/g, "")
+ .replace(/[\s\S]*?<\/comparison_prompt>/g, "")
+ .replace(/[\s\S]*?<\/resolution_prompt>/g, "");
+}
+
+const getSystemContent = (
+ pipelineState: string,
+ selectedOperation: Operation
+) => `You are a prompt engineering expert. Analyze the current operation's prompt${
+ selectedOperation.type === "resolve" ? "s" : ""
+} and suggest improvements based on the pipeline state.
+
+Current pipeline state:
+${pipelineState}
+
+Focus on the operation named "${selectedOperation.name}" ${
+ selectedOperation.type === "resolve"
+ ? `with comparison prompt:
+${selectedOperation.otherKwargs?.comparison_prompt || ""}
+
+and resolution prompt:
+${selectedOperation.otherKwargs?.resolution_prompt || ""}`
+ : `with prompt:
+${selectedOperation.prompt}`
+}
+
+${
+ selectedOperation.output?.schema
+ ? `
+Current output schema keys:
+${selectedOperation.output.schema.map((item) => `- ${item.key}`).join("\n")}
+`
+ : ""
+}
+
+IMPORTANT:
+1. ${
+ selectedOperation.type === "resolve"
+ ? "You must ALWAYS include complete revised prompts wrapped in AND tags in your response"
+ : "You must ALWAYS include a complete revised prompt wrapped in tags in your response"
+}, even if you're just responding to feedback.
+
+2. Only suggest schema key changes if absolutely necessary - when the current keys are misleading, incorrect, or ambiguous. If the schema keys are fine, don't suggest changes. Include changes in tags as a list of "oldkey,newkey" pairs, one per line. Example:
+
+misleading_key,accurate_key
+ambiguous_name,specific_name
+
+
+When responding:
+1. Briefly acknowledge/analyze any feedback (1-2 sentences)
+2. ALWAYS provide ${
+ selectedOperation.type === "resolve"
+ ? "complete revised prompts wrapped in AND tags"
+ : "a complete revised prompt wrapped in tags"
+}
+3. The prompt${
+ selectedOperation.type === "resolve" ? "s" : ""
+} should include all previous improvements plus any new changes
+4. Make prompts specific and concise:
+ - For subjective terms like "detailed" or "comprehensive", provide examples or metrics (e.g. "include 3-5 key points per section")
+ - For qualitative instructions like "long output", specify length (e.g. "200-300 words") based on my feedback or provide examples
+ - When using adjectives, include a reference point (e.g. "technical like API documentation" vs "simple like a blog post")`;
+
+function extractSchemaChanges(text: string): Array<[string, string]> {
+ const schemaContent = extractTagContent(text, "schema");
+ if (!schemaContent) return [];
+
+ return schemaContent
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => {
+ const [oldKey, newKey] = line.split(",").map((k) => k.trim());
+ return [oldKey, newKey] as [string, string];
+ });
+}
+
+function AutosizeTextarea({
+ value,
+ onChange,
+ onBlur,
+ type,
+}: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ onBlur?: () => void;
+ type?: PromptType;
+}) {
+ const [height, setHeight] = useState("auto");
+ const hiddenRef = useRef(null);
+
+ useEffect(() => {
+ if (hiddenRef.current) {
+ setHeight(`${hiddenRef.current.scrollHeight}px`);
+ }
+ }, [value]);
+
+ return (
+
+ {type && (
+
+ {type === "comparison" ? "Comparison Prompt:" : "Resolution Prompt:"}
+
+ )}
+
+ {value + "\n"}
+
+
+
+ );
+}
+
+function buildRevisionTree(
+ revisions: Revision[],
+ connections: FeedbackConnection[]
+) {
+ type TreeNode = {
+ index: number;
+ revision: Revision;
+ children: TreeNode[];
+ feedback?: string;
+ };
+
+ const nodes: TreeNode[] = revisions.map((revision, index) => ({
+ index,
+ revision,
+ children: [],
+ }));
+
+ // Build the tree structure
+ connections.forEach((conn) => {
+ const parentNode = nodes[conn.fromRevision];
+ const childNode = nodes[conn.toRevision];
+ if (parentNode && childNode) {
+ childNode.feedback = conn.feedback;
+ parentNode.children.push(childNode);
+ }
+ });
+
+ // Return only root nodes (nodes without parents)
+ return nodes.filter(
+ (node) => !connections.some((conn) => conn.toRevision === node.index)
+ );
+}
+
+function RevisionTreeNode({
+ node,
+ depth = 0,
+ selectedIndex,
+ onSelect,
+}: {
+ node: ReturnType[0];
+ depth?: number;
+ selectedIndex: number | null;
+ onSelect: (index: number) => void;
+}) {
+ return (
+
+
+ {depth > 0 && (
+
+ )}
+
onSelect(node.index)}
+ className={`flex items-center gap-2 w-full text-left py-1.5 px-2 text-sm hover:bg-muted rounded-sm transition-colors ${
+ selectedIndex === node.index ? "bg-muted" : ""
+ }`}
+ >
+
+
+
+ {node.index === 0
+ ? "Initial version"
+ : node.feedback || `Revision ${node.index}`}
+
+
+ {new Date(node.revision.timestamp).toLocaleTimeString()}
+
+
+
+
+ {node.children.length > 0 && (
+
+
+
+ {node.children.map((child, i) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// Add new component for schema changes diff view
+function SchemaChangesDiff({ changes }: { changes: Array<[string, string]> }) {
+ if (changes.length === 0) return null;
+
+ return (
+
+
Schema Key Changes:
+
+ {changes.map(([oldKey, newKey], index) => (
+
+ {oldKey}
+ →
+ {newKey}
+
+ ))}
+
+
+ );
+}
+
+// Add helper function to extract both prompts for resolve operations
+function extractPrompts(text: string): {
+ comparisonPrompt?: string;
+ resolvePrompt?: string;
+ prompt?: string;
+} {
+ const comparisonPrompt = extractTagContent(text, "comparison_prompt");
+ const resolvePrompt = extractTagContent(text, "resolution_prompt");
+ const prompt = extractTagContent(text, "prompt");
+
+ return {
+ comparisonPrompt,
+ resolvePrompt,
+ prompt,
+ };
+}
+
+// Update the state to use a single editedPrompt
+type EditedPrompt =
+ | string
+ | { comparison_prompt: string; resolution_prompt: string };
+
+export function PromptImprovementDialog({
+ open,
+ onOpenChange,
+ currentOperation,
+ onSave,
+}: PromptImprovementDialogProps) {
+ const { operations, serializeState } = usePipelineContext();
+ const { bookmarks } = useBookmarkContext();
+ const [step, setStep] = useState("select");
+ const [selectedOperationId, setSelectedOperationId] = useState(
+ currentOperation.id
+ );
+ const [editedPrompt, setEditedPrompt] = useState(null);
+ const [feedbackText, setFeedbackText] = useState("");
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [isDirectEditing, setIsDirectEditing] = useState(false);
+ const [revisions, setRevisions] = useState([]);
+ const [connections, setConnections] = useState([]);
+ const [selectedRevisionIndex, setSelectedRevisionIndex] = useState<
+ number | null
+ >(null);
+ const [expectingNewRevision, setExpectingNewRevision] = useState(false);
+ const [chatKey, setChatKey] = useState(0);
+
+ const selectedOperation = operations.find(
+ (op) => op.id === selectedOperationId
+ );
+
+ const relevantBookmarks = selectedOperation
+ ? bookmarks.flatMap((bookmark) =>
+ bookmark.notes.filter(
+ (note) => note.metadata?.operationName === selectedOperation.name
+ )
+ )
+ : [];
+
+ const { messages, isLoading, append, setMessages } = useChat({
+ api: "/api/chat",
+ id: `prompt-improvement-${chatKey}`,
+ onFinish: () => {
+ // Optional: handle completion
+ },
+ });
+
+ // Update the effect that handles new messages
+ useEffect(() => {
+ if (!isLoading && messages.length > 0 && expectingNewRevision) {
+ const lastMessage = messages[messages.length - 1];
+ if (lastMessage.role === "assistant") {
+ if (selectedOperation?.type === "resolve") {
+ const extractedPrompts = extractPrompts(lastMessage.content);
+ if (
+ extractedPrompts.comparisonPrompt &&
+ extractedPrompts.resolvePrompt
+ ) {
+ // Skip if the prompts are the same as what we have in editedPrompt
+ if (
+ typeof editedPrompt === "object" &&
+ editedPrompt?.comparison_prompt ===
+ extractedPrompts.comparisonPrompt &&
+ editedPrompt?.resolution_prompt === extractedPrompts.resolvePrompt
+ ) {
+ return;
+ }
+
+ const newPrompt = {
+ comparison_prompt: extractedPrompts.comparisonPrompt,
+ resolution_prompt: extractedPrompts.resolvePrompt,
+ };
+ setEditedPrompt(newPrompt);
+
+ // Create new revision
+ const newRevision = {
+ messages: JSON.parse(JSON.stringify(messages)),
+ prompt: newPrompt,
+ timestamp: Date.now(),
+ };
+
+ // Add revision and update selected index
+ setRevisions((prev) => {
+ const newRevisions = [...prev, newRevision] as Revision[];
+ setSelectedRevisionIndex(newRevisions.length - 1);
+ return newRevisions;
+ });
+ }
+ } else {
+ const extractedPrompt = extractTagContent(
+ lastMessage.content,
+ "prompt"
+ );
+ if (extractedPrompt) {
+ // Skip if the prompt is the same as what we have in editedPrompt
+ if (
+ typeof editedPrompt === "string" &&
+ editedPrompt === extractedPrompt
+ ) {
+ return;
+ }
+
+ setEditedPrompt(extractedPrompt);
+
+ // Create new revision
+ const newRevision = {
+ messages: JSON.parse(JSON.stringify(messages)),
+ prompt: extractedPrompt,
+ timestamp: Date.now(),
+ };
+
+ // Add revision and update selected index
+ setRevisions((prev) => {
+ const newRevisions = [...prev, newRevision];
+ setSelectedRevisionIndex(newRevisions.length - 1);
+ return newRevisions;
+ });
+ }
+ }
+
+ // If this isn't the first revision, create a connection
+ if (revisions.length > 0) {
+ const parentIndex = selectedRevisionIndex ?? revisions.length - 1;
+ setConnections((prev) => [
+ ...prev,
+ {
+ fromRevision: parentIndex,
+ toRevision: revisions.length,
+ feedback: feedbackText,
+ },
+ ]);
+ }
+
+ setExpectingNewRevision(false);
+ }
+ }
+ }, [messages, isLoading, expectingNewRevision, selectedOperation?.type]);
+
+ useEffect(() => {
+ if (selectedRevisionIndex !== null && revisions[selectedRevisionIndex]) {
+ const revision = revisions[selectedRevisionIndex];
+ // Set messages to a deep copy of the revision's messages
+ setMessages(JSON.parse(JSON.stringify(revision.messages)));
+
+ // Update the prompt
+ if (revision.prompt) {
+ setEditedPrompt(revision.prompt);
+ }
+ }
+ }, [selectedRevisionIndex, revisions]);
+
+ const handleImprove = useCallback(async () => {
+ setExpectingNewRevision(true); // Set flag before getting first response
+ setEditedPrompt(null);
+ setStep("analyze");
+ setRevisions([]);
+ setConnections([]);
+ setSelectedRevisionIndex(null);
+
+ const selectedOperation = operations.find(
+ (op) => op.id === selectedOperationId
+ );
+ if (!selectedOperation) return;
+
+ const bookmarksSection =
+ relevantBookmarks.length > 0
+ ? `\nMake sure to reflect my Feedback for this operation:\n${relevantBookmarks
+ .map((note) => `- ${note.note}`)
+ .join("\n")}`
+ : "\nNo feedback found for this operation.";
+
+ const pipelineState = await serializeState();
+ const systemContent = getSystemContent(pipelineState, selectedOperation);
+
+ // First set the system message
+ setMessages([
+ {
+ role: "system",
+ content: systemContent,
+ id: "system-1",
+ },
+ ]);
+
+ // Then append the user message with the appropriate prompt(s)
+ await append({
+ role: "user",
+ content: `Please analyze and improve my prompt${
+ selectedOperation.type === "resolve" ? "s" : ""
+ }:${
+ selectedOperation.type === "resolve"
+ ? `\nComparison prompt:\n${
+ selectedOperation.otherKwargs?.comparison_prompt || ""
+ }\n\nResolution prompt:\n${
+ selectedOperation.otherKwargs?.resolution_prompt || ""
+ }`
+ : `\n${selectedOperation.prompt}`
+ }${bookmarksSection}`,
+ id: "user-1",
+ });
+
+ // Create first revision after we get the response
+ // This will happen in the useEffect that watches messages
+ }, [
+ selectedOperationId,
+ operations,
+ serializeState,
+ append,
+ setMessages,
+ relevantBookmarks,
+ ]);
+
+ const handleBack = () => {
+ setStep("select");
+ // Clear messages when going back
+ setMessages([]);
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ // Reset state when dialog closes
+ setTimeout(() => {
+ setStep("select");
+ setMessages([]);
+ }, 200);
+ };
+
+ const handleFeedbackSubmit = useCallback(async () => {
+ if (!feedbackText.trim()) return;
+
+ setExpectingNewRevision(true); // Set flag before getting response
+
+ const sourceRevisionIndex = selectedRevisionIndex ?? revisions.length - 1;
+ const sourceRevision = revisions[sourceRevisionIndex];
+
+ // Create a new array with the source revision's messages
+ const newMessages = [...sourceRevision.messages];
+
+ // Add the feedback message with appropriate prompt instructions based on operation type
+ const feedbackMessage = {
+ role: "user" as const,
+ content: `Consider this feedback and provide ${
+ selectedOperation?.type === "resolve"
+ ? "updated prompts"
+ : "an updated prompt"
+ }: ${feedbackText}
+
+Remember to ${
+ selectedOperation?.type === "resolve"
+ ? "include the complete revised prompts wrapped in AND tags"
+ : "include the complete revised prompt wrapped in tags"
+ }, incorporating all previous improvements plus any new changes based on this feedback.`,
+ id: `user-feedback-${newMessages.length}`,
+ };
+
+ // Update messages with the feedback before sending to API
+ setMessages([...newMessages]);
+
+ // Send the feedback
+ await append(feedbackMessage);
+
+ setFeedbackText("");
+ setPopoverOpen(false);
+ }, [
+ feedbackText,
+ selectedRevisionIndex,
+ revisions,
+ messages,
+ append,
+ selectedOperation,
+ ]);
+
+ // Update the direct edit handlers
+ const handleDirectEditStart = () => {
+ setIsDirectEditing(true);
+ };
+
+ const handleDirectEditComplete = () => {
+ setIsDirectEditing(false);
+ };
+
+ const handleRevisionSelect = (index: number) => {
+ setSelectedRevisionIndex(index);
+ const revision = revisions[index];
+
+ // Increment the chat key to force a new instance
+ setChatKey((prev) => prev + 1);
+
+ // In the next tick, set up the new chat state
+ setTimeout(() => {
+ setMessages(JSON.parse(JSON.stringify(revision.messages)));
+ setEditedPrompt(revision.prompt || "");
+ }, 0);
+
+ // Reset direct edit state if it was active
+ if (isDirectEditing) {
+ setIsDirectEditing(false);
+ }
+ };
+
+ // Update the save handler
+ const handleSave = () => {
+ if (editedPrompt && selectedOperation) {
+ const lastMessage = messages[messages.length - 1];
+ const schemaChanges = lastMessage
+ ? extractSchemaChanges(lastMessage.content)
+ : [];
+ onSave(editedPrompt, schemaChanges);
+ setMessages([]);
+ setRevisions([]);
+ onOpenChange(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {step === "analyze" && (
+
+
+
+ )}
+
Rewrite Prompt
+
+
+
+ {step === "select"
+ ? "Select an operation to improve its prompt"
+ : "DocETL is analyzing and suggesting improvements"}
+
+
+
+
+ {step === "select" ? (
+
+
+
+
+
+
+ {operations
+ .filter((op) => op.llmType === "LLM")
+ .map((op) => (
+
+ {op.name}
+
+ ))}
+
+
+
+ {selectedOperation && (
+ <>
+
+
+ Current Prompt
+ {selectedOperation.type === "resolve" ? "s" : ""}:
+
+ {selectedOperation.type === "resolve" ? (
+
+
+
+ Comparison Prompt:
+
+
+ {selectedOperation.otherKwargs?.comparison_prompt ||
+ ""}
+
+
+
+
+ Resolution Prompt:
+
+
+ {selectedOperation.otherKwargs?.resolution_prompt ||
+ ""}
+
+
+
+ ) : (
+
+ {selectedOperation.prompt}
+
+ )}
+
+
+
+
Feedback:
+
+ {relevantBookmarks.length > 0 ? (
+
+ {relevantBookmarks.map((note, index) => (
+ {note.note}
+ ))}
+
+ ) : (
+
+ No feedback or bookmarks found for this operation.
+
+ )}
+
+
+ >
+ )}
+
+
+ Continue to Analysis
+
+
+ ) : (
+
+ {messages.length === 0 ? (
+
+
+
+ Starting analysis...
+
+
+ ) : (
+ <>
+
+ {messages
+ .filter((m) => m.role === "assistant")
+ .slice(-1)
+ .map((message, index) => (
+
+
+ {isLoading
+ ? message.content
+ : removePromptAndSchemaTags(message.content)}
+
+ {isLoading && (
+
+
+
+ )}
+
+ ))}
+
+
+ {!isLoading && editedPrompt && selectedOperation && (
+ <>
+
+
Prompt Changes:
+ {isDirectEditing ? (
+ selectedOperation.type === "resolve" ? (
+
+
+ setEditedPrompt((prev) =>
+ typeof prev === "object"
+ ? {
+ ...prev,
+ comparison_prompt: e.target.value,
+ }
+ : prev
+ )
+ }
+ type="comparison"
+ />
+
+ setEditedPrompt((prev) =>
+ typeof prev === "object"
+ ? {
+ ...prev,
+ resolution_prompt: e.target.value,
+ }
+ : prev
+ )
+ }
+ type="resolve"
+ />
+
+ ) : (
+
setEditedPrompt(e.target.value)}
+ />
+ )
+ ) : selectedOperation.type === "resolve" &&
+ typeof editedPrompt === "object" ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {messages.length > 0 && (
+
+ )}
+
+
+
{
+ if (isDirectEditing) {
+ // When switching to diff view, save the changes first
+ handleDirectEditComplete();
+ } else {
+ handleDirectEditStart();
+ }
+ }}
+ >
+ {isDirectEditing ? "See diff" : "Directly edit"}
+
+
+
+
+ Add feedback
+
+
+
+
+
+ Revision History:
+
+
+ {buildRevisionTree(
+ revisions,
+ connections
+ ).map((node, index) => (
+
+ ))}
+
+
+
+
+ setFeedbackText(e.target.value)
+ }
+ className="min-h-[100px] mb-2"
+ />
+
+
+ Submit Feedback
+
+
+
+
+
+
+
+
+ Save and Overwrite
+
+
+ >
+ )}
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/website/src/components/ResizableDataTable.tsx b/website/src/components/ResizableDataTable.tsx
index 93866b91..35441e9d 100644
--- a/website/src/components/ResizableDataTable.tsx
+++ b/website/src/components/ResizableDataTable.tsx
@@ -37,6 +37,7 @@ import {
ChevronDown,
Search,
Eye,
+ Maximize2,
} from "lucide-react";
import {
DropdownMenu,
@@ -54,7 +55,9 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
-
+import { ColumnDialog } from "@/components/ColumnDialog";
+import { SearchableCell } from "@/components/SearchableCell";
+import { PrettyJSON } from "@/components/PrettyJSON";
export type DataType = Record;
export type ColumnType = ColumnDef & {
initialWidth?: number;
@@ -360,6 +363,7 @@ interface ColumnHeaderProps {
filterValue: string;
onSort: () => void;
sortDirection: false | "asc" | "desc";
+ onExpand: () => void;
}
const ColumnHeader = React.memo(
@@ -371,6 +375,7 @@ const ColumnHeader = React.memo(
filterValue,
onSort,
sortDirection,
+ onExpand,
}: ColumnHeaderProps) => {
const histogramData = useMemo(() => {
if (!stats) return [];
@@ -435,75 +440,87 @@ const ColumnHeader = React.memo(
-
- {sortDirection === false && (
-
-
-
-
-
-
- )}
- {sortDirection === "asc" && (
-
-
-
-
- )}
- {sortDirection === "desc" && (
-
-
-
-
- )}
-
- {header}
+
+
+ {sortDirection === false && (
+
+
+
+
+
+
+ )}
+ {sortDirection === "asc" && (
+
+
+
+
+ )}
+ {sortDirection === "desc" && (
+
+
+
+
+ )}
+
+
+
+
+
+
{header}
-
-
+
+
+
+
{
});
MarkdownCell.displayName = "MarkdownCell";
-interface SearchableCellProps {
- content: string;
- isResizing: boolean;
-}
-
-const SearchableCell = React.memo(
- ({ content, isResizing }: SearchableCellProps) => {
- const [searchTerm, setSearchTerm] = useState("");
- const [highlightedContent, setHighlightedContent] = useState(content);
- const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
- const [matchCount, setMatchCount] = useState(0);
- const containerRef = useRef
(null);
-
- // Search functionality
- useEffect(() => {
- if (!searchTerm) {
- setHighlightedContent(content);
- setMatchCount(0);
- setCurrentMatchIndex(0);
- return;
- }
-
- try {
- const regex = new RegExp(`(${searchTerm})`, "gi");
- const matches = content.match(regex);
- const matchesCount = matches ? matches.length : 0;
- setMatchCount(matchesCount);
-
- if (matchesCount > 0) {
- const highlighted = content.replace(
- regex,
- (match) => `${match} `
- );
- setHighlightedContent(highlighted);
-
- // Scroll to current match
- setTimeout(() => {
- if (containerRef.current) {
- const marks =
- containerRef.current.getElementsByClassName("search-match");
- if (marks.length > 0 && currentMatchIndex < marks.length) {
- marks[currentMatchIndex].scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- }
- }
- }, 100);
- } else {
- setHighlightedContent(content);
- }
- } catch {
- setHighlightedContent(content);
- setMatchCount(0);
- }
- }, [searchTerm, content, currentMatchIndex]);
-
- const navigateMatches = useCallback(
- (direction: "next" | "prev") => {
- if (matchCount === 0) return;
-
- if (direction === "next") {
- setCurrentMatchIndex((prev) => (prev + 1) % matchCount);
- } else {
- setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount);
- }
- },
- [matchCount]
- );
-
- // Style for search matches
- useEffect(() => {
- const styleId = "search-match-style";
- let styleElement = document.getElementById(styleId) as HTMLStyleElement;
-
- if (!styleElement) {
- styleElement = document.createElement("style");
- styleElement.id = styleId;
- document.head.appendChild(styleElement);
- }
-
- styleElement.textContent = `
- mark.search-match {
- background-color: hsl(var(--primary) / 0.2);
- color: inherit;
- padding: 0;
- border-radius: 2px;
- }
- mark.search-match:nth-of-type(${currentMatchIndex + 1}) {
- background-color: hsl(var(--primary) / 0.5);
- }
- `;
-
- return () => {
- if (styleElement && styleElement.parentNode) {
- styleElement.parentNode.removeChild(styleElement);
- }
- };
- }, [currentMatchIndex]);
-
- return (
-
-
-
-
-
{
- setSearchTerm(e.target.value);
- setCurrentMatchIndex(0);
- }}
- className="h-6 text-xs border-none shadow-none focus-visible:ring-0"
- />
- {matchCount > 0 && (
-
- navigateMatches("prev")}
- >
-
-
-
- {currentMatchIndex + 1}/{matchCount}
-
- navigateMatches("next")}
- >
-
-
-
- )}
-
-
-
- {isResizing ? (
-
{content}
- ) : searchTerm ? (
-
- ) : (
-
- )}
-
-
- );
- }
-);
-SearchableCell.displayName = "SearchableCell";
-
interface ObservabilityIndicatorProps {
row: Record;
currentOperation: string;
@@ -1028,18 +888,17 @@ function ResizableDataTable({
if (cellValue == null) return false;
- // Handle different types of values
- if (typeof cellValue === "number") {
- return cellValue.toString().includes(searchValue);
- }
+ // Convert the cell value to a searchable string based on its type
+ let searchableString = "";
- if (Array.isArray(cellValue)) {
- return cellValue.some((item) =>
- String(item).toLowerCase().includes(searchValue)
- );
+ if (typeof cellValue === "object") {
+ // Handle both arrays and objects by converting to JSON string
+ searchableString = JSON.stringify(cellValue);
+ } else {
+ searchableString = String(cellValue);
}
- return String(cellValue).toLowerCase().includes(searchValue);
+ return searchableString.toLowerCase().includes(searchValue);
};
// Add this state to store original row indices
@@ -1161,6 +1020,16 @@ function ResizableDataTable({
saveSettings();
}, [columns, data, saveSettings, table]);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [activeColumn, setActiveColumn] = useState(null);
+ const [currentValueIndex, setCurrentValueIndex] = useState(0);
+
+ const handleColumnExpand = (columnId: string) => {
+ setActiveColumn(columnId);
+ setCurrentValueIndex(0);
+ setDialogOpen(true);
+ };
+
return (
@@ -1285,6 +1154,7 @@ function ResizableDataTable
({
}
}}
sortDirection={header.column.getIsSorted()}
+ onExpand={() => handleColumnExpand(header.column.id)}
/>
)}
@@ -1340,6 +1210,17 @@ function ResizableDataTable({
content={cell.getValue() as string}
isResizing={isResizing}
/>
+ ) : typeof cell.getValue() === "object" ? (
+
+ {(searchTerm) =>
+ searchTerm ? null : (
+
+ )
+ }
+
) : (
flexRender(
cell.column.columnDef.cell,
@@ -1393,6 +1274,34 @@ function ResizableDataTable({
)}
+
+ {activeColumn && (
+
{
+ setDialogOpen(false);
+ setActiveColumn(null);
+ }}
+ columnId={activeColumn}
+ columnHeader={
+ columns.find((col) => (col.accessorKey || col.id) === activeColumn)
+ ?.header as string
+ }
+ data={data}
+ currentIndex={currentValueIndex}
+ onNavigate={(direction) => {
+ if (direction === "next") {
+ setCurrentValueIndex((prev) =>
+ Math.min(prev + 1, data.length - 1)
+ );
+ } else {
+ setCurrentValueIndex((prev) => Math.max(prev - 1, 0));
+ }
+ }}
+ onJumpToRow={(index) => setCurrentValueIndex(index)}
+ currentOperation={currentOperation}
+ />
+ )}
);
}
diff --git a/website/src/components/RowNavigator.tsx b/website/src/components/RowNavigator.tsx
new file mode 100644
index 00000000..fd9e784b
--- /dev/null
+++ b/website/src/components/RowNavigator.tsx
@@ -0,0 +1,128 @@
+import React, { useState, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+interface RowNavigatorProps {
+ currentRow: number;
+ totalRows: number;
+ onNavigate: (direction: "prev" | "next") => void;
+ onJumpToRow: (index: number) => void;
+ disabled?: boolean;
+ label?: string;
+ compact?: boolean;
+}
+
+export const RowNavigator = React.memo(
+ ({
+ currentRow,
+ totalRows,
+ onNavigate,
+ onJumpToRow,
+ disabled = false,
+ label = "Row",
+ compact = false,
+ }: RowNavigatorProps) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const inputRef = useRef(null);
+
+ const handleSubmit = () => {
+ const rowNum = parseInt(inputValue);
+ if (!isNaN(rowNum) && rowNum >= 1 && rowNum <= totalRows) {
+ onJumpToRow(rowNum - 1);
+ setIsEditing(false);
+ }
+ setInputValue("");
+ };
+
+ return (
+
+
onNavigate("prev")}
+ disabled={currentRow === 0 || disabled}
+ className={`${compact ? "h-6 w-6" : "h-8 w-8"} p-0`}
+ >
+
+
+
+
+
+ {label}
+
+ {isEditing ? (
+
{
+ e.preventDefault();
+ handleSubmit();
+ }}
+ className="relative"
+ >
+ setInputValue(e.target.value)}
+ onBlur={() => {
+ if (inputValue) handleSubmit();
+ else setIsEditing(false);
+ }}
+ className={`${
+ compact ? "w-16 h-6 text-xs" : "w-20 h-8 text-sm"
+ } pr-8`}
+ autoFocus
+ />
+
+ ↵
+
+
+ ) : (
+ {
+ setIsEditing(true);
+ setTimeout(() => inputRef.current?.focus(), 0);
+ }}
+ disabled={disabled}
+ >
+ {currentRow + 1}
+
+ )}
+
+ of {totalRows}
+
+
+
+
onNavigate("next")}
+ disabled={currentRow === totalRows - 1 || disabled}
+ className={`${compact ? "h-6 w-6" : "h-8 w-8"} p-0`}
+ >
+
+
+
+ );
+ }
+);
+
+RowNavigator.displayName = "RowNavigator";
diff --git a/website/src/components/SearchableCell.tsx b/website/src/components/SearchableCell.tsx
new file mode 100644
index 00000000..5f65f7d7
--- /dev/null
+++ b/website/src/components/SearchableCell.tsx
@@ -0,0 +1,170 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { MarkdownCell } from "@/components/MarkdownCell";
+
+interface SearchableCellProps {
+ content: string;
+ isResizing: boolean;
+ children?: (searchTerm: string) => React.ReactNode;
+}
+
+export const SearchableCell = React.memo(
+ ({ content, isResizing, children }: SearchableCellProps) => {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [highlightedContent, setHighlightedContent] = useState(content);
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
+ const [matchCount, setMatchCount] = useState(0);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!searchTerm) {
+ setHighlightedContent(content);
+ setMatchCount(0);
+ setCurrentMatchIndex(0);
+ return;
+ }
+
+ try {
+ const regex = new RegExp(`(${searchTerm})`, "gi");
+ const matches = content.match(regex);
+ const matchesCount = matches ? matches.length : 0;
+ setMatchCount(matchesCount);
+
+ if (matchesCount > 0) {
+ const highlighted = content.replace(
+ regex,
+ (match) => `${match} `
+ );
+ setHighlightedContent(highlighted);
+
+ setTimeout(() => {
+ if (containerRef.current) {
+ const marks =
+ containerRef.current.getElementsByClassName("search-match");
+ if (marks.length > 0 && currentMatchIndex < marks.length) {
+ marks[currentMatchIndex].scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ }
+ }, 100);
+ } else {
+ setHighlightedContent(content);
+ }
+ } catch {
+ setHighlightedContent(content);
+ setMatchCount(0);
+ }
+ }, [searchTerm, content, currentMatchIndex]);
+
+ const navigateMatches = (direction: "next" | "prev") => {
+ if (matchCount === 0) return;
+
+ if (direction === "next") {
+ setCurrentMatchIndex((prev) => (prev + 1) % matchCount);
+ } else {
+ setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount);
+ }
+ };
+
+ useEffect(() => {
+ const styleId = "search-match-style";
+ let styleElement = document.getElementById(styleId) as HTMLStyleElement;
+
+ if (!styleElement) {
+ styleElement = document.createElement("style");
+ styleElement.id = styleId;
+ document.head.appendChild(styleElement);
+ }
+
+ styleElement.textContent = `
+ mark.search-match {
+ background-color: hsl(var(--primary) / 0.2);
+ color: inherit;
+ padding: 0;
+ border-radius: 2px;
+ }
+ mark.search-match:nth-of-type(${currentMatchIndex + 1}) {
+ background-color: hsl(var(--primary) / 0.5);
+ }
+ `;
+
+ return () => {
+ if (styleElement && styleElement.parentNode) {
+ styleElement.parentNode.removeChild(styleElement);
+ }
+ };
+ }, [currentMatchIndex]);
+
+ return (
+
+
+
+
+
{
+ setSearchTerm(e.target.value);
+ setCurrentMatchIndex(0);
+ }}
+ className="h-6 text-xs border-none shadow-none focus-visible:ring-0"
+ />
+ {matchCount > 0 && (
+
+ navigateMatches("prev")}
+ disabled={currentMatchIndex === 0}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+ {currentMatchIndex + 1}/{matchCount}
+
+ navigateMatches("next")}
+ disabled={currentMatchIndex === matchCount - 1}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+ )}
+
+
+
+ {children ? (
+ searchTerm ? (
+
+ ) : (
+ children(searchTerm)
+ )
+ ) : isResizing ? (
+
{content}
+ ) : searchTerm ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ }
+);
+
+SearchableCell.displayName = "SearchableCell";
diff --git a/website/src/contexts/BookmarkContext.tsx b/website/src/contexts/BookmarkContext.tsx
index 2e714c72..08c226f8 100644
--- a/website/src/contexts/BookmarkContext.tsx
+++ b/website/src/contexts/BookmarkContext.tsx
@@ -9,14 +9,14 @@ import { Bookmark, BookmarkContextType, UserNote } from "@/app/types";
import { BOOKMARKS_STORAGE_KEY } from "@/app/localStorageKeys";
const BookmarkContext = createContext(
- undefined,
+ undefined
);
export const useBookmarkContext = () => {
const context = useContext(BookmarkContext);
if (!context) {
throw new Error(
- "useBookmarkContext must be used within a BookmarkProvider",
+ "useBookmarkContext must be used within a BookmarkProvider"
);
}
return context;
@@ -37,16 +37,9 @@ export const BookmarkProvider: React.FC<{ children: ReactNode }> = ({
localStorage.setItem(BOOKMARKS_STORAGE_KEY, JSON.stringify(bookmarks));
}, [bookmarks]);
- const addBookmark = (
- text: string,
- source: string,
- color: string,
- notes: UserNote[],
- ) => {
+ const addBookmark = (color: string, notes: UserNote[]) => {
const newBookmark: Bookmark = {
id: Date.now().toString(),
- text,
- source,
color,
notes,
};
@@ -55,13 +48,31 @@ export const BookmarkProvider: React.FC<{ children: ReactNode }> = ({
const removeBookmark = (id: string) => {
setBookmarks((prevBookmarks) =>
- prevBookmarks.filter((bookmark) => bookmark.id !== id),
+ prevBookmarks.filter((bookmark) => bookmark.id !== id)
+ );
+ };
+
+ const getNotesForRowAndColumn = (
+ rowIndex: number,
+ columnId: string
+ ): UserNote[] => {
+ return bookmarks.flatMap((bookmark) =>
+ bookmark.notes.filter(
+ (note) =>
+ note.metadata?.rowIndex === rowIndex &&
+ note.metadata?.columnId === columnId
+ )
);
};
return (
{children}
diff --git a/website/src/contexts/PipelineContext.tsx b/website/src/contexts/PipelineContext.tsx
index b5c13dfc..c0e690bb 100644
--- a/website/src/contexts/PipelineContext.tsx
+++ b/website/src/contexts/PipelineContext.tsx
@@ -212,9 +212,28 @@ const serializeState = async (state: PipelineState): Promise => {
const bookmarksDetails = bookmarks
.map((bookmark: Bookmark) => {
return `
-- Text: "${bookmark.text}"
- Source: ${bookmark.source}
- Context: ${bookmark.notes[0].note || "None"}`;
+- Color: ${bookmark.color}
+ Notes: ${bookmark.notes
+ .map(
+ (note) => `
+ "${note.note}"${
+ note.metadata?.columnId
+ ? `
+ Column: ${note.metadata.columnId}${
+ note.metadata.rowIndex !== undefined
+ ? `
+ Row: ${note.metadata.rowIndex}`
+ : ""
+ }`
+ : ""
+ }${
+ note.metadata?.operationName
+ ? `
+ Operation: ${note.metadata.operationName}`
+ : ""
+ }`
+ )
+ .join("\n")}`;
})
.join("\n");
@@ -227,7 +246,9 @@ Input Dataset File: ${
Pipeline operations:${operationsDetails}
-My notes:${bookmarks.length > 0 ? bookmarksDetails : "\nNo notes added yet"}
+My feedback:${
+ bookmarks.length > 0 ? bookmarksDetails : "\nNo feedback added yet"
+ }
${
currentOperationName && outputSample
? `
@@ -358,6 +379,10 @@ export const PipelineProvider: React.FC<{ children: React.ReactNode }> = ({
localStorageKeys.HIGH_LEVEL_GOAL_KEY,
JSON.stringify(stateRef.current.highLevelGoal)
);
+ localStorage.setItem(
+ localStorageKeys.SYSTEM_PROMPT_KEY,
+ JSON.stringify(stateRef.current.systemPrompt)
+ );
setUnsavedChanges(false);
}, []);
diff --git a/website/src/hooks/use-toast.ts b/website/src/hooks/use-toast.ts
index 2e9d0205..8636f2b0 100644
--- a/website/src/hooks/use-toast.ts
+++ b/website/src/hooks/use-toast.ts
@@ -22,13 +22,6 @@ type ActionTypes = {
REMOVE_TOAST: "REMOVE_TOAST";
};
-const actionTypes: ActionTypes = {
- ADD_TOAST: "ADD_TOAST",
- UPDATE_TOAST: "UPDATE_TOAST",
- DISMISS_TOAST: "DISMISS_TOAST",
- REMOVE_TOAST: "REMOVE_TOAST",
-} as const;
-
let count = 0;
function genId() {
@@ -160,7 +153,13 @@ function toast({ ...props }: Toast) {
...props,
id,
open: true,
- style: { wordWrap: "break-word", maxWidth: "100%" },
+ style: {
+ wordWrap: "break-word",
+ maxWidth: "100%",
+ maxHeight: "80vh",
+ overflow: "auto",
+ padding: "8px",
+ },
onOpenChange: (open) => {
if (!open) dismiss();
},