-
+ <>
+
+
+ {contextPopover}
+ }
+ customButtons={[
+ ...(flags.aiSupport
+ ? [
+ ,
+ ]
+ : []),
+ suggestionsLoading && (
+
+ ),
+ ]}
+ />
+
+
+
+ {textFields}
+
+
+
+
+
+
+
+
+
-
+ >
);
};
diff --git a/chainforge/react-server/src/backend/evalgen/executor.ts b/chainforge/react-server/src/backend/evalgen/executor.ts
index 24c7802b1..66501fa84 100644
--- a/chainforge/react-server/src/backend/evalgen/executor.ts
+++ b/chainforge/react-server/src/backend/evalgen/executor.ts
@@ -52,7 +52,7 @@ import { EventEmitter } from "events";
*
* 3. Continue with Other Computations and Interactive Grading:
* You can proceed with other tasks (i.e., grading) immediately after
- * starting the background computation. Use `getNextExampleToScore`
+ * starting the background computation. UseMarkersFromText.ts `getNextExampleToScore`
* to determine which example to grade next and `setGradeForExample`
* to assign grades to specific examples. This interactive grading will
* help in filtering out incorrect evaluation functions.
diff --git a/chainforge/react-server/src/backend/evalgen/utils.ts b/chainforge/react-server/src/backend/evalgen/utils.ts
index 76c4194e7..f10136cc1 100644
--- a/chainforge/react-server/src/backend/evalgen/utils.ts
+++ b/chainforge/react-server/src/backend/evalgen/utils.ts
@@ -1,5 +1,5 @@
// Interfaces and utility functions
-// TODO: Use ChainForge's openai utils (I tried but got errors)
+// TODO: UseMarkersFromText.ts ChainForge's openai utils (I tried but got errors)
// import { AzureOpenAIStreamer } from "./oai_utils";
import { EventEmitter } from "events";
import {
@@ -65,7 +65,7 @@ export async function generateLLMEvaluationCriteria(
llm: string | LLMSpec,
apiKeys?: Dict,
promptTemplate?: string, // overrides prompt template used
- systemMsg?: string | null, // overrides default system message, if present. Use null to specify empty.
+ systemMsg?: string | null, // overrides default system message, if present. UseMarkersFromText.ts null to specify empty.
userFeedback?: { grade: boolean; note?: string; response: string }[], // user feedback to include in the prompt
): Promise
{
// Compose user feedback
diff --git a/chainforge/react-server/src/backend/markerUtils.ts b/chainforge/react-server/src/backend/markerUtils.ts
new file mode 100644
index 000000000..b3b6c82e8
--- /dev/null
+++ b/chainforge/react-server/src/backend/markerUtils.ts
@@ -0,0 +1,65 @@
+import { extractBracketedSubstrings } from "../TemplateHooksComponent";
+
+export class markerUtils {
+ static detectParameter(
+ textSelection: any,
+ fieldValues: Record,
+ markerSet: Set,
+ ): string | null {
+ if (!textSelection) return null;
+
+ const { start, end, id: fieldId } = textSelection;
+ const full = fieldValues[fieldId] ?? "";
+ const slice = full.slice(start, end);
+ const raw = slice.replace(/[{}]/g, "").trim();
+
+ let param: string | null = null;
+
+ // Exact match with a bracketed parameter
+ const inside = extractBracketedSubstrings(slice);
+ if (inside.length === 1 && `{${inside[0]}}` === slice) {
+ param = inside[0];
+ }
+ // Raw text matches a known marker
+ else if (markerSet.has(raw)) {
+ param = raw;
+ }
+ // Selection is inside a larger bracketed parameter
+ else {
+ const allBracketedParams = extractBracketedSubstrings(full) || [];
+
+ for (const bracketedParam of allBracketedParams) {
+ const bracketedSpan = `{${bracketedParam}}`;
+ let searchStart = 0;
+ let bracketedIndex = full.indexOf(bracketedSpan, searchStart);
+
+ while (bracketedIndex !== -1) {
+ const bracketedStart = bracketedIndex;
+ const bracketedEnd = bracketedIndex + bracketedSpan.length;
+
+ if (start >= bracketedStart && end <= bracketedEnd) {
+ if (slice !== bracketedSpan) {
+ param = bracketedParam;
+ break;
+ }
+ }
+
+ searchStart = bracketedIndex + 1;
+ bracketedIndex = full.indexOf(bracketedSpan, searchStart);
+ }
+
+ if (param) break;
+ }
+ }
+
+ return param;
+ }
+
+ static setsAreEqual(setA: Set, setB: Set): boolean {
+ if (setA.size !== setB.size) return false;
+ for (const item of setA) {
+ if (!setB.has(item)) return false;
+ }
+ return true;
+ }
+}
diff --git a/chainforge/react-server/src/backend/suggestUniqueName.ts b/chainforge/react-server/src/backend/suggestUniqueName.ts
new file mode 100644
index 000000000..341fd355c
--- /dev/null
+++ b/chainforge/react-server/src/backend/suggestUniqueName.ts
@@ -0,0 +1,38 @@
+import useStore from "../store";
+import { generateAndReplace } from "./ai";
+
+export async function suggestUniqueName(
+ seed: string,
+ provider: string,
+ apiKeys: any,
+): Promise {
+ const { aiFeaturesProvider, nodes } = useStore.getState();
+ // Ask the model for 3 short suggestions (bullet list)
+ const prompt = `Suggest 1 short, unique, Camel-Case names for a node that groups variations of “${seed}”. Return them as a plain list.`;
+ let response = "";
+ try {
+ const res = await generateAndReplace(
+ prompt,
+ 1,
+ false,
+ aiFeaturesProvider,
+ apiKeys,
+ );
+ response = res[0] || "";
+ } catch {
+ return seed + "Var";
+ }
+
+ const existing = new Set(Object.values(nodes).map((n: any) => n.data?.title));
+ const candidates = response
+ .split(/\n|,|;/)
+ .map((s) => s.replace(/^[\-\d\.\s]*/, "").trim())
+ .filter(Boolean);
+ for (const c of candidates) if (!existing.has(c)) return c;
+
+ // Fallback: append counter
+ let i = 2;
+ let name = seed + i;
+ while (existing.has(name)) name = seed + ++i;
+ return name;
+}
diff --git a/chainforge/react-server/src/backend/useSelectionText.ts b/chainforge/react-server/src/backend/useSelectionText.ts
new file mode 100644
index 000000000..b106e5c30
--- /dev/null
+++ b/chainforge/react-server/src/backend/useSelectionText.ts
@@ -0,0 +1,739 @@
+import { useState, useCallback, useMemo, useEffect, useContext } from "react";
+import { uuid } from "uuidv4";
+import { AlertModalContext } from "../AlertModal";
+import { extractBracketedSubstrings } from "../TemplateHooksComponent";
+import { generateAndReplace } from "./ai";
+import { suggestUniqueName } from "./suggestUniqueName";
+import useStore from "../store";
+import { markerUtils } from "./markerUtils";
+
+export interface TextSelection {
+ start: number;
+ end: number;
+ id: string;
+ anchorX: number;
+ anchorY: number;
+}
+
+interface MarkerLogicOptions {
+ nodeId: string;
+ isPromptNode?: boolean;
+ fieldValues: Record;
+ templateVars: string[];
+ onFieldChange: (fieldId: string, value: string) => void;
+ onTemplateVarsChange: (vars: string[]) => void;
+ onDataUpdate: (data: any) => void;
+ findNodeByParam?: (nodeId: string, param: string) => any;
+}
+
+export const useMarkerLogic = (options: MarkerLogicOptions) => {
+ const {
+ nodeId,
+ fieldValues,
+ isPromptNode,
+ templateVars,
+ onFieldChange,
+ onTemplateVarsChange,
+ onDataUpdate,
+ findNodeByParam,
+ } = options;
+
+ // Store hooks
+ const addNode = useStore((state) => state.addNode);
+ const addEdge = useStore((state) => state.addEdge);
+ const getNode = useStore((state) => state.getNode);
+ const removeNode = useStore((state) => state.removeNode);
+ const setDataPropsForNode = useStore((state) => state.setDataPropsForNode);
+ const pingOutputNodes = useStore((state) => state.pingOutputNodes);
+ const apiKeys = useStore((state) => state.apiKeys);
+ const aiFeaturesProvider = useStore((state) => state.aiFeaturesProvider);
+ const { nodeContexts, setNodeContext } = useStore() as any;
+
+ const showAlert = useContext(AlertModalContext);
+
+ const [markerSet, setMarkerSet] = useState>(
+ new Set(templateVars),
+ );
+ const [markerList, setMarkerList] = useState(templateVars);
+ const [textSelection, setTextSelection] = useState(
+ null,
+ );
+ const [contextDraft, setContextDraft] = useState("");
+ const [numVariants, setNumVariants] = useState("3");
+
+ // Store contexts per parameter, not globally
+ const [paramContexts, setParamContexts] = useState>(
+ {},
+ );
+ const [currentLoadedParam, setCurrentLoadedParam] = useState(
+ null,
+ );
+ const [suggestionsLoading, setSuggestionsLoading] = useState(false);
+
+ // Sync templateVars with local state
+ useEffect(() => {
+ if (!markerUtils.setsAreEqual(new Set(templateVars), markerSet)) {
+ setMarkerSet(new Set(templateVars));
+ setMarkerList(templateVars);
+ }
+ }, [templateVars, markerSet]);
+
+ // Load context when parameter changes
+ useEffect(() => {
+ if (textSelection) {
+ const detectedParam = markerUtils.detectParameter(
+ textSelection,
+ fieldValues,
+ markerSet,
+ );
+ const selectedText = fieldValues[textSelection.id]
+ ?.slice(textSelection.start, textSelection.end)
+ ?.trim();
+ const potentialParam = selectedText?.replace(/[{}]/g, "").trim();
+ const param = detectedParam || potentialParam;
+
+ if (param && param !== currentLoadedParam) {
+ setCurrentLoadedParam(param);
+
+ // Load context for this parameter
+ let contextToLoad = "";
+ if (paramContexts[param]) {
+ contextToLoad = paramContexts[param];
+ } else {
+ // Check existing node context
+ const existingNode = findNodeByParam?.(nodeId, param);
+ if (existingNode && nodeContexts[existingNode.id]) {
+ contextToLoad = nodeContexts[existingNode.id];
+ }
+ }
+
+ setContextDraft(contextToLoad);
+ }
+ }
+ }, [
+ textSelection,
+ fieldValues,
+ markerSet,
+ currentLoadedParam,
+ paramContexts,
+ nodeContexts,
+ findNodeByParam,
+ nodeId,
+ ]);
+
+ const spawnVariationsNode = useCallback(
+ async (
+ param: string,
+ selectedStr: string,
+ n: number,
+ forcedContext?: string,
+ ) => {
+ // ⃣ assemble context
+ let ctx = forcedContext ?? "";
+ const existingNode = findNodeByParam?.(nodeId, param) ?? null;
+ if (!forcedContext) {
+ if (paramContexts[param]) {
+ ctx = paramContexts[param];
+ } else if (existingNode && nodeContexts[existingNode.id]) {
+ ctx = nodeContexts[existingNode.id];
+ }
+ }
+
+ const fullPrompt = ctx
+ ? `${ctx}\n\nRephrase or vary this text: ${selectedStr}`
+ : `Rephrase or vary this text: ${selectedStr}`;
+
+ setSuggestionsLoading(true);
+ let variants: string[];
+ try {
+ variants = await generateAndReplace(
+ fullPrompt,
+ n,
+ false,
+ aiFeaturesProvider,
+ apiKeys,
+ );
+ } catch (err) {
+ console.error("LLM variation failed:", err);
+ return;
+ } finally {
+ setSuggestionsLoading(false);
+ }
+
+ const cleaned = variants.map((v) => {
+ let s = v
+ .trim()
+ .replace(/^Rephrase or vary this text:\s*/i, "")
+ .replace(/^[-\d.]+\s*/, "");
+ if (s.startsWith("{") && s.endsWith("}")) {
+ s = s.slice(1, -1).trim();
+ }
+ return s;
+ });
+
+ // Handle based on node type
+ const node = getNode(nodeId);
+ if (!node) return;
+
+ const newFields: Record = {};
+ cleaned.forEach((s, i) => (newFields[`f${i}`] = s));
+
+ if (existingNode) {
+ setDataPropsForNode(existingNode.id, { fields: newFields });
+ pingOutputNodes(existingNode.id);
+ return;
+ }
+
+ const { x, y } = node.position;
+ let title: string;
+ try {
+ title =
+ (await suggestUniqueName(param, aiFeaturesProvider, apiKeys)) ||
+ param;
+ } catch {
+ title = param;
+ }
+
+ const newID = `textfields-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+ addNode({
+ id: newID,
+ type: "textfields",
+ position: { x: x + 150, y: y + Math.random() * 100 },
+ data: { title, fields: newFields },
+ });
+
+ addEdge({
+ id: uuid(),
+ source: newID,
+ target: nodeId,
+ sourceHandle: "output",
+ targetHandle: param,
+ });
+
+ if (ctx.trim()) setNodeContext?.(newID, ctx);
+ },
+ [
+ nodeId,
+ isPromptNode,
+ paramContexts,
+ nodeContexts,
+ aiFeaturesProvider,
+ apiKeys,
+ getNode,
+ findNodeByParam,
+ setDataPropsForNode,
+ pingOutputNodes,
+ addNode,
+ addEdge,
+ setNodeContext,
+ ],
+ );
+
+ const createParam = useCallback(
+ (nVariants: string) => {
+ if (!textSelection) {
+ return;
+ }
+
+ const { start, end, id: fieldId } = textSelection;
+
+ const actualFieldId = isPromptNode ? "prompt" : fieldId;
+ const text = isPromptNode
+ ? fieldValues.prompt || ""
+ : fieldValues[fieldId] || "";
+
+ const selectedText = text.slice(start, end);
+ const cleanSelected = selectedText.replace(/[{}]/g, "").trim();
+
+ // BLOCK ANY HALF‐BRACE SELECTIONS OR STRAY BRACES INSIDE
+ if (/[{}]/.test(selectedText) && !/^{[^{}]+}$/.test(selectedText)) {
+ showAlert?.("Cannot select partial or stray braces.");
+ setTextSelection(null);
+ return;
+ }
+
+ // EMPTY selection check
+ if (!cleanSelected) {
+ setTextSelection(null);
+ return;
+ }
+
+ const tokensInSelection = extractBracketedSubstrings(selectedText) || [];
+ const allTokensInText = extractBracketedSubstrings(text) || [];
+
+ // EXACT REFRESH: user selected entire braced token
+ if (
+ tokensInSelection.length === 1 &&
+ selectedText === `{${tokensInSelection[0]}}`
+ ) {
+ spawnVariationsNode(
+ tokensInSelection[0],
+ tokensInSelection[0],
+ Number(nVariants),
+ contextDraft,
+ );
+ setTextSelection(null);
+ return;
+ }
+
+ // PLAIN REFRESH - user selected existing marker text without braces
+ if (!tokensInSelection.length && markerSet.has(cleanSelected)) {
+ spawnVariationsNode(
+ cleanSelected,
+ cleanSelected,
+ Number(nVariants),
+ contextDraft,
+ );
+ setTextSelection(null);
+ return;
+ }
+
+ // SPLIT OPERATION - user selected part of existing marker content
+ let splitMarker: string | null = null;
+ let markerStartPos = -1;
+ let markerEndPos = -1;
+
+ // Find if selection is inside an existing marker
+ for (const token of allTokensInText) {
+ const markerText = `{${token}}`;
+ let searchPos = 0;
+ let markerPos;
+
+ while ((markerPos = text.indexOf(markerText, searchPos)) !== -1) {
+ const markerStart = markerPos;
+ const markerEnd = markerPos + markerText.length;
+ const contentStart = markerStart + 1; // After opening brace
+ const contentEnd = markerEnd - 1; // Before closing brace
+
+ // Check if our selection is ENTIRELY within the marker content (not including braces)
+ if (start >= contentStart && end <= contentEnd) {
+ // Additional check: make sure the selected text is actually part of the token content
+ const tokenContent = token;
+ if (tokenContent.includes(cleanSelected)) {
+ splitMarker = token;
+ markerStartPos = markerStart;
+ markerEndPos = markerEnd;
+ break;
+ }
+ }
+ searchPos = markerPos + 1;
+ }
+ if (splitMarker) break;
+ }
+
+ if (splitMarker && markerStartPos >= 0) {
+ // Find where the selected text appears in the marker content
+ const splitIndex = splitMarker.indexOf(cleanSelected);
+ if (splitIndex === -1) {
+ console.error("Selected text not found in marker content");
+ setTextSelection(null);
+ return;
+ }
+
+ const leftPart = splitMarker.slice(0, splitIndex);
+ const rightPart = splitMarker.slice(splitIndex + cleanSelected.length);
+
+ let leftMarker = leftPart || "left";
+ let rightMarker = cleanSelected;
+
+ // Ensure uniqueness
+ let counter = 2;
+ while (markerSet.has(leftMarker) && leftMarker !== splitMarker) {
+ leftMarker = `${leftPart || "left"}_${counter++}`;
+ }
+ counter = 2;
+ while (markerSet.has(rightMarker) && rightMarker !== splitMarker) {
+ rightMarker = `${cleanSelected}_${counter++}`;
+ }
+
+ // Store context for the split markers
+ const contextToPreserve =
+ contextDraft.trim() || paramContexts[splitMarker] || "";
+
+ // Build replacement - preserve original order
+ const replacement = leftPart
+ ? `{${leftMarker}}{${rightMarker}}`
+ : `{${rightMarker}}`;
+
+ const updatedText =
+ text.slice(0, markerStartPos) +
+ replacement +
+ text.slice(markerEndPos);
+
+ onFieldChange(actualFieldId, updatedText);
+
+ // Recalculate markers
+ const newMarkers = Array.from(
+ new Set(extractBracketedSubstrings(updatedText) || []),
+ );
+ setMarkerSet(new Set(newMarkers));
+ setMarkerList(newMarkers);
+ onTemplateVarsChange(newMarkers);
+
+ // Update data based on node type
+ if (isPromptNode) {
+ onDataUpdate({ vars: newMarkers, prompt: updatedText });
+ } else {
+ const updatedFields = {
+ ...fieldValues,
+ [actualFieldId]: updatedText,
+ };
+ onDataUpdate({ vars: newMarkers, fields: updatedFields });
+ }
+
+ const store = useStore.getState();
+ const oldNode = store.nodes.find((n) => n.data?.title === splitMarker);
+ if (oldNode) {
+ removeNode(oldNode.id);
+ }
+
+ const markersToCreate = leftPart
+ ? [leftMarker, rightMarker]
+ : [rightMarker];
+
+ // Store contexts for the new markers
+ markersToCreate.forEach((markerName) => {
+ if (contextToPreserve) {
+ setParamContexts((prev) => ({
+ ...prev,
+ [markerName]: contextToPreserve,
+ }));
+ }
+ });
+
+ // Remove old marker context
+ setParamContexts((prev) => {
+ const newContexts = { ...prev };
+ if (splitMarker !== null) {
+ delete newContexts[splitMarker];
+ }
+ return newContexts;
+ });
+
+ // Wait for React to process updates, then create nodes
+ setTimeout(() => {
+ const parent = getNode(nodeId);
+ if (!parent) return;
+
+ const { x, y } = parent.position;
+
+ markersToCreate.forEach((markerName, index) => {
+ // Generate variations to create the node with proper content
+ spawnVariationsNode(
+ markerName,
+ markerName,
+ Number(nVariants),
+ contextToPreserve,
+ );
+ });
+ }, 100);
+
+ setTextSelection(null);
+ return;
+ }
+
+ // NEW PARAMETER - create new marker from selection
+ let uniqueName = cleanSelected;
+ let counter = 2;
+ while (markerSet.has(uniqueName)) {
+ uniqueName = `${cleanSelected}_${counter++}`;
+ }
+
+ if (contextDraft.trim()) {
+ setParamContexts((prev) => ({
+ ...prev,
+ [uniqueName]: contextDraft.trim(),
+ }));
+ }
+
+ const updatedText =
+ text.slice(0, start) + `{${uniqueName}}` + text.slice(end);
+
+ onFieldChange(actualFieldId, updatedText);
+
+ const newMarkerSet = new Set([...markerSet, uniqueName]);
+ const newMarkerList = [...markerList, uniqueName];
+
+ setMarkerSet(newMarkerSet);
+ setMarkerList(newMarkerList);
+ onTemplateVarsChange(newMarkerList);
+
+ if (isPromptNode) {
+ onDataUpdate({ vars: newMarkerList, prompt: updatedText });
+ } else {
+ const updatedFields = { ...fieldValues, [actualFieldId]: updatedText };
+ onDataUpdate({ vars: newMarkerList, fields: updatedFields });
+ }
+
+ // Wait for marker updates to be processed, then generate variations
+ setTimeout(() => {
+ spawnVariationsNode(
+ uniqueName,
+ uniqueName,
+ Number(nVariants),
+ contextDraft,
+ );
+ }, 100);
+
+ setTextSelection(null);
+ },
+ [
+ textSelection,
+ fieldValues,
+ markerSet,
+ markerList,
+ spawnVariationsNode,
+ showAlert,
+ onFieldChange,
+ onTemplateVarsChange,
+ onDataUpdate,
+ nodeId,
+ removeNode,
+ contextDraft,
+ paramContexts,
+ setParamContexts,
+ getNode,
+ isPromptNode,
+ ],
+ );
+
+ // Rest of the code remains the same...
+ const selectionPreview = useMemo(() => {
+ if (!textSelection) return "";
+ const fv = fieldValues[textSelection.id] ?? "";
+ return fv
+ .slice(textSelection.start, textSelection.end)
+ .trim()
+ .slice(0, 120);
+ }, [textSelection, fieldValues]);
+
+ const getCaretCoords = useCallback(
+ (textarea: HTMLTextAreaElement, pos: number) => {
+ const div = document.createElement("div");
+ const style = window.getComputedStyle(textarea);
+
+ [
+ "fontFamily",
+ "fontSize",
+ "fontWeight",
+ "fontStyle",
+ "letterSpacing",
+ "textTransform",
+ "wordSpacing",
+ "whiteSpace",
+ "lineHeight",
+ "padding",
+ "border",
+ "boxSizing",
+ ].forEach((p) => (div.style[p as any] = style[p as any]));
+
+ div.style.position = "absolute";
+ div.style.visibility = "hidden";
+ div.style.whiteSpace = "pre-wrap";
+ div.style.width = `${textarea.clientWidth}px`;
+ document.body.appendChild(div);
+
+ const text = textarea.value;
+ const before = text.slice(0, pos);
+ const after = text.slice(pos);
+ div.textContent = before;
+
+ const span = document.createElement("span");
+ span.textContent = "\u200b";
+ div.appendChild(span);
+ div.appendChild(document.createTextNode(after || "\u200b"));
+
+ const { offsetLeft: left, offsetTop: top, offsetHeight: height } = span;
+ document.body.removeChild(div);
+ return { top, left, height };
+ },
+ [],
+ );
+
+ const handleMouseUp = useCallback(
+ (
+ e: React.MouseEvent,
+ nodeRef: React.RefObject,
+ ) => {
+ const textarea = e.currentTarget;
+ const { selectionStart, selectionEnd, id } = textarea;
+
+ if (selectionStart === selectionEnd) {
+ setTextSelection(null);
+ return;
+ }
+
+ const calculateToolbarPosition = () => {
+ const midPos = Math.floor((selectionStart + selectionEnd) / 2);
+ const caret = getCaretCoords(textarea, midPos);
+
+ const textareaRect = textarea.getBoundingClientRect();
+ const nodeRect = nodeRef.current?.getBoundingClientRect() || {
+ left: 0,
+ top: 0,
+ };
+
+ const absoluteX = textareaRect.left + caret.left;
+ const TOOLBAR_OFFSET = 10;
+ const absoluteY =
+ textareaRect.top + caret.top - caret.height - TOOLBAR_OFFSET;
+
+ return {
+ x: absoluteX - nodeRect.left,
+ y: absoluteY - nodeRect.top,
+ };
+ };
+
+ const { x, y } = calculateToolbarPosition();
+
+ setTextSelection({
+ start: selectionStart,
+ end: selectionEnd,
+ id,
+ anchorX: x,
+ anchorY: y,
+ });
+ },
+ [getCaretCoords],
+ );
+
+ useEffect(() => {
+ const unsub = useStore.subscribe((state, prev) => {
+ const prevIds = new Set(prev.nodes.map((n) => n.id));
+ const currIds = new Set(state.nodes.map((n) => n.id));
+
+ const removed = [...prevIds].filter((id) => !currIds.has(id));
+ const added = [...currIds].filter((id) => !prevIds.has(id));
+
+ if (removed.length > 0 && added.length === 0) {
+ const removedMarkers = removed.flatMap((removedId) => {
+ const edge = prev.edges.find(
+ (e) => e.source === removedId && e.target === nodeId,
+ );
+ if (edge?.targetHandle) return edge.targetHandle;
+ const title = prev.nodes.find((n) => n.id === removedId)?.data?.title;
+ return title ? title : [];
+ });
+
+ const unique = Array.from(new Set(removedMarkers));
+ if (!unique.length) return;
+
+ const updated: Record = { ...fieldValues };
+ unique.forEach((marker) => {
+ const re = new RegExp(`\\{${marker}\\}`, "g");
+ Object.keys(updated).forEach((key) => {
+ updated[key] = updated[key].replace(re, marker);
+ });
+ });
+
+ const newSet = new Set();
+ Object.values(updated).forEach((txt) =>
+ extractBracketedSubstrings(txt)?.forEach((v) => newSet.add(v)),
+ );
+ const newList = Array.from(newSet);
+
+ if (!markerUtils.setsAreEqual(markerSet, newSet)) {
+ setMarkerSet(newSet);
+ setMarkerList(newList);
+ onTemplateVarsChange(newList);
+
+ if (isPromptNode) {
+ onFieldChange("prompt", updated["prompt"]);
+ onDataUpdate({
+ vars: newList,
+ prompt: updated["prompt"],
+ });
+ } else {
+ onDataUpdate({
+ vars: newList,
+ fields: updated,
+ });
+ }
+
+ setParamContexts((ctx) => {
+ const newCtx = { ...ctx };
+ unique.forEach((m) => delete newCtx[m]);
+ return newCtx;
+ });
+ }
+ }
+ });
+ return () => unsub();
+ }, [
+ fieldValues,
+ markerSet,
+ isPromptNode,
+ onTemplateVarsChange,
+ onDataUpdate,
+ setParamContexts,
+ nodeId,
+ onFieldChange,
+ ]);
+
+ const handleGenerate = useCallback(() => {
+ if (!textSelection) {
+ return;
+ }
+
+ const detectedParam = markerUtils.detectParameter(
+ textSelection,
+ fieldValues,
+ markerSet,
+ );
+ const selectedText = fieldValues[textSelection.id]
+ ?.slice(textSelection.start, textSelection.end)
+ ?.trim();
+ const potentialParam = selectedText?.replace(/[{}]/g, "").trim();
+
+ const param = detectedParam || potentialParam;
+
+ if (param && contextDraft.trim()) {
+ setParamContexts((prev) => ({
+ ...prev,
+ [param]: contextDraft.trim(),
+ }));
+
+ if (findNodeByParam) {
+ const bundleNode = findNodeByParam(nodeId, param);
+ if (bundleNode && setNodeContext) {
+ setNodeContext(bundleNode.id, contextDraft.trim());
+ }
+ }
+ }
+
+ createParam(numVariants);
+ }, [
+ textSelection,
+ fieldValues,
+ markerSet,
+ contextDraft,
+ numVariants,
+ createParam,
+ findNodeByParam,
+ nodeId,
+ setNodeContext,
+ currentLoadedParam,
+ setParamContexts,
+ ]);
+
+ return {
+ textSelection,
+ contextDraft,
+ setContextDraft,
+ numVariants,
+ setNumVariants,
+ markerSet,
+ markerList,
+ suggestionsLoading,
+ selectionPreview,
+ handleMouseUp,
+ handleGenerate,
+ setTextSelection,
+ getCaretCoords,
+ spawnVariationsNode,
+ createParam,
+ paramContexts,
+ currentLoadedParam,
+ };
+};
diff --git a/chainforge/react-server/src/store.tsx b/chainforge/react-server/src/store.tsx
index bb381416e..acec11de5 100644
--- a/chainforge/react-server/src/store.tsx
+++ b/chainforge/react-server/src/store.tsx
@@ -36,6 +36,19 @@ import { StringLookup } from "./backend/cache";
import { saveGlobalConfig } from "./backend/backend";
const IS_RUNNING_LOCALLY = APP_IS_RUNNING_LOCALLY();
+export interface SelectionContext {
+ fieldId: string;
+ start: number;
+ end: number;
+ anchorX: number;
+ anchorY: number;
+}
+
+interface SelectionSlice {
+ textSelection: SelectionContext | null;
+ setTextSelection(sel: SelectionContext | null): void;
+}
+
// Initial project settings
const initialAPIKeys = {};
const initialFlags = { aiSupport: true };
@@ -411,6 +424,7 @@ export interface StoreHandles {
// Helper functions for nodes and edges
getNode: (id: string) => Node | undefined;
addNode: (newnode: Node) => void;
+ addEdge: (edge: Edge | Connection) => void;
removeNode: (id: string) => void;
deselectAllNodes: () => void;
bringNodeToFront: (id: string) => void;
@@ -422,6 +436,10 @@ export interface StoreHandles {
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection | Edge) => void;
+ nodeContexts: Record;
+ setNodeContext: (nodeId: string, context: string) => void;
+ setNodeTitle: (nodeId: string, newTitle: string) => void;
+ deleteNodeByTitle: (title: string) => void;
// The LLM providers available in the drop-down list
AvailableLLMs: LLMSpec[];
setAvailableLLMs: (specs: LLMSpec[]) => void;
@@ -504,13 +522,14 @@ export interface StoreHandles {
node_id: string,
) => Dict;
}
-
+type FullStore = StoreHandles & SelectionSlice;
// A global store of variables, used for maintaining state
// across ChainForge and ReactFlow components.
-const useStore = create((set, get) => ({
+const useStore = create((set, get) => ({
nodes: [],
edges: [],
-
+ textSelection: null,
+ setTextSelection: (sel) => set({ textSelection: sel }),
// Available LLMs in ChainForge, in the format expected by LLMListItems.
AvailableLLMs: [...initLLMProviders],
setAvailableLLMs: (llmProviderList) => {
@@ -521,6 +540,24 @@ const useStore = create((set, get) => ({
setAIFeaturesProvider: (llmProvider) => {
set({ aiFeaturesProvider: llmProvider });
},
+ nodeContexts: {} as Record,
+ setNodeContext: (nodeId: string, context: string) => {
+ set((state) => ({
+ nodeContexts: { ...state.nodeContexts, [nodeId]: context },
+ }));
+ },
+ setNodeTitle: (nodeId: string, newTitle: string) =>
+ set((state) => {
+ const node = state.nodes.find((n) => n.id === nodeId);
+ if (node) node.data = { ...node.data, title: newTitle };
+ return { nodes: state.nodes.map((n) => (n.id === nodeId ? node! : n)) };
+ }),
+ deleteNodeByTitle: (title: string) => {
+ const st = useStore.getState();
+ Object.values(st.nodes).forEach((n: any) => {
+ if (n.data?.title === title) st.removeNode?.(n.id);
+ });
+ },
// Keeping track of LLM API keys
apiKeys: initialAPIKeys,
@@ -1008,11 +1045,25 @@ const useStore = create((set, get) => ({
nodes: get().nodes.concat(newnode),
});
},
- removeNode: (id) => {
- set({
- nodes: get().nodes.filter((n) => n.id !== id),
+ removeNode: (nodeId: string) => {
+ const node = get().nodes.find((n) => n.id === nodeId);
+ set((state) => {
+ // remove from nodes
+ return { nodes: state.nodes.filter((n) => n.id !== nodeId) };
});
+ if (node?.type === "textfields") {
+ } else {
+ const param = node?.data?.title || node?.id;
+ const listeners = (get() as any)._listeners;
+ if (listeners && Array.isArray(listeners)) {
+ listeners.forEach(
+ (l: (event: string, nodeId: string, param: string) => void) =>
+ l("nodeRemoved", nodeId, param),
+ );
+ }
+ }
},
+
deselectAllNodes: () => {
// Deselect all nodes
set({
@@ -1052,6 +1103,10 @@ const useStore = create((set, get) => ({
nodes: newnodes,
});
},
+ addEdge: (edge) =>
+ set((state) => ({
+ edges: addEdge(edge, state.edges),
+ })),
setEdges: (newedges) => {
set({
edges: newedges,
From ed38e6f600ebc4b4071853c34e7ed4033b2f96d1 Mon Sep 17 00:00:00 2001
From: mildshield14 <80240232+mildshield14@users.noreply.github.com>
Date: Mon, 14 Jul 2025 17:41:21 -0400
Subject: [PATCH 2/4] delete intellij folder
---
.idea/.gitignore | 8 --------
.idea/ChainForge.iml | 9 ---------
.idea/discord.xml | 14 --------------
.idea/google-java-format.xml | 6 ------
.idea/inspectionProfiles/Project_Default.xml | 6 ------
.idea/misc.xml | 6 ------
.idea/modules.xml | 8 --------
.idea/prettier.xml | 6 ------
.idea/vcs.xml | 6 ------
9 files changed, 69 deletions(-)
delete mode 100644 .idea/.gitignore
delete mode 100644 .idea/ChainForge.iml
delete mode 100644 .idea/discord.xml
delete mode 100644 .idea/google-java-format.xml
delete mode 100644 .idea/inspectionProfiles/Project_Default.xml
delete mode 100644 .idea/misc.xml
delete mode 100644 .idea/modules.xml
delete mode 100644 .idea/prettier.xml
delete mode 100644 .idea/vcs.xml
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b81b..000000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/ChainForge.iml b/.idea/ChainForge.iml
deleted file mode 100644
index d6ebd4805..000000000
--- a/.idea/ChainForge.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index 5faa6e2d6..000000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/google-java-format.xml b/.idea/google-java-format.xml
deleted file mode 100644
index 2aa056da3..000000000
--- a/.idea/google-java-format.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 03d9549ea..000000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 862d09bd6..000000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index ae8d9cebe..000000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
deleted file mode 100644
index b0c1c68fb..000000000
--- a/.idea/prettier.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddfb..000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
From e9c2803123d552ee24bdf813e2e0aa4298db6d88 Mon Sep 17 00:00:00 2001
From: mildshield14 <80240232+mildshield14@users.noreply.github.com>
Date: Mon, 14 Jul 2025 17:54:47 -0400
Subject: [PATCH 3/4] fix issue with replace text
---
.idea/workspace.xml | 138 ++++++++++++++++++
.../src/EvalGen/PickCriteriaStep.tsx | 2 +-
.../react-server/src/EvalGen/WelcomeStep.tsx | 2 +-
.../src/backend/evalgen/executor.ts | 2 +-
.../react-server/src/backend/evalgen/utils.ts | 4 +-
5 files changed, 143 insertions(+), 5 deletions(-)
create mode 100644 .idea/workspace.xml
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 000000000..cf0008587
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "lastFilter": {
+ "state": "OPEN",
+ "assignee": "mildshield14"
+ }
+}
+ {
+ "selectedUrlAndAccountId": {
+ "url": "https://github.com/mildshield14/ChainForge.git",
+ "accountId": "e7a4f645-d6a0-423a-91aa-2ca3d87e947e"
+ }
+}
+
+
+
+
+ {
+ "associatedIndex": 7
+}
+
+
+
+
+
+ {
+ "keyToString": {
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "git-widget-placeholder": "main",
+ "kotlin-language-version-configured": "true",
+ "last_opened_file_path": "/Users/vennilasooben/chainforge-default/ChainForge",
+ "node.js.detected.package.tslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.standard": "",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
+ "npm.start.executor": "Run",
+ "settings.editor.selected.configurable": "preferences.pluginManager",
+ "ts.external.directory.path": "/Users/vennilasooben/chainforge-default/ChainForge/chainforge/react-server/node_modules/typescript/lib",
+ "vue.rearranger.settings.migration": "true"
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1748398791802
+
+
+ 1748398791802
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chainforge/react-server/src/EvalGen/PickCriteriaStep.tsx b/chainforge/react-server/src/EvalGen/PickCriteriaStep.tsx
index 3e0356e3a..cd4e908fe 100644
--- a/chainforge/react-server/src/EvalGen/PickCriteriaStep.tsx
+++ b/chainforge/react-server/src/EvalGen/PickCriteriaStep.tsx
@@ -165,7 +165,7 @@ export const CriteriaCard: React.FC = function CriteriaCard({
onKeyUp={(e) => e.preventDefault()}
className="checkcard"
>
-
+
setCheckedAndRealign(!checked)}
diff --git a/chainforge/react-server/src/EvalGen/WelcomeStep.tsx b/chainforge/react-server/src/EvalGen/WelcomeStep.tsx
index 875482a07..fe50ae4ef 100644
--- a/chainforge/react-server/src/EvalGen/WelcomeStep.tsx
+++ b/chainforge/react-server/src/EvalGen/WelcomeStep.tsx
@@ -69,7 +69,7 @@ const WelcomeStep: React.FC = ({ setOnNextCallback }) => (
{/* We have captured the following about your context:
- …
- - [x] UseMarkersFromText.ts this info when helping me think of evaluation criteria
+ - [x] Use this info when helping me think of evaluation criteria
*/}
After EvalGen finishes, the chosen evaluators appear in the MultiEval
diff --git a/chainforge/react-server/src/backend/evalgen/executor.ts b/chainforge/react-server/src/backend/evalgen/executor.ts
index 66501fa84..24c7802b1 100644
--- a/chainforge/react-server/src/backend/evalgen/executor.ts
+++ b/chainforge/react-server/src/backend/evalgen/executor.ts
@@ -52,7 +52,7 @@ import { EventEmitter } from "events";
*
* 3. Continue with Other Computations and Interactive Grading:
* You can proceed with other tasks (i.e., grading) immediately after
- * starting the background computation. UseMarkersFromText.ts `getNextExampleToScore`
+ * starting the background computation. Use `getNextExampleToScore`
* to determine which example to grade next and `setGradeForExample`
* to assign grades to specific examples. This interactive grading will
* help in filtering out incorrect evaluation functions.
diff --git a/chainforge/react-server/src/backend/evalgen/utils.ts b/chainforge/react-server/src/backend/evalgen/utils.ts
index f10136cc1..76c4194e7 100644
--- a/chainforge/react-server/src/backend/evalgen/utils.ts
+++ b/chainforge/react-server/src/backend/evalgen/utils.ts
@@ -1,5 +1,5 @@
// Interfaces and utility functions
-// TODO: UseMarkersFromText.ts ChainForge's openai utils (I tried but got errors)
+// TODO: Use ChainForge's openai utils (I tried but got errors)
// import { AzureOpenAIStreamer } from "./oai_utils";
import { EventEmitter } from "events";
import {
@@ -65,7 +65,7 @@ export async function generateLLMEvaluationCriteria(
llm: string | LLMSpec,
apiKeys?: Dict,
promptTemplate?: string, // overrides prompt template used
- systemMsg?: string | null, // overrides default system message, if present. UseMarkersFromText.ts null to specify empty.
+ systemMsg?: string | null, // overrides default system message, if present. Use null to specify empty.
userFeedback?: { grade: boolean; note?: string; response: string }[], // user feedback to include in the prompt
): Promise {
// Compose user feedback
From 39b81dec1262a041b44b890bb4028ededf4e8a27 Mon Sep 17 00:00:00 2001
From: mildshield14 <80240232+mildshield14@users.noreply.github.com>
Date: Mon, 14 Jul 2025 18:00:03 -0400
Subject: [PATCH 4/4] remove intellij file
---
.idea/workspace.xml | 138 --------------------------------------------
1 file changed, 138 deletions(-)
delete mode 100644 .idea/workspace.xml
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index cf0008587..000000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,138 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- "lastFilter": {
- "state": "OPEN",
- "assignee": "mildshield14"
- }
-}
- {
- "selectedUrlAndAccountId": {
- "url": "https://github.com/mildshield14/ChainForge.git",
- "accountId": "e7a4f645-d6a0-423a-91aa-2ca3d87e947e"
- }
-}
-
-
-
-
- {
- "associatedIndex": 7
-}
-
-
-
-
-
- {
- "keyToString": {
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "git-widget-placeholder": "main",
- "kotlin-language-version-configured": "true",
- "last_opened_file_path": "/Users/vennilasooben/chainforge-default/ChainForge",
- "node.js.detected.package.tslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.standard": "",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_package_manager_path": "npm",
- "npm.start.executor": "Run",
- "settings.editor.selected.configurable": "preferences.pluginManager",
- "ts.external.directory.path": "/Users/vennilasooben/chainforge-default/ChainForge/chainforge/react-server/node_modules/typescript/lib",
- "vue.rearranger.settings.migration": "true"
- }
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1748398791802
-
-
- 1748398791802
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file