enzostvs HF Staff commited on
Commit
5fc0417
Β·
1 Parent(s): 88f11b2

try stuffs

Browse files
src/lib/components/chat/Assistant.svelte CHANGED
@@ -29,14 +29,15 @@
29
  let loading = $derived((nodeData.current?.data.loading as boolean) ?? false);
30
  let usage = $derived((nodeData.current?.data.usage as TokenUsage) ?? null);
31
  let message = $derived(
32
- nodeData.current?.data.content
33
  ? ({
34
  role: 'assistant',
35
- content: nodeData.current?.data.content,
36
  timestamp: (nodeData.current?.data.timestamp as number) ?? 0
37
- } as ChatMessage)
38
  : null
39
  );
 
40
  let containerRef: HTMLDivElement | null = $state(null);
41
  let selectedText = $state<string | null>(null);
42
  let selectedTextPosition = $state<{ y: number } | null>(null);
@@ -109,35 +110,38 @@
109
  </script>
110
 
111
  <article
112
- class="relative w-full rounded-3xl border border-border bg-background p-5 shadow-lg/5 lg:w-[600px]"
 
113
  >
114
- <div class="nodrag pointer-events-auto cursor-auto">
115
- <header class="mb-3 flex items-center justify-between">
116
- <div class="flex flex-wrap items-center gap-1">
117
- <div
118
- class="group relative inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md border bg-background px-3 text-sm font-normal! text-gray-600 has-[>svg]:px-2.5 dark:border-input dark:bg-input/30 dark:text-gray-400"
119
- >
120
- <img
121
- src={selectedModel.avatarUrl}
122
- alt={selectedModel.modelName}
123
- class="size-3.5 rounded-full"
124
- />
125
- {selectedModel.modelName}
 
 
126
  </div>
127
- </div>
128
- </header>
129
 
