562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { v4 as uuid4 } from "uuid";
|
|
import classNames from "classnames";
|
|
import { Fragment, MouseEvent, MutableRefObject, useCallback, useEffect, useRef, useState, memo } from "react";
|
|
|
|
import { useModal } from "@/ui/elements/Modal";
|
|
import { CaretIcon, CloseIcon, PlusIcon } from "@/ui/Icons";
|
|
import PopupMenu from "@/ui/elements/PopupMenu";
|
|
import { IconButton, TextArea, Modal, GhostButton, CTAButton } from "@/ui/elements";
|
|
import { GraphControlsAPI } from "@/app/(graph)/GraphControls";
|
|
import GraphVisualization, { GraphVisualizationAPI } from "@/app/(graph)/GraphVisualization";
|
|
|
|
import NotebookCellHeader from "./NotebookCellHeader";
|
|
import MarkdownPreview from "./MarkdownPreview";
|
|
import { Cell, Notebook as NotebookType } from "./types";
|
|
|
|
interface NotebookProps {
|
|
notebook: NotebookType;
|
|
runCell: (notebook: NotebookType, cell: Cell, cogneeInstance: string) => Promise<void>;
|
|
updateNotebook: (updatedNotebook: NotebookType) => void;
|
|
}
|
|
|
|
interface NotebookCellProps {
|
|
cell: Cell;
|
|
index: number;
|
|
isOpen: boolean;
|
|
isMarkdownEditMode: boolean;
|
|
onToggleOpen: () => void;
|
|
onToggleMarkdownEdit: () => void;
|
|
onContentChange: (value: string) => void;
|
|
onCellRun: (cell: Cell, cogneeInstance: string) => Promise<void>;
|
|
onCellRename: (cell: Cell) => void;
|
|
onCellRemove: (cell: Cell) => void;
|
|
onCellUp: (cell: Cell) => void;
|
|
onCellDown: (cell: Cell) => void;
|
|
onCellAdd: (afterCellIndex: number, cellType: "markdown" | "code") => void;
|
|
}
|
|
|
|
const NotebookCell = memo(function NotebookCell({
|
|
cell,
|
|
index,
|
|
isOpen,
|
|
isMarkdownEditMode,
|
|
onToggleOpen,
|
|
onToggleMarkdownEdit,
|
|
onContentChange,
|
|
onCellRun,
|
|
onCellRename,
|
|
onCellRemove,
|
|
onCellUp,
|
|
onCellDown,
|
|
onCellAdd,
|
|
}: NotebookCellProps) {
|
|
return (
|
|
<Fragment>
|
|
<div className="flex flex-row rounded-xl border-1 border-gray-100">
|
|
<div className="flex flex-col flex-1 relative">
|
|
{cell.type === "code" ? (
|
|
<>
|
|
<div className="absolute left-[-1.35rem] top-2.5">
|
|
<IconButton className="p-[0.25rem] m-[-0.25rem]" onClick={onToggleOpen}>
|
|
<CaretIcon className={classNames("transition-transform", isOpen ? "rotate-0" : "rotate-180")} />
|
|
</IconButton>
|
|
</div>
|
|
|
|
<NotebookCellHeader
|
|
cell={cell}
|
|
runCell={onCellRun}
|
|
renameCell={onCellRename}
|
|
removeCell={onCellRemove}
|
|
moveCellUp={onCellUp}
|
|
moveCellDown={onCellDown}
|
|
className="rounded-tl-xl rounded-tr-xl"
|
|
/>
|
|
|
|
{isOpen && (
|
|
<>
|
|
<TextArea
|
|
value={cell.content}
|
|
onChange={onContentChange}
|
|
isAutoExpanding
|
|
name="cellInput"
|
|
placeholder="Type your code here..."
|
|
className="resize-none min-h-36 max-h-96 overflow-y-auto rounded-tl-none rounded-tr-none rounded-bl-xl rounded-br-xl border-0 !outline-0"
|
|
/>
|
|
|
|
<div className="flex flex-col bg-gray-100 overflow-x-auto max-w-full">
|
|
{cell.result && (
|
|
<div className="px-2 py-2">
|
|
output: <CellResult content={cell.result} />
|
|
</div>
|
|
)}
|
|
{!!cell.error?.length && (
|
|
<div className="px-2 py-2">
|
|
error: {cell.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="absolute left-[-1.35rem] top-2.5">
|
|
<IconButton className="p-[0.25rem] m-[-0.25rem]" onClick={onToggleOpen}>
|
|
<CaretIcon className={classNames("transition-transform", isOpen ? "rotate-0" : "rotate-180")} />
|
|
</IconButton>
|
|
</div>
|
|
|
|
<NotebookCellHeader
|
|
cell={cell}
|
|
renameCell={onCellRename}
|
|
removeCell={onCellRemove}
|
|
moveCellUp={onCellUp}
|
|
moveCellDown={onCellDown}
|
|
className="rounded-tl-xl rounded-tr-xl"
|
|
/>
|
|
|
|
{isOpen && (
|
|
<div className="relative rounded-tl-none rounded-tr-none rounded-bl-xl rounded-br-xl border-0 overflow-hidden">
|
|
<GhostButton
|
|
onClick={onToggleMarkdownEdit}
|
|
className="absolute top-2 right-2.5 text-xs leading-[1] !px-2 !py-1 !h-auto"
|
|
>
|
|
{isMarkdownEditMode ? "Preview" : "Edit"}
|
|
</GhostButton>
|
|
{isMarkdownEditMode ? (
|
|
<TextArea
|
|
value={cell.content}
|
|
onChange={onContentChange}
|
|
isAutoExpanding
|
|
name="markdownInput"
|
|
placeholder="Type your markdown here..."
|
|
className="resize-none min-h-24 max-h-96 overflow-y-auto rounded-tl-none rounded-tr-none rounded-bl-xl rounded-br-xl border-0 !outline-0 !bg-gray-50"
|
|
/>
|
|
) : (
|
|
<MarkdownPreview content={cell.content} className="!bg-gray-50" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="ml-[-1.35rem]">
|
|
<PopupMenu
|
|
openToRight={true}
|
|
triggerElement={<PlusIcon />}
|
|
triggerClassName="p-[0.25rem] m-[-0.25rem]"
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<button
|
|
onClick={() => onCellAdd(index, "markdown")}
|
|
className="hover:bg-gray-100 w-full text-left px-2 cursor-pointer"
|
|
>
|
|
<span>text</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
onClick={() => onCellAdd(index, "code")}
|
|
className="hover:bg-gray-100 w-full text-left px-2 cursor-pointer"
|
|
>
|
|
<span>code</span>
|
|
</div>
|
|
</PopupMenu>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
});
|
|
|
|
export default function Notebook({ notebook, updateNotebook, runCell }: NotebookProps) {
|
|
const [openCells, setOpenCells] = useState(new Set(notebook.cells.map((c: Cell) => c.id)));
|
|
const [markdownEditMode, setMarkdownEditMode] = useState<Set<string>>(new Set());
|
|
|
|
const toggleCellOpen = useCallback((id: string) => {
|
|
setOpenCells((prev) => {
|
|
const newState = new Set(prev);
|
|
|
|
if (newState.has(id)) {
|
|
newState.delete(id)
|
|
} else {
|
|
newState.add(id);
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
}, []);
|
|
|
|
const toggleMarkdownEditMode = useCallback((id: string) => {
|
|
setMarkdownEditMode((prev) => {
|
|
const newState = new Set(prev);
|
|
|
|
if (newState.has(id)) {
|
|
newState.delete(id);
|
|
} else {
|
|
newState.add(id);
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (notebook.cells.length === 0) {
|
|
const newCell: Cell = {
|
|
id: uuid4(),
|
|
name: "first cell",
|
|
type: "code",
|
|
content: "",
|
|
};
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: [newCell],
|
|
});
|
|
toggleCellOpen(newCell.id)
|
|
}
|
|
}, [notebook, updateNotebook, toggleCellOpen]);
|
|
|
|
const handleCellRun = useCallback((cell: Cell, cogneeInstance: string) => {
|
|
return runCell(notebook, cell, cogneeInstance);
|
|
}, [notebook, runCell]);
|
|
|
|
const handleCellAdd = useCallback((afterCellIndex: number, cellType: "markdown" | "code") => {
|
|
const newCell: Cell = {
|
|
id: uuid4(),
|
|
name: cellType === "markdown" ? "Markdown Cell" : "Code Cell",
|
|
type: cellType,
|
|
content: "",
|
|
};
|
|
|
|
const newNotebook = {
|
|
...notebook,
|
|
cells: [
|
|
...notebook.cells.slice(0, afterCellIndex + 1),
|
|
newCell,
|
|
...notebook.cells.slice(afterCellIndex + 1),
|
|
],
|
|
};
|
|
|
|
toggleCellOpen(newCell.id);
|
|
updateNotebook(newNotebook);
|
|
}, [notebook, updateNotebook, toggleCellOpen]);
|
|
|
|
const removeCell = useCallback((cell: Cell, event?: MouseEvent) => {
|
|
event?.preventDefault();
|
|
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: notebook.cells.filter((c: Cell) => c.id !== cell.id),
|
|
});
|
|
}, [notebook, updateNotebook]);
|
|
|
|
const {
|
|
isModalOpen: isRemoveCellConfirmModalOpen,
|
|
openModal: openCellRemoveConfirmModal,
|
|
closeModal: closeCellRemoveConfirmModal,
|
|
confirmAction: handleCellRemoveConfirm,
|
|
} = useModal<Cell, MouseEvent>(false, removeCell);
|
|
|
|
const handleCellRemove = useCallback((cell: Cell) => {
|
|
openCellRemoveConfirmModal(cell);
|
|
}, [openCellRemoveConfirmModal]);
|
|
|
|
const handleCellInputChange = useCallback((cellId: string, value: string) => {
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: notebook.cells.map((cell: Cell) => (cell.id === cellId ? {...cell, content: value} : cell)),
|
|
});
|
|
}, [notebook, updateNotebook]);
|
|
|
|
const handleCellUp = useCallback((cell: Cell) => {
|
|
const index = notebook.cells.indexOf(cell);
|
|
|
|
if (index > 0) {
|
|
const newCells = [...notebook.cells];
|
|
newCells[index] = notebook.cells[index - 1];
|
|
newCells[index - 1] = cell;
|
|
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: newCells,
|
|
});
|
|
}
|
|
}, [notebook, updateNotebook]);
|
|
|
|
const handleCellDown = useCallback((cell: Cell) => {
|
|
const index = notebook.cells.indexOf(cell);
|
|
|
|
if (index < notebook.cells.length - 1) {
|
|
const newCells = [...notebook.cells];
|
|
newCells[index] = notebook.cells[index + 1];
|
|
newCells[index + 1] = cell;
|
|
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: newCells,
|
|
});
|
|
}
|
|
}, [notebook, updateNotebook]);
|
|
|
|
const handleCellRename = useCallback((cell: Cell) => {
|
|
const newName = prompt("Enter a new name for the cell:");
|
|
|
|
if (newName) {
|
|
updateNotebook({
|
|
...notebook,
|
|
cells: notebook.cells.map((c: Cell) => (c.id === cell.id ? {...c, name: newName } : c)),
|
|
});
|
|
}
|
|
}, [notebook, updateNotebook]);
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-white rounded-xl flex flex-col gap-0.5 px-7 py-5 flex-1">
|
|
<div className="mb-5">{notebook.name}</div>
|
|
|
|
{notebook.cells.map((cell: Cell, index) => (
|
|
<NotebookCell
|
|
key={cell.id}
|
|
cell={cell}
|
|
index={index}
|
|
isOpen={openCells.has(cell.id)}
|
|
isMarkdownEditMode={markdownEditMode.has(cell.id)}
|
|
onToggleOpen={() => toggleCellOpen(cell.id)}
|
|
onToggleMarkdownEdit={() => toggleMarkdownEditMode(cell.id)}
|
|
onContentChange={(value) => handleCellInputChange(cell.id, value)}
|
|
onCellRun={handleCellRun}
|
|
onCellRename={handleCellRename}
|
|
onCellRemove={handleCellRemove}
|
|
onCellUp={handleCellUp}
|
|
onCellDown={handleCellDown}
|
|
onCellAdd={handleCellAdd}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<Modal isOpen={isRemoveCellConfirmModalOpen}>
|
|
<div className="w-full max-w-2xl">
|
|
<div className="flex flex-row items-center justify-between">
|
|
<span className="text-2xl">Delete notebook cell?</span>
|
|
<IconButton onClick={closeCellRemoveConfirmModal}><CloseIcon /></IconButton>
|
|
</div>
|
|
<div className="mt-8 mb-6">Are you sure you want to delete a notebook cell? This action cannot be undone.</div>
|
|
<div className="flex flex-row gap-4 mt-4 justify-end">
|
|
<GhostButton type="button" onClick={closeCellRemoveConfirmModal}>cancel</GhostButton>
|
|
<CTAButton onClick={handleCellRemoveConfirm} type="submit">delete</CTAButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
function CellResult({ content }: { content: [] }) {
|
|
const parsedContent = [];
|
|
|
|
const graphRef = useRef<GraphVisualizationAPI>(null);
|
|
const graphControls = useRef<GraphControlsAPI>({
|
|
setSelectedNode: () => {},
|
|
getSelectedNode: () => null,
|
|
});
|
|
|
|
if (content.length === 0) {
|
|
return <span>OK</span>;
|
|
}
|
|
|
|
for (const line of content) {
|
|
try {
|
|
if (Array.isArray(line)) {
|
|
// Insights search returns uncommon graph data structure
|
|
if (Array.from(line).length > 0 && Array.isArray(line[0]) && line[0][1]["relationship_name"]) {
|
|
parsedContent.push(
|
|
<div key={line[0][1]["relationship_name"]} className="w-full h-full bg-white">
|
|
<span className="text-sm pl-2 mb-4">reasoning graph</span>
|
|
<GraphVisualization
|
|
data={transformInsightsGraphData(line)}
|
|
ref={graphRef as MutableRefObject<GraphVisualizationAPI>}
|
|
graphControls={graphControls}
|
|
className="min-h-80"
|
|
/>
|
|
</div>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// @ts-expect-error line can be Array or string
|
|
for (const item of line) {
|
|
if (
|
|
typeof item === "object" && item["search_result"] && (typeof(item["search_result"]) === "string"
|
|
|| (Array.isArray(item["search_result"]) && typeof(item["search_result"][0]) === "string"))
|
|
) {
|
|
parsedContent.push(
|
|
<div key={String(item["search_result"])} className="w-full h-full bg-white">
|
|
<span className="text-sm pl-2 mb-4">query response (dataset: {item["dataset_name"]})</span>
|
|
<span className="block px-2 py-2 whitespace-normal">{item["search_result"]}</span>
|
|
</div>
|
|
);
|
|
} else if (typeof(item) === "object" && item["search_result"] && typeof(item["search_result"]) === "object") {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm" key={String(item).slice(0, -10)}>
|
|
{JSON.stringify(item, null, 2)}
|
|
</pre>
|
|
)
|
|
} else if (typeof(item) === "string") {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm whitespace-normal" key={item.slice(0, -10)}>
|
|
{item}
|
|
</pre>
|
|
);
|
|
} else if (typeof(item) === "object" && !(item["search_result"] || item["graphs"])) {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm" key={String(item).slice(0, -10)}>
|
|
{JSON.stringify(item, null, 2)}
|
|
</pre>
|
|
)
|
|
}
|
|
|
|
if (typeof item === "object" && item["graphs"] && typeof item["graphs"] === "object") {
|
|
Object.entries<{ nodes: []; edges: []; }>(item["graphs"]).forEach(([datasetName, graph]) => {
|
|
parsedContent.push(
|
|
<div key={datasetName} className="w-full h-full bg-white">
|
|
<span className="text-sm pl-2 mb-4">reasoning graph (datasets: {datasetName})</span>
|
|
<GraphVisualization
|
|
data={transformToVisualizationData(graph)}
|
|
ref={graphRef as MutableRefObject<GraphVisualizationAPI>}
|
|
graphControls={graphControls}
|
|
className="min-h-80"
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
else if (typeof(line) === "object" && line["result"] && typeof(line["result"]) === "string") {
|
|
const datasets = Array.from(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
new Set(Object.values(line["datasets"]).map((dataset: any) => dataset.name))
|
|
).join(", ");
|
|
|
|
parsedContent.push(
|
|
<div key={line["result"]} className="w-full h-full bg-white">
|
|
<span className="text-sm pl-2 mb-4">query response (datasets: {datasets})</span>
|
|
<span className="block px-2 py-2 whitespace-normal">{line["result"]}</span>
|
|
</div>
|
|
);
|
|
|
|
if (line["graphs"]) {
|
|
Object.entries<{ nodes: []; edges: []; }>(line["graphs"]).forEach(([datasetName, graph]) => {
|
|
parsedContent.push(
|
|
<div key={datasetName} className="w-full h-full bg-white">
|
|
<span className="text-sm pl-2 mb-4">reasoning graph (datasets: {datasetName})</span>
|
|
<GraphVisualization
|
|
data={transformToVisualizationData(graph)}
|
|
ref={graphRef as MutableRefObject<GraphVisualizationAPI>}
|
|
graphControls={graphControls}
|
|
className="min-h-80"
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
}
|
|
else if (typeof(line) === "object" && line["result"] && typeof(line["result"]) === "object") {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm" key={String(line).slice(0, -10)}>
|
|
{JSON.stringify(line["result"], null, 2)}
|
|
</pre>
|
|
)
|
|
}
|
|
else if (typeof(line) === "object") {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm" key={String(line).slice(0, -10)}>
|
|
{JSON.stringify(line, null, 2)}
|
|
</pre>
|
|
)
|
|
}
|
|
else if (typeof(line) === "string") {
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm whitespace-normal" key={String(line).slice(0, -10)}>
|
|
{line}
|
|
</pre>
|
|
)
|
|
}
|
|
} catch {
|
|
// It is fine if we don't manage to parse the output line, we show it as it is.
|
|
parsedContent.push(
|
|
<pre className="px-2 w-full h-full bg-white text-sm whitespace-normal" key={String(line).slice(0, -10)}>
|
|
{line}
|
|
</pre>
|
|
);
|
|
}
|
|
}
|
|
|
|
return parsedContent.map((item, index) => (
|
|
<div key={index} className="px-2 py-1">
|
|
{item}
|
|
</div>
|
|
));
|
|
};
|
|
|
|
function transformToVisualizationData(graph: { nodes: [], edges: [] }) {
|
|
return {
|
|
nodes: graph.nodes,
|
|
links: graph.edges,
|
|
};
|
|
}
|
|
|
|
type Triplet = [{
|
|
id: string,
|
|
name: string,
|
|
type: string,
|
|
}, {
|
|
relationship_name: string,
|
|
}, {
|
|
id: string,
|
|
name: string,
|
|
type: string,
|
|
}]
|
|
|
|
function transformInsightsGraphData(triplets: Triplet[]) {
|
|
const nodes: {
|
|
[key: string]: {
|
|
id: string,
|
|
label: string,
|
|
type: string,
|
|
}
|
|
} = {};
|
|
const links: {
|
|
[key: string]: {
|
|
source: string,
|
|
target: string,
|
|
label: string,
|
|
}
|
|
} = {};
|
|
|
|
for (const triplet of triplets) {
|
|
nodes[triplet[0].id] = {
|
|
id: triplet[0].id,
|
|
label: triplet[0].name || triplet[0].id,
|
|
type: triplet[0].type,
|
|
};
|
|
nodes[triplet[2].id] = {
|
|
id: triplet[2].id,
|
|
label: triplet[2].name || triplet[2].id,
|
|
type: triplet[2].type,
|
|
};
|
|
const linkKey = `${triplet[0]["id"]}_${triplet[1]["relationship_name"]}_${triplet[2]["id"]}`;
|
|
links[linkKey] = {
|
|
source: triplet[0].id,
|
|
target: triplet[2].id,
|
|
label: triplet[1]["relationship_name"],
|
|
};
|
|
}
|
|
|
|
return {
|
|
nodes: Object.values(nodes),
|
|
links: Object.values(links),
|
|
};
|
|
}
|