fix: not able to open prompt editor by click prompt tree

This commit is contained in:
linonetwo 2025-12-12 23:19:55 +08:00
parent c825dc5eab
commit bdd2496be2
11 changed files with 214 additions and 108 deletions

View file

@ -23,7 +23,6 @@ export const previewActionsMiddleware: StateCreator<AgentChatStoreType, [], [],
previewDialogOpen: false,
previewDialogBaseMode: 'preview',
lastUpdated: null,
expandedArrayItems: new Map(),
formFieldsToScrollTo: [],
});
},
@ -35,34 +34,6 @@ export const previewActionsMiddleware: StateCreator<AgentChatStoreType, [], [],
setFormFieldsToScrollTo: (fieldPaths: string[]) => {
set({ formFieldsToScrollTo: fieldPaths });
},
setArrayItemExpanded: (itemId: string, expanded: boolean) => {
const { expandedArrayItems } = get();
const newMap = new Map(expandedArrayItems);
if (expanded) {
newMap.set(itemId, true);
} else {
newMap.delete(itemId);
}
set({ expandedArrayItems: newMap });
},
isArrayItemExpanded: (itemId: string) => {
const { expandedArrayItems } = get();
return expandedArrayItems.get(itemId) ?? false;
},
expandPathToTarget: (targetPath: string[]) => {
const { expandedArrayItems } = get();
const newMap = new Map(expandedArrayItems);
// For a path like ['prompts', 'system', 'children', 'default-main']
// We need to expand each ID that represents an array item: 'system' and 'default-main'
for (let index = 1; index < targetPath.length; index += 1) {
if (targetPath[index]) {
newMap.set(targetPath[index], true);
}
}
set({ expandedArrayItems: newMap });
},
updatePreviewProgress: (progress: number, step: string, currentPlugin?: string) => {
set({

View file

@ -28,7 +28,6 @@ export const useAgentChatStore = create<AgentChatStoreType>()((set, get, api) =>
previewResult: null,
lastUpdated: null,
formFieldsToScrollTo: [],
expandedArrayItems: new Map(),
};
// Merge all actions and initial state

View file

@ -39,7 +39,6 @@ export interface PreviewDialogState {
} | null;
lastUpdated: Date | null;
formFieldsToScrollTo: string[];
expandedArrayItems: Map<string, boolean>;
}
// Basic actions interface
@ -159,25 +158,6 @@ export interface PreviewActions {
*/
setFormFieldsToScrollTo: (fieldPaths: string[]) => void;
/**
* Sets the expansion state of a specific array item by its ID
* @param itemId The unique ID of the array item
* @param expanded Whether the item should be expanded
*/
setArrayItemExpanded: (itemId: string, expanded: boolean) => void;
/**
* Checks if a specific array item is expanded by its ID
* @param itemId The unique ID of the array item
*/
isArrayItemExpanded: (itemId: string) => boolean;
/**
* Expands all parent paths leading to a target field
* @param targetPath The target field path to expand to
*/
expandPathToTarget: (targetPath: string[]) => void;
/**
* Updates preview progress state
* @param progress Progress value from 0 to 1

View file

@ -11,6 +11,7 @@ import { useShallow } from 'zustand/react/shallow';
import { AgentFrameworkConfig } from '@services/agentInstance/promptConcat/promptConcatSchema';
import { useAgentChatStore } from '../../../Agent/store/agentChatStore/index';
import { PromptConfigForm } from './PromptConfigForm';
import { useArrayFieldStore } from './PromptConfigForm/store/arrayFieldStore';
// Lazy load Monaco Editor only when needed
const MonacoEditor = lazy(async () => await import('@monaco-editor/react'));
@ -34,14 +35,16 @@ export const EditView: FC<EditViewProps> = ({
const [editorMode, setEditorMode] = useState<'form' | 'code'>('form');
const [monacoInitialized, setMonacoInitialized] = useState(false);
const { formFieldsToScrollTo, setFormFieldsToScrollTo, expandPathToTarget } = useAgentChatStore(
const { formFieldsToScrollTo, setFormFieldsToScrollTo } = useAgentChatStore(
useShallow((state) => ({
formFieldsToScrollTo: state.formFieldsToScrollTo,
setFormFieldsToScrollTo: state.setFormFieldsToScrollTo,
expandPathToTarget: state.expandPathToTarget,
})),
);
// Use a stable reference for expandItemsByPath
const expandItemsByPath = useArrayFieldStore(useCallback((state) => state.expandItemsByPath, []));
const {
loading: agentFrameworkConfigLoading,
config: agentFrameworkConfig,
@ -52,26 +55,66 @@ export const EditView: FC<EditViewProps> = ({
agentId: agent?.id,
});
// Use a ref to track if we're currently processing a scroll request
const isProcessingScrollReference = React.useRef(false);
const savedPathReference = React.useRef<string[]>([]);
useEffect(() => {
if (formFieldsToScrollTo.length > 0 && editorMode === 'form') {
expandPathToTarget(formFieldsToScrollTo);
if (formFieldsToScrollTo.length > 0 && editorMode === 'form' && agentFrameworkConfig && !isProcessingScrollReference.current) {
// Mark as processing and save the path
isProcessingScrollReference.current = true;
savedPathReference.current = [...formFieldsToScrollTo];
const savedPath = savedPathReference.current;
// Clear formFieldsToScrollTo - but don't let the cleanup cancel our timeouts
setFormFieldsToScrollTo([]);
// Path format: ['prompts', 'system', 'child-id', 'child-id'] or ['prompts', 'system']
// - savedPath[0]: top-level key (prompts, plugins, response)
// - savedPath[1]: parent item id
// - savedPath[2+]: nested child item ids (if present)
// Step 1: Expand the top-level item first
setTimeout(() => {
const topLevelKey = savedPath[0];
if (savedPath.length > 1) {
const firstItemId = savedPath[1];
expandItemsByPath(topLevelKey, [firstItemId]);
}
}, 100);
// Step 2: After the parent expands and children render, expand nested items
// If path has more than 2 elements, we have nested children to expand
const hasNestedChildren = savedPath.length > 2;
if (hasNestedChildren) {
setTimeout(() => {
const topLevelKey = savedPath[0];
const firstItemId = savedPath[1];
const topLevelArray = agentFrameworkConfig[topLevelKey as keyof typeof agentFrameworkConfig];
if (Array.isArray(topLevelArray)) {
const parentIndex = topLevelArray.findIndex((item: unknown) => {
const data = item as Record<string, unknown> | null;
return data?.id === firstItemId || data?.caption === firstItemId || data?.title === firstItemId;
});
if (parentIndex !== -1) {
const nestedFieldPath = `${topLevelKey}_${parentIndex}_children`;
// Get the nested item IDs (from savedPath[2] onwards)
const nestedItemIds = savedPath.slice(2);
expandItemsByPath(nestedFieldPath, nestedItemIds);
}
}
}, 300); // Longer delay to wait for nested array to render
}
const scrollTimeout = setTimeout(() => {
const targetId = formFieldsToScrollTo[formFieldsToScrollTo.length - 1];
// Step 3: Scroll to the target element (after nested items have expanded)
const scrollDelay = hasNestedChildren ? 500 : 200;
setTimeout(() => {
const targetId = savedPath[savedPath.length - 1];
// Find input element whose value exactly matches the target ID
const targetElement = document.querySelector(`input[value="${targetId}"]`);
if (targetElement) {
// Expand parent accordions
let current = targetElement.parentElement;
while (current) {
const accordion = current.querySelector('[aria-expanded="false"]');
if (accordion instanceof HTMLElement) {
accordion.click();
}
current = current.parentElement;
}
// Scroll to element and highlight
setTimeout(() => {
if (targetElement instanceof HTMLElement) {
@ -84,15 +127,12 @@ export const EditView: FC<EditViewProps> = ({
}
}, 300);
}
setFormFieldsToScrollTo([]);
}, 100);
return () => {
clearTimeout(scrollTimeout);
};
// Mark processing as complete
isProcessingScrollReference.current = false;
}, scrollDelay);
}
}, [formFieldsToScrollTo, editorMode, setFormFieldsToScrollTo, expandPathToTarget]);
}, [formFieldsToScrollTo, editorMode, expandItemsByPath, agentFrameworkConfig, setFormFieldsToScrollTo]);
const { getPreviewPromptResult } = useAgentChatStore(
useShallow((state) => ({

View file

@ -49,6 +49,8 @@ interface ArrayFieldActions {
cleanupField: (fieldPath: string) => void;
/** Register move callbacks for an item */
registerMoveCallbacks: (fieldPath: string, itemIndex: number, callbacks: ItemMoveCallbacks) => void;
/** Expand items along a path by their IDs (caption field) */
expandItemsByPath: (fieldPath: string, itemIds: string[]) => void;
/** Reset all state */
reset: () => void;
}
@ -67,11 +69,20 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
...initialState,
setItemExpanded: (fieldPath, itemIndex, expanded) => {
console.log('[arrayFieldStore] setItemExpanded called', {
fieldPath,
itemIndex,
expanded,
});
set((state) => {
if (!state.expandedStates[fieldPath]) {
state.expandedStates[fieldPath] = [];
}
state.expandedStates[fieldPath][itemIndex] = expanded;
console.log('[arrayFieldStore] expandedStates updated', {
fieldPath,
newExpandedStates: state.expandedStates[fieldPath],
});
});
},
@ -195,6 +206,34 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
});
},
expandItemsByPath: (fieldPath, itemIds) => {
set((state) => {
const items = state.itemsData[fieldPath];
if (!items || !Array.isArray(items)) {
return;
}
const expandedStates = state.expandedStates[fieldPath];
if (!expandedStates) {
return;
}
// For each ID in the path, find matching item and expand it
itemIds.forEach((itemId) => {
const itemIndex = items.findIndex((item) => {
if (!item || typeof item !== 'object') return false;
const data = item as Record<string, unknown>;
// Match by id, caption, or title field
return data.id === itemId || data.caption === itemId || data.title === itemId;
});
if (itemIndex !== -1 && itemIndex < expandedStates.length) {
expandedStates[itemIndex] = true;
}
});
});
},
reset: () => {
set(initialState);
},