130
- {#if loading}
131
- <div class="flex items-center justify-start gap-1">
132
- <Spinner className="size-4!" />
133
- <p class="text-sm text-muted-foreground/70">Reaching out to the AI...</p>
134
- </div>
135
- {/if}
136
- {#if message}
137
- <div bind:this={containerRef}>
138
- <Message {message} />
139
- </div>
140
- {/if}
 
141
  {#if usage && !loading && message}
142
  {@const provider = selectedModel.provider}
143
  <p class="mt-3 border-t border-border pt-3 text-xs text-muted-foreground">
 
29
  let loading = $derived((nodeData.current?.data.loading as boolean) ?? false);
30
  let usage = $derived((nodeData.current?.data.usage as TokenUsage) ?? null);
31
  let message = $derived(
32
+ nodeData.current?.data.content != null
33
  ? ({
34
  role: 'assistant',
35
+ content: String(nodeData.current?.data.content ?? ''),
36
  timestamp: (nodeData.current?.data.timestamp as number) ?? 0
37
+ } as unknown as ChatMessage)
38
  : null
39
  );
40
+ let rowHeight = $derived((nodeData.current?.data.rowHeight as number) ?? null);
41
  let containerRef: HTMLDivElement | null = $state(null);
42
  let selectedText = $state<string | null>(null);
43
  let selectedTextPosition = $state<{ y: number } | null>(null);
 
110
  </script>
111
 
112
  <article
113
+ class="relative flex w-full flex-col rounded-3xl border border-border bg-background p-5 shadow-lg/5 lg:w-[600px]"
114
+ style={rowHeight ? `min-height: ${rowHeight}px` : ''}
115
  >
116
+ <div class="nodrag pointer-events-auto flex flex-1 cursor-auto flex-col justify-between">
117
+ <div>
118
+ <header class="mb-3 flex items-center justify-between">
119
+ <div class="flex flex-wrap items-center gap-1">
120
+ <div
121
+ class="group relative inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md border bg-background px-3 text-sm font-normal! text-gray-600 has-[>svg]:px-2.5 dark:border-input dark:bg-input/30 dark:text-gray-400"
122
+ >
123
+ <img
124
+ src={selectedModel.avatarUrl}
125
+ alt={selectedModel.modelName}
126
+ class="size-3.5 rounded-full"
127
+ />
128
+ {selectedModel.modelName}
129
+ </div>
130
  </div>
131
+ </header>
 
132
 
133
+ {#if loading}
134
+ <div class="flex items-center justify-start gap-1">
135
+ <Spinner className="size-4!" />
136
+ <p class="text-sm text-muted-foreground/70">Reaching out to the AI...</p>
137
+ </div>
138
+ {/if}
139
+ {#if message}
140
+ <div bind:this={containerRef}>
141
+ <Message {message} />
142
+ </div>
143
+ {/if}
144
+ </div>
145
  {#if usage && !loading && message}
146
  {@const provider = selectedModel.provider}
147
  <p class="mt-3 border-t border-border pt-3 text-xs text-muted-foreground">
src/lib/components/chat/User.svelte CHANGED
@@ -38,7 +38,6 @@
38
  );
39
  let messages = $derived((nodeData.current?.data.messages as ChatMessage[]) ?? []);
40
  let isFirstNode = $derived((nodeData.current?.data.isFirstNode as boolean) ?? false);
41
- let canDeleteWelcome = $derived((nodeData.current?.data.canDeleteWelcome as boolean) ?? false);
42
 
43
  let prompt = $state.raw<string>((nodeData.current?.data.prompt as string) ?? '');
44
  let loading = $state.raw<boolean>(false);
@@ -58,9 +57,6 @@
58
  },
59
  { replace: true }
60
  );
61
- if (lastMessage) {
62
- handleTriggerAction([model]);
63
- }
64
  }
65
  }
66
  function removeModel(model: ChatModel) {
@@ -116,121 +112,151 @@
116
  }
117
 
118
  async function handleTriggerAiCall(newNodes: Node[]) {
119
- newNodes.forEach(async (node) => {
120
- const model = node?.data?.selectedModel as ChatModel;
121
- if (!model) return;
122
- try {
123
- const start = Date.now();
124
- const response = await fetch('/api', {
125
- method: 'POST',
126
- body: JSON.stringify({
127
- model: model.id,
128
- provider: model.provider,
129
- messages,
130
- billingTo: authState.user?.billingOption ?? 'personal',
131
- options: {
132
- temperature: model.temperature,
133
- max_tokens: model.max_tokens,
134
- top_p: model.top_p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  }
136
- }),
137
- headers: {
138
- Authorization: `Bearer ${authState.token ?? ''}`
 
139
  }
140
- });
141
- if (!response.ok) {
142
- const errorBody = await response.text().catch(() => response.statusText);
143
- throw new Error(errorBody || response.statusText);
144
- }
145
- if (!response.body) throw new Error('No response body');
146
 
147
- let content = '';
148
- let usage: TokenUsage | null = null;
149
 
150
- const reader = response.body.getReader();
151
- const decoder = new TextDecoder();
152
 
153
- while (true) {
154
- const { done, value } = await reader.read();
155
- if (done) {
156
- if (content.includes('__ERROR__')) {
157
- const errorMessage = content.split('__ERROR__').pop() ?? 'Unknown error';
158
- throw new Error(errorMessage);
159
- }
160
- if (content.includes('__USAGE__')) {
161
- const usageParts = content.split('__USAGE__');
162
- const usageJson = usageParts.pop() ?? '';
163
- content = usageParts.join('').trimEnd();
164
- try {
165
- usage = JSON.parse(usageJson) as TokenUsage;
166
- } catch {
167
- // ignore malformed usage JSON
168
  }
169
- }
170
- const newNodeId = `user-${crypto.randomUUID()}`;
171
- const newNode: Node = {
172
- id: newNodeId,
173
- type: 'user',
174
- position: {
175
- x: 0,
176
- y: 0
177
- },
178
- data: {
179
- role: 'user',
180
- messages: [...messages, { role: 'assistant', content }],
181
- selectedModels: [model]
182
  }
183
- };
184
- const newEdge: Edge = {
185
- id: `edge-${crypto.randomUUID()}`,
186
- source: node.id,
187
- target: newNodeId
188
- };
189
- updateNodes((currentNodes) => [...currentNodes, newNode]);
190
- updateEdges((currentEdges) => [...currentEdges, newEdge]);
191
- const end = Date.now();
192
- updateNodeData(
193
- node.id,
194
- {
195
- ...node.data,
196
- content,
197
- timestamp: end - start,
198
- loading: false,
199
- messages,
200
- usage
201
- },
202
- { replace: true }
203
- );
204
- break;
205
- }
206
 
207
- content += decoder.decode(value, { stream: true });
208
- updateNodeData(node.id, { ...node.data, content, loading: false }, { replace: true });
209
- }
210
- } catch (error) {
211
- const msg = error instanceof Error ? error.message : 'An unknown error occurred';
212
- updateNodes((currentNodes) => currentNodes.filter((n) => n.id !== node.id));
213
- updateEdges((currentEdges) => currentEdges.filter((e) => e.target !== node.id));
214
- updateNodeData(
215
- id,
216
- {
217
- ...nodeData.current?.data,
218
- messages
219
- },
220
- { replace: true }
221
- );
222
- if (newNodes.length === 1) {
223
  updateNodeData(
224
  id,
225
- { ...nodeData.current?.data, messages: messages.slice(0, -1), prompt: 'hello world' },
 
 
 
226
  { replace: true }
227
  );
 
 
 
 
 
 
 
 
 
 
228
  }
229
- errorMessage += `\n${msg}`;
230
- } finally {
231
- loading = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
- });
 
 
 
 
 
 
 
234
  }
235
 
236
  let lastMessage = $derived(
@@ -242,7 +268,6 @@
242
  function handlePromptInput(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) {
243
  const value = (e.target as HTMLTextAreaElement).value;
244
  if (isFirstNode) {
245
- // updateNodeData(id, { ...nodeData.current?.data, canDeleteWelcome: false }, { replace: true });
246
  const welcomeNodeId = currentNodes.find((n) => n.type === 'welcome')?.id;
247
  if (!welcomeNodeId) return;
248
  if (value.trim() === '') {
@@ -289,7 +314,7 @@
289
  >
290
  <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
291
  {model.modelName}
292
- {#if !lastMessage || model.isError}
293
  <Button
294
  variant="default"
295
  size="icon-3xs"
@@ -306,7 +331,7 @@
306
  </Button>
307
  </SettingsModel>
308
  {/each}
309
- {#if selectedModels.length < MAX_MODELS_PER_NODE && !loading}
310
  <ComboBoxModels onSelect={addModel} excludeIds={selectedModels.map((m) => m.id)} />
311
  {/if}
312
  </div>
 
38
  );
39
  let messages = $derived((nodeData.current?.data.messages as ChatMessage[]) ?? []);
40
  let isFirstNode = $derived((nodeData.current?.data.isFirstNode as boolean) ?? false);
 
41
 
42
  let prompt = $state.raw<string>((nodeData.current?.data.prompt as string) ?? '');
43
  let loading = $state.raw<boolean>(false);
 
57
  },
58
  { replace: true }
59
  );
 
 
 
60
  }
61
  }
62
  function removeModel(model: ChatModel) {
 
112
  }
113
 
114
  async function handleTriggerAiCall(newNodes: Node[]) {
115
+ const results = await Promise.all(
116
+ newNodes.map(async (node) => {
117
+ const model = node?.data?.selectedModel as ChatModel;
118
+ if (!model) return;
119
+ try {
120
+ const start = Date.now();
121
+ const formattedMessages = messages.map((message) => {
122
+ if (message.role === 'user') {
123
+ return {
124
+ role: 'user',
125
+ content: message.content
126
+ };
127
+ } else {
128
+ const modelData = message[model.id];
129
+ const content =
130
+ typeof modelData === 'object' && modelData && 'content' in modelData
131
+ ? modelData.content
132
+ : (Object.values(message).find(
133
+ (m) => typeof m === 'object' && m && 'content' in m
134
+ )?.content ?? '');
135
+ return {
136
+ role: 'assistant',
137
+ content
138
+ };
139
+ }
140
+ });
141
+ const response = await fetch('/api', {
142
+ method: 'POST',
143
+ body: JSON.stringify({
144
+ model: model.id,
145
+ provider: model.provider,
146
+ messages: formattedMessages,
147
+ billingTo: authState.user?.billingOption ?? 'personal',
148
+ options: {
149
+ temperature: model.temperature,
150
+ max_tokens: model.max_tokens,
151
+ top_p: model.top_p
152
+ }
153
+ }),
154
+ headers: {
155
+ Authorization: `Bearer ${authState.token ?? ''}`
156
  }
157
+ });
158
+ if (!response.ok) {
159
+ const errorBody = await response.text().catch(() => response.statusText);
160
+ throw new Error(errorBody || response.statusText);
161
  }
162
+ if (!response.body) throw new Error('No response body');
 
 
 
 
 
163
 
164
+ let content = '';
165
+ let usage: TokenUsage | null = null;
166
 
167
+ const reader = response.body.getReader();
168
+ const decoder = new TextDecoder();
169
 
170
+ while (true) {
171
+ const { done, value } = await reader.read();
172
+ if (done) {
173
+ if (content.includes('__ERROR__')) {
174
+ const errorMessage = content.split('__ERROR__').pop() ?? 'Unknown error';
175
+ throw new Error(errorMessage);
 
 
 
 
 
 
 
 
 
176
  }
177
+ if (content.includes('__USAGE__')) {
178
+ const usageParts = content.split('__USAGE__');
179
+ const usageJson = usageParts.pop() ?? '';
180
+ content = usageParts.join('').trimEnd();
181
+ try {
182
+ usage = JSON.parse(usageJson) as TokenUsage;
183
+ } catch {
184
+ // ignore malformed usage JSON
185
+ }
 
 
 
 
186
  }
187
+ const end = Date.now();
188
+ updateNodeData(
189
+ node.id,
190
+ {
191
+ ...node.data,
192
+ content,
193
+ timestamp: end - start,
194
+ loading: false,
195
+ messages,
196
+ usage
197
+ },
198
+ { replace: true }
199
+ );
200
+ return {
201
+ [model.id]: { content, timestamp: String(end - start) }
202
+ };
203
+ }
 
 
 
 
 
 
204
 
205
+ content += decoder.decode(value, { stream: true });
206
+ updateNodeData(node.id, { ...node.data, content, loading: false }, { replace: true });
207
+ }
208
+ } catch (error) {
209
+ const msg = error instanceof Error ? error.message : 'An unknown error occurred';
210
+ updateNodes((currentNodes) => currentNodes.filter((n) => n.id !== node.id));
211
+ updateEdges((currentEdges) => currentEdges.filter((e) => e.target !== node.id));
 
 
 
 
 
 
 
 
 
212
  updateNodeData(
213
  id,
214
+ {
215
+ ...nodeData.current?.data,
216
+ messages
217
+ },
218
  { replace: true }
219
  );
220
+ if (newNodes.length === 1) {
221
+ updateNodeData(
222
+ id,
223
+ { ...nodeData.current?.data, messages: messages.slice(0, -1), prompt: 'hello world' },
224
+ { replace: true }
225
+ );
226
+ }
227
+ errorMessage += `\n${msg}`;
228
+ } finally {
229
+ loading = false;
230
  }
231
+ })
232
+ );
233
+
234
+ const assistantMessage = results.reduce<ChatMessage>(
235
+ (acc, result) => (result ? { ...acc, ...result } : acc),
236
+ { role: 'assistant' }
237
+ );
238
+
239
+ const newNodeId = `user-${crypto.randomUUID()}`;
240
+ const newNode: Node = {
241
+ id: newNodeId,
242
+ type: 'user',
243
+ position: {
244
+ x: 0,
245
+ y: 0
246
+ },
247
+ data: {
248
+ role: 'user',
249
+ selectedModels,
250
+ messages: [...messages, assistantMessage]
251
  }
252
+ };
253
+ const newEdges: Edge[] = newNodes.map((node) => ({
254
+ id: `edge-${crypto.randomUUID()}`,
255
+ source: node.id,
256
+ target: newNodeId
257
+ }));
258
+ updateNodes((currentNodes) => [...currentNodes, newNode]);
259
+ updateEdges((currentEdges) => [...currentEdges, ...newEdges]);
260
  }
261
 
262
  let lastMessage = $derived(
 
268
  function handlePromptInput(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) {
269
  const value = (e.target as HTMLTextAreaElement).value;
270
  if (isFirstNode) {
 
271
  const welcomeNodeId = currentNodes.find((n) => n.type === 'welcome')?.id;
272
  if (!welcomeNodeId) return;
273
  if (value.trim() === '') {
 
314
  >
315
  <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
316
  {model.modelName}
317
+ {#if !lastMessage || model.isError || selectedModels.length > MAX_MODELS_PER_NODE}
318
  <Button
319
  variant="default"
320
  size="icon-3xs"
 
331
  </Button>
332
  </SettingsModel>
333
  {/each}
334
+ {#if !lastMessage && !loading}
335
  <ComboBoxModels onSelect={addModel} excludeIds={selectedModels.map((m) => m.id)} />
336
  {/if}
337
  </div>
src/lib/components/chat/Welcome.svelte CHANGED
@@ -24,10 +24,9 @@
24
  </script>
25
 
26
  {#if showWelcomeState}
27
- <!-- should go to the top when fading out -->
28
  <article
29
  class="nodrag pointer-events-auto relative w-full cursor-auto text-center lg:w-[600px]"
30
- transition:fly={{ duration: 300, y: -100 }}
31
  >
32
  <img src={HFLogo} alt="HF Logo" class="mx-auto size-14 lg:size-16" />
33
  <h1 class="font-mono text-4xl font-bold xl:text-5xl">Welcome to the Playground</h1>
 
24
  </script>
25
 
26
  {#if showWelcomeState}
 
27
  <article
28
  class="nodrag pointer-events-auto relative w-full cursor-auto text-center lg:w-[600px]"
29
+ transition:fly={{ duration: 300, y: -20 }}
30
  >
31
  <img src={HFLogo} alt="HF Logo" class="mx-auto size-14 lg:size-16" />
32
  <h1 class="font-mono text-4xl font-bold xl:text-5xl">Welcome to the Playground</h1>
src/lib/components/flow/FitViewOnResize.svelte CHANGED
@@ -1,5 +1,5 @@
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
3
  import type { Node, Edge } from '@xyflow/svelte';
4
  import { useNodes, useEdges } from '@xyflow/svelte';
5
  import { useSvelteFlow } from '@xyflow/svelte';
@@ -9,7 +9,7 @@
9
  let { initialNodes }: { initialNodes: Node[] } = $props();
10
 
11
  const DEFAULT_WIDTH = breakpointsState.isMobile ? 300 : 600;
12
- const DEFAULT_HEIGHT = 200;
13
  const H_SPACING = 60;
14
  const V_SPACING = 60;
15
 
@@ -18,7 +18,8 @@
18
  const edgesStore = useEdges();
19
 
20
  let lastLayoutKey = $state<string | null>(null);
21
- let isFirstLayout = true;
 
22
 
23
  // ─── Helpers ────────────────────────────────────────────────────────
24
 
@@ -64,6 +65,23 @@
64
  })()
65
  );
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  $effect(() => {
68
  const key = layoutKey;
69
  if (key === lastLayoutKey) return;
@@ -77,18 +95,22 @@
77
  runLayout(ns, es);
78
  });
79
 
 
 
 
 
 
 
 
 
 
 
 
80
  // ─── Window resize ─────────────────────────────────────────────────
81
 
82
  function handleWindowResize() {
83
  if (!viewState.draggable) return;
84
- const nonWelcome = nodesStore.current.filter((n) => !isWelcome(n)).map((n) => ({ id: n.id }));
85
- fitView({
86
- maxZoom: 1,
87
- minZoom: breakpointsState.isMobile ? 1 : 0.8,
88
- interpolate: 'smooth',
89
- duration: 500,
90
- nodes: nonWelcome
91
- });
92
  }
93
 
94
  onMount(() => window.addEventListener('resize', handleWindowResize));
@@ -100,17 +122,20 @@
100
  //
101
  // Each node's subtree is split into two non-overlapping horizontal zones:
102
  //
103
- // Block A (left) β€” the node itself, with its below-children stacked underneath.
104
  // Block B (right) β€” side-children (user-follow-up) and their full subtrees.
105
  //
106
  // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€Block A──────────────┐ β”Œβ”€β”€β”€β”€β”€Block B─────┐
107
- // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
108
- // β”‚ β”‚ Parent β”‚ β”‚ β”‚ β”‚ Side node β”‚ β”‚
109
- // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
110
- // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
111
- // β”‚ β”‚ Child A β”‚ β”‚ Child B β”‚ β”‚ β”‚ β”‚ Side sub β”‚ β”‚
112
- // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
113
- // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 
 
 
114
  //
115
  // Phase 1 (bottom-up): compute the bounding-box extent of every subtree.
116
  // Phase 2 (top-down): assign positions, centering parents above children.
@@ -132,6 +157,7 @@
132
 
133
  // Build parent β†’ children adjacency from edges
134
  const childrenOf = new Map<string, string[]>();
 
135
  const hasParent = new Set<string>();
136
 
137
  for (const e of edges) {
@@ -139,6 +165,34 @@
139
  const list = childrenOf.get(e.source) ?? [];
140
  list.push(e.target);
141
  childrenOf.set(e.source, list);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  }
143
 
144
  // Separate welcome nodes from the rest
@@ -159,6 +213,7 @@
159
  const children = childrenOf.get(nodeId) ?? [];
160
  const sideIds = children.filter((id) => isSideChild(id, nodeMap));
161
  const belowIds = children.filter((id) => !isSideChild(id, nodeMap));
 
162
 
163
  // Block A β€” node width vs. combined below-children width
164
  let belowTotalW = 0;
@@ -171,8 +226,19 @@
171
  belowMaxH = Math.max(belowMaxH, ext.height);
172
  }
173
 
174
- const blockAW = Math.max(nodeW, belowTotalW);
175
- const blockAH = belowIds.length > 0 ? nodeH + V_SPACING + belowMaxH : nodeH;
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  // Block B β€” side-children and their subtrees
178
  let blockBW = 0;
@@ -202,6 +268,7 @@
202
  // ── Phase 2: assign positions top-down ──────────────────────────
203
 
204
  const positions = new Map<string, { x: number; y: number }>();
 
205
 
206
  function placeSubtree(nodeId: string, allocX: number, y: number) {
207
  const node = nodeMap.get(nodeId);
@@ -220,7 +287,7 @@
220
  y
221
  });
222
 
223
- // Place below-children, centered within Block A
224
  if (belowIds.length > 0) {
225
  let belowTotalW = 0;
226
  for (let i = 0; i < belowIds.length; i++) {
@@ -228,6 +295,18 @@
228
  belowTotalW += extents.get(belowIds[i])!.width;
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  const childY = y + nodeH + V_SPACING;
232
  let childX = allocX + (blockAWidth - belowTotalW) / 2;
233
 
@@ -237,6 +316,31 @@
237
  }
238
  }
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  // Place side-children to the right of Block A (non-overlapping zone)
241
  let sideX = allocX + blockAWidth + H_SPACING;
242
  for (const sid of sideIds) {
@@ -280,10 +384,12 @@
280
  positions.set(node.id, { x: 0, y: maxY + DEFAULT_HEIGHT + V_SPACING });
281
  }
282
 
283
- return nodes.map((n) => ({
284
- ...n,
285
- position: positions.get(n.id) ?? { x: 0, y: 0 }
286
- }));
 
 
287
  }
288
 
289
  // ─── Apply layout to the store ─────────────────────────────────────
@@ -299,21 +405,19 @@
299
  nodesStore.set(result);
300
  } else {
301
  const posMap = new Map(result.map((n) => [n.id, n.position]));
 
302
  nodesStore.update((prev) =>
303
  prev.map((n) => {
304
  const pos = posMap.get(n.id);
305
- return pos ? { ...n, position: pos } : n;
 
 
306
  })
307
  );
308
  }
309
 
310
  if (viewState.draggable) {
311
- fitView({
312
- maxZoom: breakpointsState.isMobile ? 1 : 1.15,
313
- minZoom: breakpointsState.isMobile ? 1 : 0.7,
314
- ...(isFirstLayout ? {} : { interpolate: 'smooth' as const, duration: 250 }),
315
- nodes: nonWelcomeIds
316
- });
317
  isFirstLayout = false;
318
  }
319
  }
 
1
  <script lang="ts">
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
  import type { Node, Edge } from '@xyflow/svelte';
4
  import { useNodes, useEdges } from '@xyflow/svelte';
5
  import { useSvelteFlow } from '@xyflow/svelte';
 
9
  let { initialNodes }: { initialNodes: Node[] } = $props();
10
 
11
  const DEFAULT_WIDTH = breakpointsState.isMobile ? 300 : 600;
12
+ const DEFAULT_HEIGHT = 100;
13
  const H_SPACING = 60;
14
  const V_SPACING = 60;
15
 
 
18
  const edgesStore = useEdges();
19
 
20
  let lastLayoutKey = $state<string | null>(null);
21
+ let isFirstLayout = $state(true);
22
+ let lastDraggable = $state(viewState.draggable);
23
 
24
  // ─── Helpers ────────────────────────────────────────────────────────
25
 
 
65
  })()
66
  );
