diff options
Diffstat (limited to 'frontend/src/store/flowStore.ts')
| -rw-r--r-- | frontend/src/store/flowStore.ts | 266 |
1 files changed, 247 insertions, 19 deletions
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index 5ed66e6..56ade75 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -15,7 +15,32 @@ import { getOutgoers } from 'reactflow'; +// --- Project / Blueprint types --- +export interface ViewportState { + x: number; + y: number; + zoom: number; +} + +export interface BlueprintDocument { + version: number; + nodes: Node<NodeData>[]; + edges: Edge[]; + viewport?: ViewportState; + theme?: 'light' | 'dark'; +} + +export interface FSItem { + name: string; + path: string; // path relative to user root + type: 'file' | 'folder'; + size?: number | null; + mtime?: number | null; + children?: FSItem[]; +} + export type NodeStatus = 'idle' | 'loading' | 'success' | 'error'; +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export interface Message { id?: string; @@ -90,6 +115,10 @@ export interface ArchivedNode { systemPrompt: string; temperature: number; reasoningEffort: 'low' | 'medium' | 'high'; + userPrompt?: string; + response?: string; + enableGoogleSearch?: boolean; + mergeStrategy?: 'raw' | 'smart'; } interface FlowState { @@ -98,6 +127,10 @@ interface FlowState { selectedNodeId: string | null; archivedNodes: ArchivedNode[]; // Stored node templates theme: 'light' | 'dark'; + projectTree: FSItem[]; + currentBlueprintPath?: string; + lastViewport?: ViewportState; + saveStatus: SaveStatus; onNodesChange: OnNodesChange; onEdgesChange: OnEdgesChange; @@ -140,6 +173,22 @@ interface FlowState { config: Partial<NodeData> ) => string; // Returns new node ID + // Blueprint serialization / persistence + serializeBlueprint: (viewport?: ViewportState) => BlueprintDocument; + loadBlueprint: (doc: BlueprintDocument) => ViewportState | undefined; + saveBlueprintFile: (path: string, viewport?: ViewportState) => Promise<void>; + readBlueprintFile: (path: string) => Promise<BlueprintDocument>; + refreshProjectTree: () => Promise<FSItem[]>; + createProjectFolder: (path: string) => Promise<void>; + renameProjectItem: (path: string, newName?: string, newPath?: string) => Promise<void>; + deleteProjectItem: (path: string, isFolder?: boolean) => Promise<void>; + setCurrentBlueprintPath: (path?: string) => void; + setLastViewport: (viewport: ViewportState) => void; + saveCurrentBlueprint: (path?: string, viewport?: ViewportState) => Promise<void>; + clearBlueprint: () => void; + loadArchivedNodes: () => Promise<void>; + saveArchivedNodes: () => Promise<void>; + // Merge trace functions createMergedTrace: ( nodeId: string, @@ -172,12 +221,37 @@ const getStableColor = (str: string) => { return `hsl(${hue}, 70%, 60%)`; }; -const useFlowStore = create<FlowState>((set, get) => ({ +const API_BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; +const DEFAULT_USER = 'test'; + +const jsonFetch = async <T>(url: string, options?: RequestInit): Promise<T> => { + const res = await fetch(url, options); + if (!res.ok) { + const detail = await res.text(); + throw new Error(detail || `Request failed: ${res.status}`); + } + return res.json() as Promise<T>; +}; + +const useFlowStore = create<FlowState>((set, get) => { + + const validateBlueprint = (doc: any): BlueprintDocument => { + if (!doc || typeof doc !== 'object') throw new Error('Invalid blueprint: not an object'); + if (typeof doc.version !== 'number') throw new Error('Invalid blueprint: missing version'); + if (!Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) throw new Error('Invalid blueprint: nodes/edges missing'); + return doc as BlueprintDocument; + }; + + return { nodes: [], edges: [], selectedNodeId: null, archivedNodes: [], theme: 'light' as const, + projectTree: [], + currentBlueprintPath: undefined, + lastViewport: undefined, + saveStatus: 'idle', toggleTheme: () => { const newTheme = get().theme === 'light' ? 'dark' : 'light'; @@ -190,6 +264,10 @@ const useFlowStore = create<FlowState>((set, get) => ({ } }, + setLastViewport: (viewport: ViewportState) => { + set({ lastViewport: viewport }); + }, + findNonOverlappingPosition: (baseX: number, baseY: number) => { const { nodes } = get(); // Estimate larger dimensions to be safe, considering dynamic handles @@ -1036,18 +1114,24 @@ const useFlowStore = create<FlowState>((set, get) => ({ model: node.data.model, systemPrompt: node.data.systemPrompt, temperature: node.data.temperature, - reasoningEffort: node.data.reasoningEffort || 'medium' + reasoningEffort: node.data.reasoningEffort || 'medium', + userPrompt: node.data.userPrompt, + response: node.data.response, + enableGoogleSearch: node.data.enableGoogleSearch, + mergeStrategy: node.data.mergeStrategy }; set(state => ({ archivedNodes: [...state.archivedNodes, archived] })); + setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); }, removeFromArchive: (archiveId: string) => { set(state => ({ archivedNodes: state.archivedNodes.filter(a => a.id !== archiveId) })); + setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); }, createNodeFromArchive: (archiveId: string, position: { x: number; y: number }) => { @@ -1063,15 +1147,16 @@ const useFlowStore = create<FlowState>((set, get) => ({ model: archived.model, temperature: archived.temperature, systemPrompt: archived.systemPrompt, - userPrompt: '', - mergeStrategy: 'smart', + userPrompt: archived.userPrompt || '', reasoningEffort: archived.reasoningEffort, + enableGoogleSearch: archived.enableGoogleSearch, + mergeStrategy: archived.mergeStrategy || 'smart', traces: [], outgoingTraces: [], forkedTraces: [], mergedTraces: [], activeTraceIds: [], - response: '', + response: archived.response || '', status: 'idle', inputs: 1 } @@ -1313,6 +1398,139 @@ const useFlowStore = create<FlowState>((set, get) => ({ return newNodeId; }, + // -------- Blueprint serialization / persistence -------- + setCurrentBlueprintPath: (path?: string) => { + set({ currentBlueprintPath: path }); + }, + + serializeBlueprint: (viewport?: ViewportState): BlueprintDocument => { + return { + version: 1, + nodes: get().nodes, + edges: get().edges, + viewport: viewport || get().lastViewport, + theme: get().theme, + }; + }, + + loadBlueprint: (doc: BlueprintDocument): ViewportState | undefined => { + set({ + nodes: (doc.nodes || []) as LLMNode[], + edges: (doc.edges || []) as Edge[], + theme: doc.theme || get().theme, + selectedNodeId: null, + lastViewport: doc.viewport || get().lastViewport, + }); + // Recompute traces after loading + setTimeout(() => get().propagateTraces(), 0); + return doc.viewport; + }, + + saveBlueprintFile: async (path: string, viewport?: ViewportState) => { + const payload = get().serializeBlueprint(viewport); + await jsonFetch(`${API_BASE}/api/projects/save_blueprint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: DEFAULT_USER, + path, + content: payload, + }), + }); + set({ currentBlueprintPath: path, lastViewport: payload.viewport }); + await get().refreshProjectTree(); + }, + + readBlueprintFile: async (path: string): Promise<BlueprintDocument> => { + const res = await jsonFetch<{ content: BlueprintDocument }>( + `${API_BASE}/api/projects/file?user=${encodeURIComponent(DEFAULT_USER)}&path=${encodeURIComponent(path)}` + ); + return validateBlueprint(res.content); + }, + + refreshProjectTree: async () => { + const tree = await jsonFetch<FSItem[]>( + `${API_BASE}/api/projects/tree?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ projectTree: tree }); + return tree; + }, + + createProjectFolder: async (path: string) => { + await jsonFetch(`${API_BASE}/api/projects/create_folder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path }), + }); + await get().refreshProjectTree(); + }, + + renameProjectItem: async (path: string, newName?: string, newPath?: string) => { + if (!newName && !newPath) { + throw new Error('newName or newPath is required'); + } + await jsonFetch(`${API_BASE}/api/projects/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path, new_name: newName, new_path: newPath }), + }); + await get().refreshProjectTree(); + }, + + deleteProjectItem: async (path: string, isFolder = false) => { + await jsonFetch(`${API_BASE}/api/projects/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path, is_folder: isFolder }), + }); + await get().refreshProjectTree(); + }, + + saveCurrentBlueprint: async (path?: string, viewport?: ViewportState) => { + const targetPath = path || get().currentBlueprintPath; + if (!targetPath) { + throw new Error('No blueprint path. Please provide a file name.'); + } + set({ saveStatus: 'saving' }); + try { + await get().saveBlueprintFile(targetPath, viewport); + set({ saveStatus: 'saved', currentBlueprintPath: targetPath }); + } catch (e) { + console.error(e); + set({ saveStatus: 'error' }); + throw e; + } + }, + + clearBlueprint: () => { + set({ + nodes: [], + edges: [], + selectedNodeId: null, + currentBlueprintPath: undefined, + lastViewport: undefined, + saveStatus: 'idle', + }); + }, + + loadArchivedNodes: async () => { + const res = await jsonFetch<{ archived: ArchivedNode[] }>( + `${API_BASE}/api/projects/archived?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ archivedNodes: res.archived || [] }); + }, + + saveArchivedNodes: async () => { + const payload = { user: DEFAULT_USER, archived: get().archivedNodes }; + await jsonFetch(`${API_BASE}/api/projects/archived`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + // -------------------------------------------------------- + // Compute merged messages based on strategy // Optional tracesOverride parameter to use latest traces during propagation computeMergedMessages: (nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy, tracesOverride?: Trace[]): Message[] => { @@ -1487,10 +1705,13 @@ const useFlowStore = create<FlowState>((set, get) => ({ const node = nodes.find(n => n.id === nodeId); if (!node) return ''; - // Get colors from source traces - const colors = sourceTraceIds - .map(id => node.data.traces.find((t: Trace) => t.id === id)?.color) - .filter((c): c is string => c !== undefined); + // Get colors from source traces (preserve multi-colors for merged parents) + const colors = sourceTraceIds.flatMap(id => { + const t = node.data.traces.find((tr: Trace) => tr.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); // Compute merged messages const messages = computeMergedMessages(nodeId, sourceTraceIds, strategy); @@ -1533,12 +1754,15 @@ const useFlowStore = create<FlowState>((set, get) => ({ const newSourceTraceIds = updates.sourceTraceIds || current.sourceTraceIds; const newStrategy = updates.strategy || current.strategy; - // Recompute colors if source traces changed + // Recompute colors if source traces changed (preserve multi-colors) let newColors = current.colors; if (updates.sourceTraceIds) { - newColors = updates.sourceTraceIds - .map(id => node.data.traces.find((t: Trace) => t.id === id)?.color) - .filter((c): c is string => c !== undefined); + newColors = updates.sourceTraceIds.flatMap(id => { + const t = node.data.traces.find((tr: Trace) => tr.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); } // Recompute messages if source or strategy changed @@ -1919,10 +2143,13 @@ const useFlowStore = create<FlowState>((set, get) => ({ // Get prepend messages for this merged trace const mergedPrepend = prependMessages.get(merged.id) || []; - // Update colors from current traces - const updatedColors = merged.sourceTraceIds - .map(id => uniqueIncoming.find(t => t.id === id)?.color) - .filter((c): c is string => c !== undefined); + // Update colors from current traces (preserve multi-colors) + const updatedColors = merged.sourceTraceIds.flatMap(id => { + const t = uniqueIncoming.find(trace => trace.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); // Combine all messages for this merged trace const mergedMessages = [...mergedPrepend, ...filteredMessages, ...myResponseMsg]; @@ -2017,7 +2244,8 @@ const useFlowStore = create<FlowState>((set, get) => ({ }; }) })); - } -})); + }, + }; +}); export default useFlowStore; |