View file

@ -8,6 +8,7 @@ import { Box, Checkbox, IconButton } from '@mui/material';
import { ArrayFieldItemTemplateProps, FormContextType, getTemplate, getUiOptions, RJSFSchema } from '@rjsf/utils';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { ArrayItemProvider, useArrayItemContext } from '../context/ArrayItemContext';
import { ExtendedFormContext } from '../index';
import { useArrayFieldStore } from '../store/arrayFieldStore';
@ -37,16 +38,25 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
// This ensures consistent path between template and item
const fieldPath = arrayItemContext.arrayFieldPath ?? 'array';
// Get expanded state from store using shallow comparison
const expanded = useArrayFieldStore(
useCallback((state) => state.expandedStates[fieldPath]?.[index] ?? false, [fieldPath, index]),
// Get ALL relevant store data in one subscription
// Use useShallow to prevent unnecessary re-renders
const {
expandedStates,
stableItemIds: allStableItemIds,
setItemExpanded,
registerMoveCallbacks,
} = useArrayFieldStore(
useShallow((state) => ({
expandedStates: state.expandedStates,
stableItemIds: state.stableItemIds,
setItemExpanded: state.setItemExpanded,
registerMoveCallbacks: state.registerMoveCallbacks,
})),
);
// Get stable item ID from store for dnd-kit
const stableItemId = useArrayFieldStore(
useCallback((state) => state.stableItemIds[fieldPath]?.[index] ?? `item-${index}`, [fieldPath, index]),
);
const setItemExpanded = useArrayFieldStore((state) => state.setItemExpanded);
const registerMoveCallbacks = useArrayFieldStore((state) => state.registerMoveCallbacks);
// Get data for this specific item
const expanded = expandedStates[fieldPath]?.[index] ?? false;
const stableItemId = allStableItemIds[fieldPath]?.[index] ?? `item-${index}`;
// Register move callbacks so they can be accessed during drag operations
useEffect(() => {
@ -65,8 +75,15 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
});
const handleToggleExpanded = useCallback(() => {
console.log('[ArrayFieldItemTemplate] handleToggleExpanded called', {
fieldPath,
index,
currentExpanded: expanded,
willBeExpanded: !expanded,
itemCaption: (itemData as any)?.caption,
});
setItemExpanded(fieldPath, index, !expanded);
}, [fieldPath, index, expanded, setItemExpanded]);
}, [fieldPath, index, expanded, setItemExpanded, itemData]);
// 获取当前项的数据来显示 caption
const itemCaption = useMemo(() => {

View file

@ -4,6 +4,7 @@ import { Box, Typography } from '@mui/material';
import { ArrayFieldTemplateProps } from '@rjsf/utils';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { ArrayAddButton, ArrayContainer, ArrayHeader, ArrayItemCount, EmptyState, HelpTooltip, StyledFieldLabel } from '../components';
import { ArrayItemProvider } from '../context/ArrayItemContext';
import { ExtendedFormContext } from '../index';
@ -16,7 +17,7 @@ import { useArrayFieldStore } from '../store/arrayFieldStore';
export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) => {
const { items, onAddClick, canAdd, title, schema, registry } = props;
const formData = props.formData as unknown[] | undefined;
const fieldPathId = (props as unknown as { fieldPathId?: { path: (string | number)[] } }).fieldPathId;
const fieldPathId = (props as unknown as { fieldPathId?: { path: (string | number)[]; $id?: string } }).fieldPathId;
const { t } = useTranslation('agent');
// Get formContext for direct data manipulation
@ -25,18 +26,42 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
const description = schema.description;
// Generate a stable field path for this array
// Use title as fallback since idSchema might not be available
const fieldPath = useMemo(() => title || 'array', [title]);
// Use fieldPathId from RJSF which contains the actual property path
const fieldPath = useMemo(() => {
// First try to use fieldPathId which contains the actual path like ["prompts"] or ["prompts", 0, "children"]
if (fieldPathId?.path && Array.isArray(fieldPathId.path) && fieldPathId.path.length > 0) {
return fieldPathId.path.join('_');
}
// Fallback to fieldPathId.$id with root_ prefix removed
if (fieldPathId?.$id) {
return fieldPathId.$id.replace(/^root_/, '');
}
return title || 'array';
}, [fieldPathId?.path, fieldPathId?.$id, title]);
// Get store actions and state
const initializeField = useArrayFieldStore((state) => state.initializeField);
const updateItemsData = useArrayFieldStore((state) => state.updateItemsData);
const cleanupField = useArrayFieldStore((state) => state.cleanupField);
const moveItem = useArrayFieldStore((state) => state.moveItem);
// Get stable item IDs from store - these persist across re-renders
const stableItemIds = useArrayFieldStore((state) => state.stableItemIds[fieldPath] ?? []);
// Get items order from store - used for optimistic rendering during drag
const itemsOrder = useArrayFieldStore((state) => state.itemsOrder[fieldPath] ?? []);
// Get ALL store data and functions in one subscription to avoid multiple subscriptions
// Use useShallow to prevent unnecessary re-renders when the returned object has the same values
const {
initializeField,
updateItemsData,
cleanupField,
moveItem,
stableItemIds: allStableItemIds,
itemsOrder: allItemsOrder,
} = useArrayFieldStore(
useShallow((state) => ({
initializeField: state.initializeField,
updateItemsData: state.updateItemsData,
cleanupField: state.cleanupField,
moveItem: state.moveItem,
stableItemIds: state.stableItemIds,
itemsOrder: state.itemsOrder,
})),
);
// Get data for this specific fieldPath
const stableItemIds = allStableItemIds[fieldPath] ?? [];
const itemsOrder = allItemsOrder[fieldPath] ?? [];
// Track active drag item for overlay
const [activeId, setActiveId] = useState<string | null>(null);
@ -48,9 +73,36 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
}, [fieldPath, items.length, initializeField]);
// Update store when formData changes (from RJSF)
// Use ref to track previous formData and only update when content actually changes
const previousFormDataReference = React.useRef<unknown[] | undefined>(undefined);
const previousLengthReference = React.useRef<number>(-1);
useEffect(() => {
const itemsData = Array.isArray(formData) ? formData : [];
updateItemsData(fieldPath, itemsData);
const currentLength = itemsData.length;
// Only update if:
// 1. This is the first render (previousLengthReference.current === -1)
// 2. Length changed
// 3. Content changed (only check if length is the same)
if (previousLengthReference.current === -1 || previousLengthReference.current !== currentLength) {
previousFormDataReference.current = itemsData;
previousLengthReference.current = currentLength;
updateItemsData(fieldPath, itemsData);
} else if (previousFormDataReference.current) {
// Length is the same, do a shallow comparison of items
let hasChanged = false;
for (let itemIndex = 0; itemIndex < itemsData.length; itemIndex++) {
if (itemsData[itemIndex] !== previousFormDataReference.current[itemIndex]) {
hasChanged = true;
break;
}
}
if (hasChanged) {
previousFormDataReference.current = itemsData;
updateItemsData(fieldPath, itemsData);
}
}
}, [formData, fieldPath, updateItemsData]);
// Cleanup on unmount

