Spaces:
Running
Running
add new feature
Browse files
src/lib/components/chat/Assistant.svelte
CHANGED
|
@@ -1,6 +1,16 @@
|
|
| 1 |
<script lang="ts">
|
| 2 |
-
import {
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import type { ChatModel, ChatMessage } from '$lib/helpers/types';
|
| 6 |
import { Button } from '$lib/components/ui/button';
|
|
@@ -11,8 +21,11 @@
|
|
| 11 |
|
| 12 |
// svelte-ignore state_referenced_locally
|
| 13 |
const nodeData = useNodesData(id);
|
|
|
|
|
|
|
| 14 |
|
| 15 |
let selectedModel = $derived((nodeData.current?.data.selectedModel as ChatModel) ?? null);
|
|
|
|
| 16 |
let loading = $derived((nodeData.current?.data.loading as boolean) ?? false);
|
| 17 |
let message = $derived(
|
| 18 |
nodeData.current?.data.content
|
|
@@ -23,9 +36,80 @@
|
|
| 23 |
} as ChatMessage)
|
| 24 |
: null
|
| 25 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</script>
|
| 27 |
|
| 28 |
-
<article
|
|
|
|
|
|
|
| 29 |
<div class="nodrag pointer-events-auto cursor-auto">
|
| 30 |
<header class="mb-3 flex items-center justify-between">
|
| 31 |
<div class="flex flex-wrap items-center gap-1">
|
|
@@ -49,13 +133,26 @@
|
|
| 49 |
</div>
|
| 50 |
{/if}
|
| 51 |
{#if message}
|
| 52 |
-
<
|
|
|
|
|
|
|
| 53 |
{/if}
|
| 54 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</article>
|
| 56 |
-
<Handle type="target"
|
| 57 |
-
<Handle type="target"
|
| 58 |
-
<Handle type="target"
|
| 59 |
-
<Handle type="source"
|
| 60 |
-
<Handle type="source"
|
| 61 |
-
<Handle type="source"
|
|
|
|
| 1 |
<script lang="ts">
|
| 2 |
+
import {
|
| 3 |
+
Handle,
|
| 4 |
+
useNodesData,
|
| 5 |
+
Position,
|
| 6 |
+
type NodeProps,
|
| 7 |
+
useNodes,
|
| 8 |
+
useEdges,
|
| 9 |
+
type Edge,
|
| 10 |
+
type Node
|
| 11 |
+
} from '@xyflow/svelte';
|
| 12 |
+
import { MessageCirclePlus, Star } from '@lucide/svelte';
|
| 13 |
+
import { mode } from 'mode-watcher';
|
| 14 |
|
| 15 |
import type { ChatModel, ChatMessage } from '$lib/helpers/types';
|
| 16 |
import { Button } from '$lib/components/ui/button';
|
|
|
|
| 21 |
|
| 22 |
// svelte-ignore state_referenced_locally
|
| 23 |
const nodeData = useNodesData(id);
|
| 24 |
+
const { update: updateNodes } = useNodes();
|
| 25 |
+
const { update: updateEdges } = useEdges();
|
| 26 |
|
| 27 |
let selectedModel = $derived((nodeData.current?.data.selectedModel as ChatModel) ?? null);
|
| 28 |
+
let messages = $derived((nodeData.current?.data.messages as ChatMessage[]) ?? []);
|
| 29 |
let loading = $derived((nodeData.current?.data.loading as boolean) ?? false);
|
| 30 |
let message = $derived(
|
| 31 |
nodeData.current?.data.content
|
|
|
|
| 36 |
} as ChatMessage)
|
| 37 |
: null
|
| 38 |
);
|
| 39 |
+
let containerRef: HTMLDivElement | null = $state(null);
|
| 40 |
+
let selectedText = $state<string | null>(null);
|
| 41 |
+
let selectedTextPosition = $state<{ y: number } | null>(null);
|
| 42 |
+
|
| 43 |
+
$effect(() => {
|
| 44 |
+
document.addEventListener('selectionchange', handleTextSelectionChange);
|
| 45 |
+
return () => {
|
| 46 |
+
document.removeEventListener('selectionchange', handleTextSelectionChange);
|
| 47 |
+
};
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
function handleTextSelectionChange(e: Event) {
|
| 51 |
+
const selection = document.getSelection();
|
| 52 |
+
const text = selection?.toString();
|
| 53 |
+
if (!text || text.trim() === '') {
|
| 54 |
+
selectedText = null;
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
if (containerRef?.contains(selection?.anchorNode as unknown as HTMLElement)) {
|
| 58 |
+
selectedText = text;
|
| 59 |
+
let rect = selection?.getRangeAt(0).getBoundingClientRect();
|
| 60 |
+
if (rect) {
|
| 61 |
+
selectedTextPosition = {
|
| 62 |
+
y: rect.top - containerRef.getBoundingClientRect().top
|
| 63 |
+
};
|
| 64 |
+
} else {
|
| 65 |
+
selectedTextPosition = {
|
| 66 |
+
y:
|
| 67 |
+
(selection?.anchorNode?.parentElement?.getBoundingClientRect().top ?? 0) -
|
| 68 |
+
containerRef.getBoundingClientRect().top
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
} else {
|
| 72 |
+
selectedText = null;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function handleAddFollowUpMessage(prompt: string) {
|
| 77 |
+
document.getSelection()?.removeAllRanges();
|
| 78 |
+
const newNodes: Node[] = [];
|
| 79 |
+
const newEdges: Edge[] = [];
|
| 80 |
+
|
| 81 |
+
const newNodeId = `user-follow-up-${crypto.randomUUID()}`;
|
| 82 |
+
const newNode: Node = {
|
| 83 |
+
id: newNodeId,
|
| 84 |
+
type: 'user-follow-up',
|
| 85 |
+
position: {
|
| 86 |
+
x: 0,
|
| 87 |
+
y: 0
|
| 88 |
+
},
|
| 89 |
+
data: {
|
| 90 |
+
role: 'user',
|
| 91 |
+
prompt,
|
| 92 |
+
selectedModels: [selectedModel],
|
| 93 |
+
messages: [...messages, message]
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
const newEdge: Edge = {
|
| 97 |
+
id: `edge-${crypto.randomUUID()}`,
|
| 98 |
+
source: id,
|
| 99 |
+
target: newNodeId,
|
| 100 |
+
sourceHandle: 's-right',
|
| 101 |
+
targetHandle: 't-left'
|
| 102 |
+
};
|
| 103 |
+
newNodes.push(newNode);
|
| 104 |
+
newEdges.push(newEdge);
|
| 105 |
+
updateNodes((currentNodes) => [...currentNodes, ...newNodes]);
|
| 106 |
+
updateEdges((currentEdges) => [...currentEdges, ...newEdges]);
|
| 107 |
+
}
|
| 108 |
</script>
|
| 109 |
|
| 110 |
+
<article
|
| 111 |
+
class="relative w-full rounded-3xl border border-border bg-background p-5 shadow-lg/5 lg:w-[600px]"
|
| 112 |
+
>
|
| 113 |
<div class="nodrag pointer-events-auto cursor-auto">
|
| 114 |
<header class="mb-3 flex items-center justify-between">
|
| 115 |
<div class="flex flex-wrap items-center gap-1">
|
|
|
|
| 133 |
</div>
|
| 134 |
{/if}
|
| 135 |
{#if message}
|
| 136 |
+
<div bind:this={containerRef}>
|
| 137 |
+
<Message {message} provider={selectedModel.provider} />
|
| 138 |
+
</div>
|
| 139 |
{/if}
|
| 140 |
</div>
|
| 141 |
+
<Button
|
| 142 |
+
variant={mode.current === 'dark' ? 'default' : 'outline'}
|
| 143 |
+
size="icon-lg"
|
| 144 |
+
class="absolute top-0 right-0 z-50 translate-x-1/2 {selectedText
|
| 145 |
+
? 'opacity-100'
|
| 146 |
+
: 'pointer-events-none opacity-0'}"
|
| 147 |
+
style="top: {(selectedTextPosition?.y ?? 0) + 50}px;"
|
| 148 |
+
onclick={() => selectedText && handleAddFollowUpMessage(selectedText)}
|
| 149 |
+
>
|
| 150 |
+
<MessageCirclePlus class="size-5" />
|
| 151 |
+
</Button>
|
| 152 |
</article>
|
| 153 |
+
<Handle type="target" id="t-top" class="opacity-0" position={Position.Top} />
|
| 154 |
+
<Handle type="target" id="t-left" class="opacity-0" position={Position.Left} />
|
| 155 |
+
<Handle type="target" id="t-right" class="opacity-0" position={Position.Right} />
|
| 156 |
+
<Handle type="source" id="s-bottom" class="opacity-0" position={Position.Bottom} />
|
| 157 |
+
<Handle type="source" id="s-left" class="opacity-0" position={Position.Left} />
|
| 158 |
+
<Handle type="source" id="s-right" class="opacity-0" position={Position.Right} />
|
src/lib/components/chat/Message.svelte
CHANGED
|
@@ -38,7 +38,7 @@
|
|
| 38 |
{:else}
|
| 39 |
<SvelteMarkdown source={message.content} renderers={renderers as any} />
|
| 40 |
{#if message.timestamp}
|
| 41 |
-
<p class="flex items-center gap-1 text-xs text-muted-foreground/70">
|
| 42 |
Generated in
|
| 43 |
{message.timestamp / 1000}s using
|
| 44 |
<span class="flex items-center gap-1 rounded bg-muted py-0.5 pr-1 pl-0.5">
|
|
|
|
| 38 |
{:else}
|
| 39 |
<SvelteMarkdown source={message.content} renderers={renderers as any} />
|
| 40 |
{#if message.timestamp}
|
| 41 |
+
<p class="flex items-center gap-1 text-xs text-muted-foreground/70 select-none">
|
| 42 |
Generated in
|
| 43 |
{message.timestamp / 1000}s using
|
| 44 |
<span class="flex items-center gap-1 rounded bg-muted py-0.5 pr-1 pl-0.5">
|
src/lib/components/chat/User.svelte
CHANGED
|
@@ -11,7 +11,6 @@
|
|
| 11 |
type Node,
|
| 12 |
useSvelteFlow
|
| 13 |
} from '@xyflow/svelte';
|
| 14 |
-
import { mode } from 'mode-watcher';
|
| 15 |
|
| 16 |
import type { ChatModel, ChatMessage } from '$lib/helpers/types';
|
| 17 |
import { Button } from '$lib/components/ui/button';
|
|
@@ -21,11 +20,11 @@
|
|
| 21 |
import SettingsModel from '$lib/components/model/SettingsModel.svelte';
|
| 22 |
import { MAX_MODELS_PER_NODE, MAX_SUGGESTIONS } from '$lib';
|
| 23 |
import { SUGGESTIONS_PROMPT } from '$lib/consts';
|
| 24 |
-
import { breakpointsState } from '$lib/state/breakpoints.svelte';
|
| 25 |
import { authState } from '$lib/state/auth.svelte';
|
| 26 |
import { signinModalState } from '$lib/state/signin-modal.svelte';
|
|
|
|
| 27 |
|
| 28 |
-
let { id
|
| 29 |
|
| 30 |
// svelte-ignore state_referenced_locally
|
| 31 |
const nodeData = useNodesData(id);
|
|
@@ -96,7 +95,8 @@
|
|
| 96 |
role: 'assistant',
|
| 97 |
selectedModel: m,
|
| 98 |
content: '',
|
| 99 |
-
loading: true
|
|
|
|
| 100 |
}
|
| 101 |
};
|
| 102 |
const newEdge: Edge = {
|
|
@@ -169,7 +169,7 @@
|
|
| 169 |
const end = Date.now();
|
| 170 |
updateNodeData(
|
| 171 |
node.id,
|
| 172 |
-
{ ...node.data, content, timestamp: end - start, loading: false },
|
| 173 |
{ replace: true }
|
| 174 |
);
|
| 175 |
break;
|
|
@@ -191,9 +191,18 @@
|
|
| 191 |
? messages[messages.length - 1]
|
| 192 |
: null
|
| 193 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</script>
|
| 195 |
|
| 196 |
-
<article
|
|
|
|
|
|
|
| 197 |
<div class="nodrag pointer-events-auto cursor-auto">
|
| 198 |
<header class="mb-3 flex items-center justify-between">
|
| 199 |
<div class="flex flex-wrap items-center gap-1">
|
|
@@ -280,7 +289,7 @@
|
|
| 280 |
<div></div>
|
| 281 |
{/if}
|
| 282 |
<Button
|
| 283 |
-
variant=
|
| 284 |
size="icon-sm"
|
| 285 |
class=""
|
| 286 |
disabled={!selectedModels.length || !prompt || loading}
|
|
@@ -297,9 +306,9 @@
|
|
| 297 |
{/if}
|
| 298 |
</div>
|
| 299 |
</article>
|
| 300 |
-
<Handle type="target" position={Position.Top} class="opacity-0" />
|
| 301 |
-
<Handle type="target" position={Position.Left} class="opacity-0" />
|
| 302 |
-
<Handle type="target" position={Position.Right} class="opacity-0" />
|
| 303 |
-
<Handle type="source" position={Position.Bottom} class="opacity-0" />
|
| 304 |
-
<Handle type="source" position={Position.Left} class="opacity-0" />
|
| 305 |
-
<Handle type="source" position={Position.Right} class="opacity-0" />
|
|
|
|
| 11 |
type Node,
|
| 12 |
useSvelteFlow
|
| 13 |
} from '@xyflow/svelte';
|
|
|
|
| 14 |
|
| 15 |
import type { ChatModel, ChatMessage } from '$lib/helpers/types';
|
| 16 |
import { Button } from '$lib/components/ui/button';
|
|
|
|
| 20 |
import SettingsModel from '$lib/components/model/SettingsModel.svelte';
|
| 21 |
import { MAX_MODELS_PER_NODE, MAX_SUGGESTIONS } from '$lib';
|
| 22 |
import { SUGGESTIONS_PROMPT } from '$lib/consts';
|
|
|
|
| 23 |
import { authState } from '$lib/state/auth.svelte';
|
| 24 |
import { signinModalState } from '$lib/state/signin-modal.svelte';
|
| 25 |
+
import { onMount } from 'svelte';
|
| 26 |
|
| 27 |
+
let { id }: NodeProps = $props();
|
| 28 |
|
| 29 |
// svelte-ignore state_referenced_locally
|
| 30 |
const nodeData = useNodesData(id);
|
|
|
|
| 95 |
role: 'assistant',
|
| 96 |
selectedModel: m,
|
| 97 |
content: '',
|
| 98 |
+
loading: true,
|
| 99 |
+
messages
|
| 100 |
}
|
| 101 |
};
|
| 102 |
const newEdge: Edge = {
|
|
|
|
| 169 |
const end = Date.now();
|
| 170 |
updateNodeData(
|
| 171 |
node.id,
|
| 172 |
+
{ ...node.data, content, timestamp: end - start, loading: false, messages },
|
| 173 |
{ replace: true }
|
| 174 |
);
|
| 175 |
break;
|
|
|
|
| 191 |
? messages[messages.length - 1]
|
| 192 |
: null
|
| 193 |
);
|
| 194 |
+
|
| 195 |
+
onMount(() => {
|
| 196 |
+
if (prompt.trim() !== '' && prompt) {
|
| 197 |
+
handleTriggerAction();
|
| 198 |
+
prompt = '';
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
</script>
|
| 202 |
|
| 203 |
+
<article
|
| 204 |
+
class="relative z-10 w-full rounded-3xl border border-border bg-background p-5 shadow-lg/5 lg:w-[600px]"
|
| 205 |
+
>
|
| 206 |
<div class="nodrag pointer-events-auto cursor-auto">
|
| 207 |
<header class="mb-3 flex items-center justify-between">
|
| 208 |
<div class="flex flex-wrap items-center gap-1">
|
|
|
|
| 289 |
<div></div>
|
| 290 |
{/if}
|
| 291 |
<Button
|
| 292 |
+
variant="default"
|
| 293 |
size="icon-sm"
|
| 294 |
class=""
|
| 295 |
disabled={!selectedModels.length || !prompt || loading}
|
|
|
|
| 306 |
{/if}
|
| 307 |
</div>
|
| 308 |
</article>
|
| 309 |
+
<Handle type="target" id="t-top" position={Position.Top} class="opacity-0" />
|
| 310 |
+
<Handle type="target" id="t-left" position={Position.Left} class="opacity-0" />
|
| 311 |
+
<Handle type="target" id="t-right" position={Position.Right} class="opacity-0" />
|
| 312 |
+
<Handle type="source" id="s-bottom" position={Position.Bottom} class="opacity-0" />
|
| 313 |
+
<Handle type="source" id="s-left" position={Position.Left} class="opacity-0" />
|
| 314 |
+
<Handle type="source" id="s-right" position={Position.Right} class="opacity-0" />
|
src/lib/components/flow/FitViewOnResize.svelte
CHANGED
|
@@ -125,12 +125,19 @@
|
|
| 125 |
if (fitViewTimer) clearTimeout(fitViewTimer);
|
| 126 |
});
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
/**
|
| 129 |
* Custom tree layout:
|
| 130 |
* - Root nodes (no incoming edge) on the same horizontal row
|
| 131 |
* - Siblings (children sharing the same source) on the same horizontal row
|
| 132 |
-
* -
|
| 133 |
-
* -
|
|
|
|
| 134 |
*/
|
| 135 |
function computeLayout(nodes: Node[], edges: Edge[]): Node[] {
|
| 136 |
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
@@ -148,7 +155,7 @@
|
|
| 148 |
// Root nodes: no incoming edge
|
| 149 |
const rootNodes = nodes.filter((n) => !hasParent.has(n.id));
|
| 150 |
|
| 151 |
-
// 1) Compute the horizontal space each subtree needs
|
| 152 |
const subtreeWidths = new Map<string, number>();
|
| 153 |
|
| 154 |
function computeSubtreeWidth(nodeId: string): number {
|
|
@@ -157,16 +164,19 @@
|
|
| 157 |
const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
|
| 158 |
const children = childrenBySource.get(nodeId) ?? [];
|
| 159 |
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 161 |
subtreeWidths.set(nodeId, nodeWidth);
|
| 162 |
return nodeWidth;
|
| 163 |
}
|
| 164 |
|
| 165 |
-
const
|
| 166 |
-
|
| 167 |
-
(
|
| 168 |
|
| 169 |
-
const width = Math.max(nodeWidth,
|
| 170 |
subtreeWidths.set(nodeId, width);
|
| 171 |
return width;
|
| 172 |
}
|
|
@@ -174,8 +184,14 @@
|
|
| 174 |
for (const root of rootNodes) {
|
| 175 |
computeSubtreeWidth(root.id);
|
| 176 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
// 2) Place nodes top-down
|
| 179 |
const positions = new Map<string, { x: number; y: number }>();
|
| 180 |
|
| 181 |
function placeNode(nodeId: string, allocatedX: number, y: number) {
|
|
@@ -183,28 +199,39 @@
|
|
| 183 |
const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
|
| 184 |
const nodeHeight = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT;
|
| 185 |
const stWidth = subtreeWidths.get(nodeId) ?? nodeWidth;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
// Center node within its allocated subtree space
|
| 188 |
const x = allocatedX + (stWidth - nodeWidth) / 2;
|
| 189 |
positions.set(nodeId, { x, y });
|
| 190 |
|
| 191 |
-
|
| 192 |
-
if (
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
| 201 |
}
|
| 202 |
}
|
| 203 |
|
| 204 |
// Place root nodes side by side
|
| 205 |
let rootX = 0;
|
| 206 |
for (const root of rootNodes) {
|
| 207 |
-
computeSubtreeWidth(root.id);
|
| 208 |
placeNode(root.id, rootX, 0);
|
| 209 |
rootX += (subtreeWidths.get(root.id) ?? DEFAULT_WIDTH) + H_SPACING;
|
| 210 |
}
|
|
|
|
| 125 |
if (fitViewTimer) clearTimeout(fitViewTimer);
|
| 126 |
});
|
| 127 |
|
| 128 |
+
/** Whether a child should be placed to the right of its parent (same row) */
|
| 129 |
+
function isSideChild(nodeId: string, nodeMap: Map<string, Node>): boolean {
|
| 130 |
+
const node = nodeMap.get(nodeId);
|
| 131 |
+
return node?.type === 'user-follow-up';
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
/**
|
| 135 |
* Custom tree layout:
|
| 136 |
* - Root nodes (no incoming edge) on the same horizontal row
|
| 137 |
* - Siblings (children sharing the same source) on the same horizontal row
|
| 138 |
+
* - user-follow-up nodes placed to the RIGHT of their source (same row)
|
| 139 |
+
* - Parent centered above its below-children
|
| 140 |
+
* - Works recursively for any depth
|
| 141 |
*/
|
| 142 |
function computeLayout(nodes: Node[], edges: Edge[]): Node[] {
|
| 143 |
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
|
|
| 155 |
// Root nodes: no incoming edge
|
| 156 |
const rootNodes = nodes.filter((n) => !hasParent.has(n.id));
|
| 157 |
|
| 158 |
+
// 1) Compute the horizontal space each subtree needs (ignoring side children)
|
| 159 |
const subtreeWidths = new Map<string, number>();
|
| 160 |
|
| 161 |
function computeSubtreeWidth(nodeId: string): number {
|
|
|
|
| 164 |
const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
|
| 165 |
const children = childrenBySource.get(nodeId) ?? [];
|
| 166 |
|
| 167 |
+
// Side children are independent — they don't affect the main tree width
|
| 168 |
+
const belowChildIds = children.filter((id) => !isSideChild(id, nodeMap));
|
| 169 |
+
|
| 170 |
+
if (belowChildIds.length === 0) {
|
| 171 |
subtreeWidths.set(nodeId, nodeWidth);
|
| 172 |
return nodeWidth;
|
| 173 |
}
|
| 174 |
|
| 175 |
+
const belowChildrenWidth =
|
| 176 |
+
belowChildIds.reduce((sum, cid) => sum + computeSubtreeWidth(cid), 0) +
|
| 177 |
+
(belowChildIds.length - 1) * H_SPACING;
|
| 178 |
|
| 179 |
+
const width = Math.max(nodeWidth, belowChildrenWidth);
|
| 180 |
subtreeWidths.set(nodeId, width);
|
| 181 |
return width;
|
| 182 |
}
|
|
|
|
| 184 |
for (const root of rootNodes) {
|
| 185 |
computeSubtreeWidth(root.id);
|
| 186 |
}
|
| 187 |
+
// Also compute widths for side-child subtrees (they need it for their own below-children)
|
| 188 |
+
for (const node of nodes) {
|
| 189 |
+
if (!subtreeWidths.has(node.id)) {
|
| 190 |
+
computeSubtreeWidth(node.id);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
|
| 194 |
+
// 2) Place nodes top-down
|
| 195 |
const positions = new Map<string, { x: number; y: number }>();
|
| 196 |
|
| 197 |
function placeNode(nodeId: string, allocatedX: number, y: number) {
|
|
|
|
| 199 |
const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
|
| 200 |
const nodeHeight = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT;
|
| 201 |
const stWidth = subtreeWidths.get(nodeId) ?? nodeWidth;
|
| 202 |
+
const children = childrenBySource.get(nodeId) ?? [];
|
| 203 |
+
|
| 204 |
+
const sideChildIds = children.filter((id) => isSideChild(id, nodeMap));
|
| 205 |
+
const belowChildIds = children.filter((id) => !isSideChild(id, nodeMap));
|
| 206 |
|
| 207 |
// Center node within its allocated subtree space
|
| 208 |
const x = allocatedX + (stWidth - nodeWidth) / 2;
|
| 209 |
positions.set(nodeId, { x, y });
|
| 210 |
|
| 211 |
+
// Place below-children under this node
|
| 212 |
+
if (belowChildIds.length > 0) {
|
| 213 |
+
const childY = y + nodeHeight + V_SPACING;
|
| 214 |
+
let childX = allocatedX;
|
| 215 |
+
for (const childId of belowChildIds) {
|
| 216 |
+
const childStWidth = subtreeWidths.get(childId) ?? DEFAULT_WIDTH;
|
| 217 |
+
placeNode(childId, childX, childY);
|
| 218 |
+
childX += childStWidth + H_SPACING;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
|
| 222 |
+
// Place side-children to the right of this node, same Y (independent of main tree)
|
| 223 |
+
let sideX = x + nodeWidth + H_SPACING;
|
| 224 |
+
for (const sideId of sideChildIds) {
|
| 225 |
+
placeNode(sideId, sideX, y);
|
| 226 |
+
const sideNode = nodeMap.get(sideId);
|
| 227 |
+
const sideWidth = sideNode ? getMeasuredWidth(sideNode) : DEFAULT_WIDTH;
|
| 228 |
+
sideX += sideWidth + H_SPACING;
|
| 229 |
}
|
| 230 |
}
|
| 231 |
|
| 232 |
// Place root nodes side by side
|
| 233 |
let rootX = 0;
|
| 234 |
for (const root of rootNodes) {
|
|
|
|
| 235 |
placeNode(root.id, rootX, 0);
|
| 236 |
rootX += (subtreeWidths.get(root.id) ?? DEFAULT_WIDTH) + H_SPACING;
|
| 237 |
}
|
src/lib/helpers/resolve-collisions.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Node } from '@xyflow/svelte';
|
| 2 |
+
|
| 3 |
+
export type CollisionAlgorithmOptions = {
|
| 4 |
+
maxIterations: number;
|
| 5 |
+
overlapThreshold: number;
|
| 6 |
+
margin: number;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export type CollisionAlgorithm = (nodes: Node[], options: CollisionAlgorithmOptions) => Node[];
|
| 10 |
+
|
| 11 |
+
type Box = {
|
| 12 |
+
x: number;
|
| 13 |
+
y: number;
|
| 14 |
+
width: number;
|
| 15 |
+
height: number;
|
| 16 |
+
moved: boolean;
|
| 17 |
+
node: Node;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
function getBoxesFromNodes(nodes: Node[], margin = 0): Box[] {
|
| 21 |
+
const boxes: Box[] = new Array(nodes.length);
|
| 22 |
+
|
| 23 |
+
for (let i = 0; i < nodes.length; i++) {
|
| 24 |
+
const node = nodes[i];
|
| 25 |
+
boxes[i] = {
|
| 26 |
+
x: node.position.x - margin,
|
| 27 |
+
y: node.position.y - margin,
|
| 28 |
+
width: (node.width ?? node.measured?.width ?? 0) + margin * 2,
|
| 29 |
+
height: (node.height ?? node.measured?.height ?? 0) + margin * 2,
|
| 30 |
+
node,
|
| 31 |
+
moved: false
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return boxes;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export const resolveCollisions: CollisionAlgorithm = (
|
| 39 |
+
nodes,
|
| 40 |
+
{ maxIterations = 50, overlapThreshold = 0.5, margin = 0 }
|
| 41 |
+
) => {
|
| 42 |
+
const boxes = getBoxesFromNodes(nodes, margin);
|
| 43 |
+
|
| 44 |
+
for (let iter = 0; iter <= maxIterations; iter++) {
|
| 45 |
+
let moved = false;
|
| 46 |
+
|
| 47 |
+
for (let i = 0; i < boxes.length; i++) {
|
| 48 |
+
for (let j = i + 1; j < boxes.length; j++) {
|
| 49 |
+
const A = boxes[i];
|
| 50 |
+
const B = boxes[j];
|
| 51 |
+
|
| 52 |
+
// Calculate center positions
|
| 53 |
+
const centerAX = A.x + A.width * 0.5;
|
| 54 |
+
const centerAY = A.y + A.height * 0.5;
|
| 55 |
+
const centerBX = B.x + B.width * 0.5;
|
| 56 |
+
const centerBY = B.y + B.height * 0.5;
|
| 57 |
+
|
| 58 |
+
// Calculate distance between centers
|
| 59 |
+
const dx = centerAX - centerBX;
|
| 60 |
+
const dy = centerAY - centerBY;
|
| 61 |
+
|
| 62 |
+
// Calculate overlap along each axis
|
| 63 |
+
const px = (A.width + B.width) * 0.5 - Math.abs(dx);
|
| 64 |
+
const py = (A.height + B.height) * 0.5 - Math.abs(dy);
|
| 65 |
+
|
| 66 |
+
// Check if there's significant overlap
|
| 67 |
+
if (px > overlapThreshold && py > overlapThreshold) {
|
| 68 |
+
A.moved = B.moved = moved = true;
|
| 69 |
+
// Resolve along the smallest overlap axis
|
| 70 |
+
if (px < py) {
|
| 71 |
+
// Move along x-axis
|
| 72 |
+
const sx = dx > 0 ? 1 : -1;
|
| 73 |
+
const moveAmount = (px / 2) * sx;
|
| 74 |
+
A.x += moveAmount;
|
| 75 |
+
B.x -= moveAmount;
|
| 76 |
+
} else {
|
| 77 |
+
// Move along y-axis
|
| 78 |
+
const sy = dy > 0 ? 1 : -1;
|
| 79 |
+
const moveAmount = (py / 2) * sy;
|
| 80 |
+
A.y += moveAmount;
|
| 81 |
+
B.y -= moveAmount;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
// Early exit if no overlaps were found
|
| 87 |
+
if (!moved) {
|
| 88 |
+
break;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const newNodes = boxes.map((box) => {
|
| 93 |
+
if (box.moved) {
|
| 94 |
+
return {
|
| 95 |
+
...box.node,
|
| 96 |
+
position: {
|
| 97 |
+
x: box.x + margin,
|
| 98 |
+
y: box.y + margin
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
return box.node;
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
return newNodes;
|
| 106 |
+
};
|
src/routes/+page.svelte
CHANGED
|
@@ -23,10 +23,12 @@
|
|
| 23 |
import { breakpointsState } from '$lib/state/breakpoints.svelte';
|
| 24 |
import PanelCanvasActions from '$lib/components/flow/actions/PanelCanvasActions.svelte';
|
| 25 |
import { viewState } from '$lib/state/view.svelte';
|
|
|
|
| 26 |
|
| 27 |
const nodeTypes = {
|
| 28 |
user: User,
|
| 29 |
-
assistant: Assistant
|
|
|
|
| 30 |
};
|
| 31 |
|
| 32 |
function getInitialNodes() {
|
|
@@ -37,7 +39,7 @@
|
|
| 37 |
position: { x: 0, y: 0 },
|
| 38 |
data: {
|
| 39 |
isFirstNode: true,
|
| 40 |
-
selectedModels: modelsState.models.slice(
|
| 41 |
}
|
| 42 |
}
|
| 43 |
];
|
|
@@ -56,7 +58,7 @@
|
|
| 56 |
// }
|
| 57 |
}}
|
| 58 |
/>
|
| 59 |
-
|
| 60 |
<div class="h-screen w-screen overflow-hidden">
|
| 61 |
<SvelteFlow
|
| 62 |
bind:nodes
|
|
@@ -81,6 +83,13 @@
|
|
| 81 |
panOnDrag={viewState.draggable}
|
| 82 |
onbeforedelete={() => Promise.resolve(false)}
|
| 83 |
defaultEdgeOptions={{ type: 'smoothstep' }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
class="bg-background!"
|
| 85 |
>
|
| 86 |
<FitViewOnResize {initialNodes} />
|
|
|
|
| 23 |
import { breakpointsState } from '$lib/state/breakpoints.svelte';
|
| 24 |
import PanelCanvasActions from '$lib/components/flow/actions/PanelCanvasActions.svelte';
|
| 25 |
import { viewState } from '$lib/state/view.svelte';
|
| 26 |
+
import { resolveCollisions } from '$lib/helpers/resolve-collisions';
|
| 27 |
|
| 28 |
const nodeTypes = {
|
| 29 |
user: User,
|
| 30 |
+
assistant: Assistant,
|
| 31 |
+
'user-follow-up': User
|
| 32 |
};
|
| 33 |
|
| 34 |
function getInitialNodes() {
|
|
|
|
| 39 |
position: { x: 0, y: 0 },
|
| 40 |
data: {
|
| 41 |
isFirstNode: true,
|
| 42 |
+
selectedModels: modelsState.models.slice(1, 1 + MAX_DEFAULT_MODELS) as ChatModel[]
|
| 43 |
}
|
| 44 |
}
|
| 45 |
];
|
|
|
|
| 58 |
// }
|
| 59 |
}}
|
| 60 |
/>
|
| 61 |
+
<!-- todo: resolve collissions when new nodes are added, maybe do it in the fitviewonresize component -->
|
| 62 |
<div class="h-screen w-screen overflow-hidden">
|
| 63 |
<SvelteFlow
|
| 64 |
bind:nodes
|
|
|
|
| 83 |
panOnDrag={viewState.draggable}
|
| 84 |
onbeforedelete={() => Promise.resolve(false)}
|
| 85 |
defaultEdgeOptions={{ type: 'smoothstep' }}
|
| 86 |
+
onnodedragstop={() => {
|
| 87 |
+
nodes = resolveCollisions(nodes, {
|
| 88 |
+
maxIterations: Infinity,
|
| 89 |
+
overlapThreshold: 0.5,
|
| 90 |
+
margin: 15
|
| 91 |
+
});
|
| 92 |
+
}}
|
| 93 |
class="bg-background!"
|
| 94 |
>
|
| 95 |
<FitViewOnResize {initialNodes} />
|