67
 
68
+ // ─── Fit view helper (only when draggable) ──────────────────────────
69
+
70
+ function doFitView(opts?: { animate?: boolean; forceAnimate?: boolean }) {
71
+ if (!viewState.draggable) return;
72
+ const nonWelcome = nodesStore.current.filter((n) => !isWelcome(n)).map((n) => ({ id: n.id }));
73
+ if (nonWelcome.length === 0) return;
74
+ const shouldAnimate =
75
+ opts?.forceAnimate || (opts?.animate !== false && !isFirstLayout);
76
+ fitView({
77
+ maxZoom: breakpointsState.isMobile ? 1 : 1.15,
78
+ minZoom: breakpointsState.isMobile ? 1 : 0.7,
79
+ padding: 0.15,
80
+ ...(shouldAnimate ? { interpolate: 'smooth' as const, duration: 250 } : {}),
81
+ nodes: nonWelcome
82
+ });
83
+ }
84
+
85
  $effect(() => {
86
  const key = layoutKey;
87
  if (key === lastLayoutKey) return;
 
95
  runLayout(ns, es);
96
  });
97
 
98
+ // When draggable switches to true: fitView after DOM settles (no blink)
99
+ $effect(() => {
100
+ const draggable = viewState.draggable;
101
+ if (draggable === lastDraggable) return;
102
+ lastDraggable = draggable;
103
+ if (!draggable) return;
104
+ tick().then(() => {
105
+ requestAnimationFrame(() => doFitView({ forceAnimate: true }));
106
+ });
107
+ });
108
+
109
  // ─── Window resize ─────────────────────────────────────────────────