View file

@ -50,7 +50,6 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => {
previewCurrentPlugin: null,
lastUpdated: null,
formFieldsToScrollTo: [],
expandedArrayItems: new Map(),
});
// Clear all mock calls

View file

@ -45,7 +45,6 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => {
previewCurrentPlugin: null,
lastUpdated: null,
formFieldsToScrollTo: [],
expandedArrayItems: new Map(),
});
});

View file

@ -38,6 +38,7 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
const [isFullScreen, setIsFullScreen] = useState(false);
const [baseMode, setBaseMode] = useState<'preview' | 'edit'>(initialBaseMode);
const [showSideBySide, setShowSideBySide] = useState(false);
const [baseModeBeforeSideBySide, setBaseModeBeforeSideBySide] = useState<'preview' | 'edit'>(initialBaseMode);
const {
loading: agentFrameworkConfigLoading,
@ -75,10 +76,19 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
}, []);
const handleToggleEditMode = useCallback((): void => {
setShowSideBySide(previous => !previous);
}, []);
setShowSideBySide(previous => {
if (!previous) {
// Entering side-by-side, save current baseMode
setBaseModeBeforeSideBySide(baseMode);
} else {
// Exiting side-by-side, restore previous baseMode
setBaseMode(baseModeBeforeSideBySide);
}
return !previous;
});
}, [baseMode, baseModeBeforeSideBySide]);
// Listen for form field scroll targets to automatically switch to edit mode
// Listen for form field scroll targets to automatically switch to side-by-side mode
const { formFieldsToScrollTo } = useAgentChatStore(
useShallow((state) => ({
formFieldsToScrollTo: state.formFieldsToScrollTo,
@ -86,10 +96,12 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
);
useEffect(() => {
if (formFieldsToScrollTo.length > 0) {
// Save current baseMode before switching to side-by-side
setBaseModeBeforeSideBySide(baseMode);
setBaseMode('edit');
setShowSideBySide(false);
setShowSideBySide(true); // Show side-by-side when clicking from PromptTree
}
}, [formFieldsToScrollTo]);
}, [formFieldsToScrollTo, baseMode]);
useEffect(() => {
if (open) {

View file

@ -49,10 +49,9 @@ export const PromptTreeNode = memo(({
depth: number;
fieldPath?: string[];
}): React.ReactElement => {
const { setFormFieldsToScrollTo, expandPathToTarget } = useAgentChatStore(
const { setFormFieldsToScrollTo } = useAgentChatStore(
useShallow((state) => ({
setFormFieldsToScrollTo: state.setFormFieldsToScrollTo,
expandPathToTarget: state.expandPathToTarget,
})),
);
const handleNodeClick = useCallback((event: React.MouseEvent) => {
@ -61,8 +60,7 @@ export const PromptTreeNode = memo(({
const targetFieldPath = (node.source && node.source.length > 0) ? node.source : [...fieldPath, node.id];
setFormFieldsToScrollTo(targetFieldPath);
expandPathToTarget(targetFieldPath);
}, [node.source, node.id, fieldPath, setFormFieldsToScrollTo, expandPathToTarget]);
}, [node.source, node.id, fieldPath, setFormFieldsToScrollTo]);
return (
<TreeItem