summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-02-13 05:07:46 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-02-13 05:07:46 +0000
commitb6f21c210ee804782eba2e7c30c2ccdcbd95bffb (patch)
tree4ff355d72a511063ba366c5052300cf1ca6f60a6 /frontend/src/components
parent7d897ad9bb5ee46839ec91992cbbf4593168f119 (diff)
Add unfold merged trace: convert to sequential node chain
Unfold takes a merged trace's messages, extracts the node order, and creates real edges chaining those nodes sequentially (A→B→C→D→E). The merged trace is deleted and replaced by a regular pass-through trace. - Add unfoldMergedTrace() to flowStore (creates edges, rewires downstream) - Add Unfold button (Layers icon) to Sidebar merged traces UI - Fix isMerged edge detection to use explicit flag instead of ID prefix - Fix LLMNode useUpdateNodeInternals deps for dynamic handle updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Sidebar.tsx98
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx77
2 files changed, 140 insertions, 35 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 78d2475..65d5cd2 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -6,7 +6,7 @@ import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../st
import type { Edge } from 'reactflow';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
-import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react';
+import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers } from 'lucide-react';
interface SidebarProps {
isOpen: boolean;
@@ -18,7 +18,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const {
nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode,
isTraceComplete, theme,
- createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages,
+ createMergedTrace, updateMergedTrace, deleteMergedTrace, unfoldMergedTrace, computeMergedMessages,
files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath,
saveCurrentBlueprint
} = useFlowStore();
@@ -183,6 +183,39 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
}
};
+ // Image helpers
+ const isImageFile = (mime: string) => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime);
+ const getImageUrl = (fileId: string) => `${import.meta.env.VITE_BACKEND_URL || ''}/api/files/download?user=${encodeURIComponent(user?.username || 'test')}&file_id=${encodeURIComponent(fileId)}`;
+
+ // Paste handler: upload pasted image and attach it
+ const handlePasteImage = async (
+ e: React.ClipboardEvent<HTMLTextAreaElement>,
+ addFile: (fileId: string) => void,
+ scopeFn?: () => string,
+ ) => {
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.type.startsWith('image/')) {
+ e.preventDefault();
+ const blob = item.getAsFile();
+ if (!blob) continue;
+ const file = new File([blob], `paste-${Date.now()}.${blob.type.split('/')[1] || 'png'}`, { type: blob.type });
+ try {
+ const meta = await uploadFile(file, { provider: 'local' });
+ addFile(meta.id);
+ if (scopeFn) {
+ try { await addFileScope(meta.id, scopeFn()); } catch {}
+ }
+ } catch (err) {
+ console.error('Paste upload failed:', err);
+ }
+ return; // only handle first image
+ }
+ }
+ };
+
// Filter files for attach modal
const filteredFilesToAttach = useMemo(() => {
const q = attachSearch.trim().toLowerCase();
@@ -1036,6 +1069,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
})
});
+ if (!response.ok) {
+ const errText = await response.text();
+ throw new Error(errText || `HTTP ${response.status}`);
+ }
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
@@ -1671,7 +1708,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
{/* Alternating color indicator */}
<div className="flex -space-x-1 shrink-0">
{merged.colors.slice(0, 3).map((color, idx) => (
- <div
+ <div
key={idx}
className="w-3 h-3 rounded-full border-2"
style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }}
@@ -1685,7 +1722,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
</div>
)}
</div>
-
+
<div className="flex-1 min-w-0">
<div className={`flex items-center gap-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
<span className="font-mono truncate">Merged #{merged.id.slice(-6)}</span>
@@ -1718,6 +1755,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<button
onClick={(e) => {
e.stopPropagation();
+ unfoldMergedTrace(selectedNode.id, merged.id);
+ }}
+ className={`p-1 rounded shrink-0 ${
+ isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-50 text-gray-400 hover:text-blue-600'
+ }`}
+ title="Unfold to regular trace"
+ >
+ <Layers size={12} />
+ </button>
+
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
deleteMergedTrace(selectedNode.id, merged.id);
}}
className={`p-1 rounded shrink-0 ${
@@ -1736,9 +1786,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label>
- <textarea
+ <textarea
value={selectedNode.data.userPrompt}
onChange={(e) => handleChange('userPrompt', e.target.value)}
+ onPaste={(e) => handlePasteImage(
+ e,
+ (id) => {
+ const current = selectedNode.data.attachedFileIds || [];
+ if (!current.includes(id)) {
+ updateNodeData(selectedNode.id, { attachedFileIds: [...current, id] });
+ }
+ },
+ () => `${currentBlueprintPath || 'untitled'}/${selectedNode.id}`,
+ )}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -1924,15 +1984,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
{(selectedNode.data.attachedFileIds || []).map(id => {
const file = files.find(f => f.id === id);
if (!file) return null;
+ const isImg = isImageFile(file.mime);
return (
- <div
- key={id}
+ <div
+ key={id}
className={`group flex items-center justify-between p-2 rounded text-xs ${
isDark ? 'bg-gray-700/50' : 'bg-white border border-gray-200'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
- <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} />
+ {isImg ? (
+ <img src={getImageUrl(id)} alt={file.name} className="w-8 h-8 object-cover rounded" />
+ ) : (
+ <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} />
+ )}
<span className={`truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}>
{file.name}
</span>
@@ -2497,7 +2562,12 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<div className="flex flex-wrap gap-2">
{sentFilesForMsg.map(fileId => {
const file = files.find(f => f.id === fileId);
- return (
+ const isImg = file && isImageFile(file.mime);
+ return isImg ? (
+ <a key={fileId} href={getImageUrl(fileId)} target="_blank" rel="noopener noreferrer" title={file?.name}>
+ <img src={getImageUrl(fileId)} alt={file?.name || 'Image'} className="max-w-[200px] max-h-[150px] rounded object-cover" />
+ </a>
+ ) : (
<div key={fileId} className="flex items-center gap-1 bg-blue-600 rounded px-2 py-1 text-xs">
<FileText size={12} />
<span className="max-w-[120px] truncate">{file?.name || 'File'}</span>
@@ -2671,14 +2741,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
{quickChatAttachedFiles.map(fileId => {
const file = files.find(f => f.id === fileId);
if (!file) return null;
+ const isImg = isImageFile(file.mime);
return (
- <div
+ <div
key={fileId}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
isDark ? 'bg-gray-600 text-gray-200' : 'bg-white text-gray-700 border border-gray-300'
}`}
>
- <FileText size={12} />
+ {isImg ? (
+ <img src={getImageUrl(fileId)} alt={file.name} className="w-8 h-8 object-cover rounded" />
+ ) : (
+ <FileText size={12} />
+ )}
<span className="max-w-[120px] truncate">{file.name}</span>
<button
onClick={() => handleQuickChatDetach(fileId)}
@@ -2697,6 +2772,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
ref={quickChatInputRef}
value={quickChatInput}
onChange={(e) => setQuickChatInput(e.target.value)}
+ onPaste={(e) => handlePasteImage(e, (id) => setQuickChatAttachedFiles(prev => [...prev, id]), getQuickChatScope)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx
index 8cbf0e9..7542860 100644
--- a/frontend/src/components/nodes/LLMNode.tsx
+++ b/frontend/src/components/nodes/LLMNode.tsx
@@ -10,10 +10,10 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const updateNodeInternals = useUpdateNodeInternals();
const edges = useEdges();
- // Force update handles when traces change
+ // Force update handles when traces or edges change
useEffect(() => {
updateNodeInternals(id);
- }, [id, data.outgoingTraces, data.mergedTraces, data.inputs, updateNodeInternals]);
+ }, [id, data.traces, data.outgoingTraces, data.mergedTraces, data.forkedTraces, data.inputs, edges.length, updateNodeInternals]);
// Determine how many input handles to show
// We want to ensure there is always at least one empty handle at the bottom
@@ -53,6 +53,35 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const isDisabled = data.disabled;
const isDark = theme === 'dark';
+ // Calculate handle counts to determine minimum node height
+ // Each handle slot is 16px (h-3=12px + my-0.5*2=4px)
+ const HANDLE_SLOT = 16;
+ const NODE_PADDING = 16; // py-2 top + bottom
+
+ // Left side: regular inputs + prepend handles
+ const prependCount = (data.outgoingTraces || []).filter(trace => {
+ const isSelfTrace = trace.id === `trace-${id}`;
+ const isForkTrace = trace.id.startsWith('fork-') && trace.sourceNodeId === id;
+ if (!isSelfTrace && !isForkTrace) return false;
+ return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+ }).length;
+ const leftHandleCount = inputsToShow + prependCount;
+
+ // Right side: continue + outgoing + merged + new branch
+ const continueCount = (data.traces || []).filter((trace: any) => {
+ return !edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+ }).length;
+ const outgoingCount = (data.outgoingTraces || []).filter(trace => {
+ const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id);
+ if (isLocallyMerged) return false;
+ return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+ }).length;
+ const mergedCount = (data.mergedTraces || []).length;
+ const rightHandleCount = continueCount + outgoingCount + mergedCount + 1; // +1 for "new branch"
+
+ const maxHandles = Math.max(leftHandleCount, rightHandleCount);
+ const minHandleHeight = maxHandles * HANDLE_SLOT + NODE_PADDING;
+
// Truncate preview content
const previewContent = data.response
? data.response.slice(0, 200) + (data.response.length > 200 ? '...' : '')
@@ -61,13 +90,13 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
: null;
return (
- <div
+ <div
className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all relative ${
- isDisabled
- ? isDark
- ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed'
+ isDisabled
+ ? isDark
+ ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed'
: 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed'
- : selected
+ : selected
? isDark
? 'bg-gray-800 border-blue-400'
: 'bg-white border-blue-500'
@@ -75,7 +104,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}`}
- style={{ pointerEvents: isDisabled ? 'none' : 'auto' }}
+ style={{ pointerEvents: isDisabled ? 'none' : 'auto', minHeight: minHandleHeight }}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
>
@@ -145,16 +174,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const gradientStops = mergedColors.map((color, idx) =>
`${color} ${(idx / mergedColors.length) * 100}%, ${color} ${((idx + 1) / mergedColors.length) * 100}%`
).join(', ');
- handleBackground = `linear-gradient(45deg, ${gradientStops})`;
+ handleBackground = `conic-gradient(from 0deg, ${gradientStops})`;
}
return (
- <div key={i} className="relative h-4 w-4 my-1">
+ <div key={i} className="relative h-3 w-3 my-0.5">
<Handle
type="target"
position={Position.Left}
id={`input-${i}`}
- className="!w-3 !h-3 !left-[-6px] !border-0"
+ className="!w-2.5 !h-2.5 !left-[-6px] !border-0"
style={{
top: '50%',
transform: 'translateY(-50%)',
@@ -196,12 +225,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
);
return (
- <div key={`prepend-${trace.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to: ${trace.id}`}>
+ <div key={`prepend-${trace.id}`} className="relative h-3 w-3 my-0.5" title={`Prepend context to: ${trace.id}`}>
<Handle
type="target"
position={Position.Left}
id={`prepend-${trace.id}`}
- className="!w-3 !h-3 !left-[-6px] !border-2"
+ className="!w-2.5 !h-2.5 !left-[-6px] !border-2"
style={{
top: '50%',
transform: 'translateY(-50%)',
@@ -244,16 +273,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const gradientStops = colors.map((color: string, idx: number) =>
`${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
).join(', ');
- backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+ backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`;
}
return (
- <div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}>
+ <div key={`continue-${trace.id}`} className="relative h-3 w-3 my-0.5" title={`Continue trace: ${trace.id}`}>
<Handle
type="source"
position={Position.Right}
id={`trace-${trace.id}`}
- className="!w-3 !h-3 !right-[-6px]"
+ className="!w-2.5 !h-2.5 !right-[-6px]"
style={{
background: backgroundStyle,
top: '50%',
@@ -287,16 +316,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const gradientStops = colors.map((color, idx) =>
`${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
).join(', ');
- backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+ backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`;
}
return (
- <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}>
+ <div key={trace.id} className="relative h-3 w-3 my-0.5" title={`Trace: ${trace.id}`}>
<Handle
type="source"
position={Position.Right}
id={`trace-${trace.id}`}
- className="!w-3 !h-3 !right-[-6px]"
+ className="!w-2.5 !h-2.5 !right-[-6px]"
style={{
background: backgroundStyle,
top: '50%',
@@ -319,15 +348,15 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
const gradientStops = colors.map((color, idx) =>
`${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
).join(', ');
- const stripeGradient = `linear-gradient(45deg, ${gradientStops})`;
+ const stripeGradient = `conic-gradient(from 0deg, ${gradientStops})`;
return (
- <div key={merged.id} className="relative h-4 w-4 my-1" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}>
+ <div key={merged.id} className="relative h-3 w-3 my-0.5" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}>
<Handle
type="source"
position={Position.Right}
id={`trace-${merged.id}`}
- className="!w-3 !h-3 !right-[-6px]"
+ className="!w-2.5 !h-2.5 !right-[-6px]"
style={{
background: stripeGradient,
top: '50%',
@@ -340,12 +369,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
})}
{/* 3. New Branch Generator Handle (Always visible) */}
- <div className="relative h-4 w-4 my-1" title="Create New Branch">
+ <div className="relative h-3 w-3 my-0.5" title="Create New Branch">
<Handle
type="source"
position={Position.Right}
id="new-trace"
- className="!w-3 !h-3 !bg-gray-400 !right-[-6px]"
+ className="!w-2.5 !h-2.5 !bg-gray-400 !right-[-6px]"
style={{ top: '50%', transform: 'translateY(-50%)' }}
/>
<span className={`absolute right-4 top-[-2px] text-[9px] pointer-events-none w-max ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>