110
 
111
  function handleWindowResize() {
112
  if (!viewState.draggable) return;
113
+ doFitView({ animate: true });
 
 
 
 
 
 
 
114
  }
115
 
116
  onMount(() => window.addEventListener('resize', handleWindowResize));
 
122
  //
123
  // Each node's subtree is split into two non-overlapping horizontal zones:
124
  //
125
+ // Block A (left) β€” user centered above assistants, shared user centered below.
126
  // Block B (right) β€” side-children (user-follow-up) and their full subtrees.
127
  //
128
  // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€Block A──────────────┐ β”Œβ”€β”€β”€β”€β”€Block B─────┐
129
+ // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
130
+ // β”‚ β”‚ USER β”‚ β”‚ β”‚ β”‚ Side node β”‚ β”‚
131
+ // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
132
+ // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
133
+ // β”‚ β”‚ ASS 1 β”‚ β”‚ ASS 2 β”‚ β”‚ β”‚ β”‚ Side sub β”‚ β”‚
134
+ // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
135
+ // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
136
+ // β”‚ β”‚ USER β”‚ β”‚
137
+ // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
138
+ // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
139
  //
140
  // Phase 1 (bottom-up): compute the bounding-box extent of every subtree.
141
  // Phase 2 (top-down): assign positions, centering parents above children.
 
157
 
158
  // Build parent β†’ children adjacency from edges
159
  const childrenOf = new Map<string, string[]>();
160
+ const parentsOf = new Map<string, string[]>();
161
  const hasParent = new Set<string>();
162
 
163
  for (const e of edges) {
 
165
  const list = childrenOf.get(e.source) ?? [];
166
  list.push(e.target);
167
  childrenOf.set(e.source, list);
168
+ const parents = parentsOf.get(e.target) ?? [];
169
+ parents.push(e.source);
170
+ parentsOf.set(e.target, parents);
171
+ }
172
+
173
+ // Move shared children (user node with multiple assistant parents) to grandparent.
174
+ // They get placed centered below the assistants row.
175
+ const sharedBelowOf = new Map<string, string[]>();
176
+ for (const [childId, parents] of parentsOf) {
177
+ if (parents.length < 2) continue;
178
+ // All parents must share the same parent (grandparent)
179
+ const firstParent = parents[0];
180
+ const grandparentId = (parentsOf.get(firstParent) ?? [])[0];
181
+ if (!grandparentId || !parents.every((p) => (parentsOf.get(p) ?? []).includes(grandparentId)))
182
+ continue;
183
+ const gpChildren = childrenOf.get(grandparentId) ?? [];
184
+ if (!parents.every((p) => gpChildren.includes(p))) continue;
185
+ // Remove child from each parent
186
+ for (const p of parents) {
187
+ const list = childrenOf.get(p) ?? [];
188
+ childrenOf.set(
189
+ p,
190
+ list.filter((id) => id !== childId)
191
+ );
192
+ }
193
+ const list = sharedBelowOf.get(grandparentId) ?? [];
194
+ list.push(childId);
195
+ sharedBelowOf.set(grandparentId, list);
196
  }
197
 
198
  // Separate welcome nodes from the rest
 
213
  const children = childrenOf.get(nodeId) ?? [];
214
  const sideIds = children.filter((id) => isSideChild(id, nodeMap));
215
  const belowIds = children.filter((id) => !isSideChild(id, nodeMap));
216
+ const sharedBelowIds = sharedBelowOf.get(nodeId) ?? [];
217
 
218
  // Block A β€” node width vs. combined below-children width
219
  let belowTotalW = 0;
 
226
  belowMaxH = Math.max(belowMaxH, ext.height);
227
  }
228
 
229
+ // Shared children (e.g. user below assistants) go centered under the row
230
+ let sharedBelowW = 0;
231
+ let sharedBelowH = 0;
232
+ for (let i = 0; i < sharedBelowIds.length; i++) {
233
+ const ext = computeExtent(sharedBelowIds[i]);
234
+ if (i > 0) sharedBelowW += H_SPACING;
235
+ sharedBelowW += ext.width;
236
+ sharedBelowH = Math.max(sharedBelowH, ext.height);
237
+ }
238
+
239
+ const rowH = belowIds.length > 0 ? nodeH + V_SPACING + belowMaxH : nodeH;
240
+ const blockAW = Math.max(nodeW, belowTotalW, sharedBelowIds.length > 0 ? sharedBelowW : 0);
241
+ const blockAH = sharedBelowIds.length > 0 ? rowH + V_SPACING + sharedBelowH : rowH;
242
 
243
  // Block B β€” side-children and their subtrees
244
  let blockBW = 0;
 
268
  // ── Phase 2: assign positions top-down ──────────────────────────
269
 
270
  const positions = new Map<string, { x: number; y: number }>();
271
+ const rowHeightMap = new Map<string, number>();
272
 
273
  function placeSubtree(nodeId: string, allocX: number, y: number) {
274
  const node = nodeMap.get(nodeId);
 
287
  y
288
  });
289
 
290
+ // Place below-children (assistants), centered within Block A
291
  if (belowIds.length > 0) {
292
  let belowTotalW = 0;
293
  for (let i = 0; i < belowIds.length; i++) {
 
295
  belowTotalW += extents.get(belowIds[i])!.width;
296
  }
297
 
298
+ const rowHeight = Math.max(
299
+ ...belowIds.map((id) => {
300
+ const n = nodeMap.get(id);
301
+ return n ? getMeasuredHeight(n) : DEFAULT_HEIGHT;
302
+ })
303
+ );
304
+ for (const cid of belowIds) {
305
+ if (nodeMap.get(cid)?.type === 'assistant') {
306
+ rowHeightMap.set(cid, rowHeight);
307
+ }
308
+ }
309
+
310
  const childY = y + nodeH + V_SPACING;
311
  let childX = allocX + (blockAWidth - belowTotalW) / 2;
312
 
 
316
  }
317
  }
318
 
319
+ // Place shared below-children (user node) centered below the assistants row
320
+ const sharedBelowIds = sharedBelowOf.get(nodeId) ?? [];
321
+ if (sharedBelowIds.length > 0 && belowIds.length > 0) {
322
+ let belowTotalW = 0;
323
+ for (let i = 0; i < belowIds.length; i++) {
324
+ if (i > 0) belowTotalW += H_SPACING;
325
+ belowTotalW += extents.get(belowIds[i])!.width;
326
+ }
327
+ const rowY = y + nodeH + V_SPACING;
328
+ const rowHeight = Math.max(...belowIds.map((id) => extents.get(id)!.height));
329
+ const sharedY = rowY + rowHeight + V_SPACING;
330
+
331
+ let sharedTotalW = 0;
332
+ for (let i = 0; i < sharedBelowIds.length; i++) {
333
+ if (i > 0) sharedTotalW += H_SPACING;
334
+ sharedTotalW += extents.get(sharedBelowIds[i])!.width;
335
+ }
336
+ const sharedCenterX = allocX + (blockAWidth - sharedTotalW) / 2;
337
+ let sharedX = sharedCenterX;
338
+ for (const sid of sharedBelowIds) {
339
+ placeSubtree(sid, sharedX, sharedY);
340
+ sharedX += extents.get(sid)!.width + H_SPACING;
341
+ }
342
+ }
343
+
344
  // Place side-children to the right of Block A (non-overlapping zone)
345
  let sideX = allocX + blockAWidth + H_SPACING;
346
  for (const sid of sideIds) {
 
384
  positions.set(node.id, { x: 0, y: maxY + DEFAULT_HEIGHT + V_SPACING });
385
  }
386
 
387
+ return nodes.map((n) => {
388
+ const pos = positions.get(n.id) ?? { x: 0, y: 0 };
389
+ const data =
390
+ n.type === 'assistant' ? { ...n.data, rowHeight: rowHeightMap.get(n.id) } : n.data;
391
+ return { ...n, position: pos, data };
392
+ });
393
  }
394
 
395
  // ─── Apply layout to the store ─────────────────────────────────────
 
405
  nodesStore.set(result);
406
  } else {
407
  const posMap = new Map(result.map((n) => [n.id, n.position]));
408
+ const dataMap = new Map(result.map((n) => [n.id, n.data]));
409
  nodesStore.update((prev) =>
410
  prev.map((n) => {
411
  const pos = posMap.get(n.id);
412
+ const data = dataMap.get(n.id);
413
+ const updated = pos ? { ...n, position: pos } : n;
414
+ return data ? { ...updated, data } : updated;
415
  })
416
  );
417
  }
418
 
419
  if (viewState.draggable) {
420
+ doFitView();
 
 
 
 
 
421
  isFirstLayout = false;
422
  }
423
  }
src/lib/helpers/types.ts CHANGED
@@ -13,11 +13,17 @@ export interface ChatModel {
13
  provider: string;
14
  isError?: boolean;
15
  }
 
16
  export interface ChatMessage {
17
  role: 'user' | 'assistant';
18
- content: string;
19
- timestamp?: number;
20
- isHidden?: boolean;
 
 
 
 
 
21
  }
22
 
23
  export interface TokenUsage {
 
13
  provider: string;
14
  isError?: boolean;
15
  }
16
+
17
  export interface ChatMessage {
18
  role: 'user' | 'assistant';
19
+ [modelId: string]:
20
+ | {
21
+ content: string;
22
+ timestamp?: string;
23
+ isHidden?: boolean;
24
+ }
25
+ | 'user'
26
+ | 'assistant';
27
  }
28
 
29
  export interface TokenUsage {
src/routes/+page.svelte CHANGED
@@ -46,8 +46,7 @@
46
  position: { x: 0, y: 0 },
47
  data: {
48
  isFirstNode: true,
49
- canDeleteWelcome: true,
50
- selectedModels: modelsState.models.slice(0, MAX_DEFAULT_MODELS) as ChatModel[]
51
  }
52
  }
53
  ];
 
46
  position: { x: 0, y: 0 },
47
  data: {
48
  isFirstNode: true,
49
+ selectedModels: modelsState.models.slice(3, 4 + MAX_DEFAULT_MODELS) as ChatModel[]
 
50
  }
51
  }
52
  ];