enzostvs HF Staff Cursor commited on
Commit
d3ab1f5
·
1 Parent(s): db66673

feat: add chat UI with markdown rendering and flow actions

Browse files

- Add Assistant, User, and Message chat components
- Add markdown rendering components (code blocks, links, lists, etc.)
- Add flow panel actions (PanelRightActions)
- Update layout, page, and FitViewOnResize components
- Remove old Chat, FollowUp, and Messages components
- Update dependencies

Co-authored-by: Cursor <cursoragent@cursor.com>

package.json CHANGED
@@ -47,6 +47,7 @@
47
  "@xyflow/svelte": "^1.5.0",
48
  "clsx": "^2.1.1",
49
  "elkjs": "^0.11.0",
 
50
  "svelte-markdown": "^0.4.1",
51
  "tailwind-merge": "^3.4.0"
52
  }
 
47
  "@xyflow/svelte": "^1.5.0",
48
  "clsx": "^2.1.1",
49
  "elkjs": "^0.11.0",
50
+ "mode-watcher": "^1.1.0",
51
  "svelte-markdown": "^0.4.1",
52
  "tailwind-merge": "^3.4.0"
53
  }
pnpm-lock.yaml CHANGED
@@ -23,6 +23,9 @@ importers:
23
  elkjs:
24
  specifier: ^0.11.0
25
  version: 0.11.0
 
 
 
26
  svelte-markdown:
27
  specifier: ^0.4.1
28
  version: 0.4.1(svelte@5.50.1)
@@ -1236,6 +1239,11 @@ packages:
1236
  resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
1237
  engines: {node: '>=16 || 14 >=14.17'}
1238
 
 
 
 
 
 
1239
  mri@1.2.0:
1240
  resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
1241
  engines: {node: '>=4'}
@@ -1412,6 +1420,16 @@ packages:
1412
  engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1413
  hasBin: true
1414
 
 
 
 
 
 
 
 
 
 
 
1415
  runed@0.35.1:
1416
  resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
1417
  peerDependencies:
@@ -1488,6 +1506,12 @@ packages:
1488
  peerDependencies:
1489
  svelte: ^5.30.2
1490
 
 
 
 
 
 
 
1491
  svelte@5.50.1:
1492
  resolution: {integrity: sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==}
1493
  engines: {node: '>=18'}
@@ -2606,6 +2630,12 @@ snapshots:
2606
  dependencies:
2607
  brace-expansion: 2.0.2
2608
 
 
 
 
 
 
 
2609
  mri@1.2.0: {}
2610
 
2611
  mrmime@2.0.1: {}
@@ -2730,6 +2760,16 @@ snapshots:
2730
  '@rollup/rollup-win32-x64-msvc': 4.57.1
2731
  fsevents: 2.3.3
2732
 
 
 
 
 
 
 
 
 
 
 
2733
  runed@0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.1)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.1):
2734
  dependencies:
2735
  dequal: 2.0.3
@@ -2809,6 +2849,13 @@ snapshots:
2809
  transitivePeerDependencies:
2810
  - '@sveltejs/kit'
2811
 
 
 
 
 
 
 
 
2812
  svelte@5.50.1:
2813
  dependencies:
2814
  '@jridgewell/remapping': 2.3.5
 
23
  elkjs:
24
  specifier: ^0.11.0
25
  version: 0.11.0
26
+ mode-watcher:
27
+ specifier: ^1.1.0
28
+ version: 1.1.0(svelte@5.50.1)
29
  svelte-markdown:
30
  specifier: ^0.4.1
31
  version: 0.4.1(svelte@5.50.1)
 
1239
  resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
1240
  engines: {node: '>=16 || 14 >=14.17'}
1241
 
1242
+ mode-watcher@1.1.0:
1243
+ resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==}
1244
+ peerDependencies:
1245
+ svelte: ^5.27.0
1246
+
1247
  mri@1.2.0:
1248
  resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
1249
  engines: {node: '>=4'}
 
1420
  engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1421
  hasBin: true
1422
 
1423
+ runed@0.23.4:
1424
+ resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==}
1425
+ peerDependencies:
1426
+ svelte: ^5.7.0
1427
+
1428
+ runed@0.25.0:
1429
+ resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==}
1430
+ peerDependencies:
1431
+ svelte: ^5.7.0
1432
+
1433
  runed@0.35.1:
1434
  resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
1435
  peerDependencies:
 
1506
  peerDependencies:
1507
  svelte: ^5.30.2
1508
 
1509
+ svelte-toolbelt@0.7.1:
1510
+ resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
1511
+ engines: {node: '>=18', pnpm: '>=8.7.0'}
1512
+ peerDependencies:
1513
+ svelte: ^5.0.0
1514
+
1515
  svelte@5.50.1:
1516
  resolution: {integrity: sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==}
1517
  engines: {node: '>=18'}
 
2630
  dependencies:
2631
  brace-expansion: 2.0.2
2632
 
2633
+ mode-watcher@1.1.0(svelte@5.50.1):
2634
+ dependencies:
2635
+ runed: 0.25.0(svelte@5.50.1)
2636
+ svelte: 5.50.1
2637
+ svelte-toolbelt: 0.7.1(svelte@5.50.1)
2638
+
2639
  mri@1.2.0: {}
2640
 
2641
  mrmime@2.0.1: {}
 
2760
  '@rollup/rollup-win32-x64-msvc': 4.57.1
2761
  fsevents: 2.3.3
2762
 
2763
+ runed@0.23.4(svelte@5.50.1):
2764
+ dependencies:
2765
+ esm-env: 1.2.2
2766
+ svelte: 5.50.1
2767
+
2768
+ runed@0.25.0(svelte@5.50.1):
2769
+ dependencies:
2770
+ esm-env: 1.2.2
2771
+ svelte: 5.50.1
2772
+
2773
  runed@0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.1)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.1):
2774
  dependencies:
2775
  dequal: 2.0.3
 
2849
  transitivePeerDependencies:
2850
  - '@sveltejs/kit'
2851
 
2852
+ svelte-toolbelt@0.7.1(svelte@5.50.1):
2853
+ dependencies:
2854
+ clsx: 2.1.1
2855
+ runed: 0.23.4(svelte@5.50.1)
2856
+ style-to-object: 1.0.14
2857
+ svelte: 5.50.1
2858
+
2859
  svelte@5.50.1:
2860
  dependencies:
2861
  '@jridgewell/remapping': 2.3.5
src/lib/components/chat/Assistant.svelte ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Handle, useEdges, useNodes, useNodesData, Position, type NodeProps, type Edge , type Node, useSvelteFlow} from '@xyflow/svelte';
3
+
4
+ import type { ChatModel, ChatMessage } from '$lib/helpers/types';
5
+ import { Button } from '$lib/components/ui/button';
6
+ import ComboBoxModels from '$lib/components/model/ComboBoxModels.svelte';
7
+ import { onMount } from 'svelte';
8
+ import Message from './Message.svelte';
9
+ import Spinner from '$lib/components/loading/Spinner.svelte';
10
+
11
+ let { id }: NodeProps = $props();
12
+
13
+ // svelte-ignore state_referenced_locally
14
+ const nodeData = useNodesData(id)
15
+
16
+ let selectedModels = $derived(nodeData.current?.data.selectedModels as ChatModel[] ?? []);
17
+ let loading = $derived(nodeData.current?.data.loading as boolean ?? false);
18
+ let message = $derived(nodeData.current?.data.content ? { role: 'assistant', content: nodeData.current?.data.content } as ChatMessage : null);
19
+ </script>
20
+
21
+ <article class="bg-white border border-gray-200 shadow-lg/5 p-5 w-[600px] rounded-3xl">
22
+ <div class="nodrag">
23
+ <header class="flex items-center justify-between mb-3">
24
+ <div class="flex items-center gap-1 flex-wrap">
25
+ {#each selectedModels as model}
26
+ <Button variant="outline" size="sm" class="font-normal! shadow-none! relative group">
27
+ <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
28
+ {model.modelName}
29
+ </Button>
30
+ {/each}
31
+ </div>
32
+ </header>
33
+
34
+ {#if loading}
35
+ <div class="flex items-center justify-start gap-1">
36
+ <Spinner className="size-4!" />
37
+ <p class="text-sm text-muted-foreground/70">Thinking...</p>
38
+ </div>
39
+ {/if}
40
+ {#if message}
41
+ <Message {message} />
42
+ {/if}
43
+ </div>
44
+ </article>
45
+ <Handle type="target" position={Position.Top} class="opacity-0"/>
46
+ <Handle type="target" position={Position.Left} class="opacity-0"/>
47
+ <Handle type="target" position={Position.Right} class="opacity-0"/>
48
+ <Handle type="source" position={Position.Bottom} class="opacity-0" />
49
+ <Handle type="source" position={Position.Left} class="opacity-0" />
50
+ <Handle type="source" position={Position.Right} class="opacity-0" />
src/lib/components/chat/Chat.svelte DELETED
@@ -1,220 +0,0 @@
1
- <script lang="ts">
2
- import { Send, X } from '@lucide/svelte';
3
- import { Handle, useEdges, useNodes, useNodesData, Position, type NodeProps, type Edge , type Node, useSvelteFlow} from '@xyflow/svelte';
4
-
5
- import type { ChatModel, ChatMessage } from '$lib/helpers/types';
6
- import { Button } from '$lib/components/ui/button';
7
- import ComboBoxModels from '$lib/components/model/ComboBoxModels.svelte';
8
- import Spinner from '$lib/components/loading/Spinner.svelte';
9
- import { onMount } from 'svelte';
10
- import Messages from './Messages.svelte';
11
-
12
- let { id }: NodeProps = $props();
13
-
14
- // svelte-ignore state_referenced_locally
15
- const nodeData = useNodesData(id)
16
- const { current: nodes, set: setNodes, update: updateNodes } = useNodes();
17
- const { current: edges, set: setEdges, update: updateEdges } = useEdges();
18
- const { fitView, updateNodeData } = useSvelteFlow();
19
-
20
- let selectedModels = $state.raw<ChatModel[]>(nodeData.current?.data.selectedModels as ChatModel[] ?? []);
21
- let prompt = $state.raw<string>(nodeData.current?.data.prompt as string ?? '');
22
- let provider = $state.raw<string>('auto');
23
- let loading = $state.raw<boolean>(false);
24
- let aiCallDone = $state.raw<boolean>(false);
25
-
26
- let messages = $state.raw<ChatMessage[]>([]);
27
-
28
- function addModel(model: ChatModel) {
29
- if (!selectedModels.some((m) => m.id === model.id)) {
30
- selectedModels = [...selectedModels, model];
31
- }
32
- }
33
- function removeModel(model: ChatModel) {
34
- selectedModels = selectedModels.filter((m) => m.id !== model.id);
35
- }
36
-
37
- function handleTriggerAction() {
38
- const newNodes: Node[] = [];
39
- if (selectedModels.length > 1) {
40
- updateNodeData(id, {
41
- selectedModels: selectedModels.slice(1),
42
- }, { replace: true });
43
- selectedModels.slice(1).forEach((m) => {
44
- const newNodeId = `chat-${crypto.randomUUID()}`;
45
- const position = {
46
- y: 0,
47
- x: 630,
48
- }
49
- newNodes.push({
50
- id: newNodeId,
51
- type: 'chat',
52
- position,
53
- data: {
54
- prompt,
55
- selectedModels: [m],
56
- },
57
- });
58
- });
59
- selectedModels = selectedModels.slice(0, 1);
60
- };
61
- updateNodes((currentNodes) => [...currentNodes, ...newNodes]);
62
- fitView({
63
- maxZoom: 1,
64
- minZoom: 1,
65
- interpolate: 'smooth',
66
- duration: 500,
67
- })
68
- handleTriggerAiCall();
69
- }
70
-
71
- async function handleTriggerAiCall() {
72
- const start = Date.now();
73
- try {
74
- messages = [...messages, { role: 'user', content: prompt }];
75
- loading = true;
76
- const response = await fetch('/api', {
77
- method: 'POST',
78
- body: JSON.stringify({
79
- model: selectedModels[0].id,
80
- prompt,
81
- }),
82
- });
83
- if (!response.ok) throw new Error(response.statusText);
84
- if (!response.body) throw new Error('No response body');
85
-
86
- messages = [...messages, { role: 'assistant', content: '' }];
87
- const assistantIndex = messages.length - 1;
88
- let content = '';
89
-
90
- const reader = response.body.getReader();
91
- const decoder = new TextDecoder();
92
-
93
- while (true) {
94
- const { done, value } = await reader.read();
95
- if (done) {
96
- aiCallDone = true;
97
- const newNodeId = `message-${crypto.randomUUID()}`;
98
- const newNode: Node = {
99
- id: newNodeId,
100
- type: 'followUp',
101
- position: {
102
- x: 0,
103
- y: 0,
104
- },
105
- data: {
106
- // map messages to add isHidden: true
107
- messages,
108
- selectedModels,
109
- },
110
- }
111
- const newEdge: Edge = {
112
- id: `edge-${crypto.randomUUID()}`,
113
- source: id,
114
- target: newNodeId,
115
- }
116
- updateNodes((currentNodes) => [...currentNodes, newNode]);
117
- updateEdges((currentEdges) => [...currentEdges, newEdge]);
118
- break;
119
- }
120
-
121
- content += decoder.decode(value, { stream: true });
122
- messages = messages.map((m, i) =>
123
- i === assistantIndex ? { ...m, content } : m
124
- );
125
- }
126
-
127
- messages = messages.map((m, i) =>
128
- i === assistantIndex ? { ...m, content, timestamp: Date.now() - start } : m
129
- );
130
- } catch (error) {
131
- console.error(error);
132
- } finally {
133
- loading = false;
134
- }
135
- }
136
-
137
- onMount(() => {
138
- if (nodeData.current?.data.prompt) {
139
- handleTriggerAiCall();
140
- }
141
- });
142
- </script>
143
-
144
- <article class="bg-white border border-gray-200 shadow-lg/5 p-5 w-[600px] rounded-3xl">
145
- <div class="nodrag">
146
- <header class="flex items-center justify-between mb-3">
147
- <div class="flex items-center gap-1 flex-wrap">
148
- {#each selectedModels as model}
149
- <Button variant="outline" size="sm" class="font-normal! shadow-none! relative group">
150
- <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
151
- {model.modelName}
152
- <Button variant="default" size="icon-3xs" class="!shadow-none! absolute -top-1 -right-1 rounded-full! opacity-0 group-hover:opacity-100 transition-opacity duration-300" onclick={() => removeModel(model)}>
153
- <X class="size-3" />
154
- </Button>
155
- </Button>
156
- {/each}
157
- {#if selectedModels.length < 3 && !loading && !aiCallDone}
158
- <ComboBoxModels
159
- onSelect={addModel}
160
- excludeIds={selectedModels.map((m) => m.id)}
161
- />
162
- {/if}
163
- </div>
164
- </header>
165
- <Messages {messages} />
166
- {#if !aiCallDone}
167
- <footer class="flex transition-all duration-300 {!loading ? 'flex-col items-end' : 'items-start mt-4 gap-2'}">
168
- {#if loading}
169
- <input
170
- name="message"
171
- id="message"
172
- placeholder="Ask me anything..."
173
- disabled={loading}
174
- class="w-full resize-none bg-transparent border rounded-lg py-1.5 px-3 outline-none text-sm text-muted-foreground"
175
- bind:value={prompt}
176
- onkeydown={(e: KeyboardEvent) => {
177
- if (e.key === 'Enter' && !e.shiftKey) {
178
- e.preventDefault();
179
- prompt = prompt.trim();
180
- if (prompt) {
181
- handleTriggerAction();
182
- }
183
- }
184
- }} />
185
- {:else}
186
- <textarea
187
- name="message"
188
- id="message"
189
- placeholder="Ask me anything..."
190
- disabled={loading}
191
- class="w-full resize-none bg-transparent border-none outline-none text-base text-accent-foreground"
192
- bind:value={prompt}
193
- onkeydown={(e: KeyboardEvent) => {
194
- if (e.key === 'Enter' && !e.shiftKey) {
195
- e.preventDefault();
196
- prompt = prompt.trim();
197
- if (prompt) {
198
- handleTriggerAction();
199
- }
200
- }
201
- }}
202
- ></textarea>
203
- {/if}
204
- <Button variant="outline" size="icon-sm" class="" disabled={!selectedModels.length || !prompt || loading} onclick={handleTriggerAction}>
205
- {#if loading}
206
- <Spinner className="size-5"/>
207
- {:else}
208
- <Send />
209
- {/if}
210
- </Button>
211
- </footer>
212
- {/if}
213
- </div>
214
- </article>
215
- <Handle type="target" position={Position.Top} class="opacity-0"/>
216
- <Handle type="target" position={Position.Left} class="opacity-0"/>
217
- <Handle type="target" position={Position.Right} class="opacity-0"/>
218
- <Handle type="source" position={Position.Bottom} class="opacity-0" />
219
- <Handle type="source" position={Position.Left} class="opacity-0" />
220
- <Handle type="source" position={Position.Right} class="opacity-0" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/chat/Message.svelte ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import SvelteMarkdown from 'svelte-markdown';
3
+
4
+ import type { ChatMessage } from '$lib/helpers/types';
5
+ import Paragraph from './markdown/Paragraph.svelte';
6
+ import Heading from './markdown/Heading.svelte';
7
+ import Code from './markdown/Code.svelte';
8
+ import Codespan from './markdown/Codespan.svelte';
9
+ import Blockquote from './markdown/Blockquote.svelte';
10
+ import List from './markdown/List.svelte';
11
+ import ListItem from './markdown/ListItem.svelte';
12
+ import Link from './markdown/Link.svelte';
13
+ import Hr from './markdown/Hr.svelte';
14
+
15
+ let { message }: { message: ChatMessage } = $props();
16
+
17
+ const renderers = {
18
+ paragraph: Paragraph,
19
+ heading: Heading,
20
+ code: Code,
21
+ codespan: Codespan,
22
+ blockquote: Blockquote,
23
+ list: List,
24
+ listitem: ListItem,
25
+ link: Link,
26
+ hr: Hr,
27
+ };
28
+ </script>
29
+
30
+ <main class="cursor-auto select-auto p-1">
31
+ {#if message?.role === 'user'}
32
+ <p class="text-lg text-accent-foreground leading-relaxed">
33
+ {message.content}
34
+ </p>
35
+ {:else}
36
+ <SvelteMarkdown source={message.content} renderers={renderers as any} />
37
+ {/if}
38
+ </main>
39
+ <!-- {#if message.timestamp}
40
+ <p class="text-[10px] text-muted-foreground bg-muted px-2 py-1 rounded-md font-mono">
41
+ {message.timestamp / 1000}s
42
+ </p>
43
+ {/if} -->
src/lib/components/chat/Messages.svelte DELETED
@@ -1,26 +0,0 @@
1
- <script lang="ts">
2
- import SvelteMarkdown from 'svelte-markdown';
3
-
4
- import type { ChatMessage } from '$lib/helpers/types';
5
-
6
- let { messages }: { messages: ChatMessage[] } = $props();
7
- </script>
8
-
9
- <main class="p-1 space-y-2 cursor-auto select-auto">
10
- {#each messages as message}
11
- <div class="flex items-center justify-end {message.role === 'user' ? 'justify-end' : 'justify-start'}">
12
- <div class="flex flex-col justify-center items-start gap-1.5">
13
- {#if message.role === 'user'}
14
- <p class="text-sm text-muted-foreground bg-accent-foreground/5 px-2 py-1 rounded-md">{message.content}</p>
15
- {:else}
16
- <SvelteMarkdown source={message.content} />
17
- {/if}
18
- {#if message.timestamp}
19
- <p class="text-[10px] text-muted-foreground bg-muted px-2 py-1 rounded-md font-mono">
20
- {message.timestamp / 1000}s
21
- </p>
22
- {/if}
23
- </div>
24
- </div>
25
- {/each}
26
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/chat/{FollowUp.svelte → User.svelte} RENAMED
@@ -6,24 +6,21 @@
6
  import { Button } from '$lib/components/ui/button';
7
  import ComboBoxModels from '$lib/components/model/ComboBoxModels.svelte';
8
  import Spinner from '$lib/components/loading/Spinner.svelte';
9
- import { onMount } from 'svelte';
10
- import Messages from './Messages.svelte';
11
 
12
  let { id }: NodeProps = $props();
13
 
14
  // svelte-ignore state_referenced_locally
15
  const nodeData = useNodesData(id)
16
- const { current: nodes, set: setNodes, update: updateNodes } = useNodes();
17
- const { current: edges, set: setEdges, update: updateEdges } = useEdges();
18
  const { fitView, updateNodeData } = useSvelteFlow();
19
-
20
  let selectedModels = $state.raw<ChatModel[]>(nodeData.current?.data.selectedModels as ChatModel[] ?? []);
21
  let prompt = $state.raw<string>(nodeData.current?.data.prompt as string ?? '');
22
- let provider = $state.raw<string>('auto');
23
  let loading = $state.raw<boolean>(false);
24
  let aiCallDone = $state.raw<boolean>(false);
25
-
26
- let messages = $state.raw<ChatMessage[]>([]);
27
 
28
  function addModel(model: ChatModel) {
29
  if (!selectedModels.some((m) => m.id === model.id)) {
@@ -36,109 +33,105 @@
36
 
37
  function handleTriggerAction() {
38
  const newNodes: Node[] = [];
39
- if (selectedModels.length > 1) {
40
- updateNodeData(id, {
41
- selectedModels: selectedModels.slice(1),
42
- }, { replace: true });
43
- selectedModels.slice(1).forEach((m) => {
44
- const newNodeId = `chat-${crypto.randomUUID()}`;
45
- const position = {
 
 
 
 
46
  y: 0,
47
- x: 630,
 
 
 
 
 
48
  }
49
- newNodes.push({
50
- id: newNodeId,
51
- type: 'chat',
52
- position,
53
- data: {
54
- prompt,
55
- selectedModels: [m],
56
- },
57
- });
58
- });
59
- selectedModels = selectedModels.slice(0, 1);
60
- };
61
  updateNodes((currentNodes) => [...currentNodes, ...newNodes]);
 
62
  fitView({
63
  maxZoom: 1,
64
  minZoom: 1,
65
  interpolate: 'smooth',
66
  duration: 500,
67
  })
68
- handleTriggerAiCall();
69
  }
70
 
71
- async function handleTriggerAiCall() {
72
  const start = Date.now();
73
- try {
74
- messages = [...messages, { role: 'user', content: prompt }];
75
- loading = true;
76
- const response = await fetch('/api', {
77
- method: 'POST',
78
- body: JSON.stringify({
79
- model: selectedModels[0].id,
80
- prompt,
81
- }),
82
- });
83
- if (!response.ok) throw new Error(response.statusText);
84
- if (!response.body) throw new Error('No response body');
 
85
 
86
- messages = [...messages, { role: 'assistant', content: '' }];
87
- const assistantIndex = messages.length - 1;
88
- let content = '';
89
 
90
- const reader = response.body.getReader();
91
- const decoder = new TextDecoder();
92
 
93
- while (true) {
94
- const { done, value } = await reader.read();
95
- if (done) {
96
- aiCallDone = true;
97
- console.log("AI CALL DONE")
98
- const newNodeId = `message-${crypto.randomUUID()}`;
99
- const newNode: Node = {
100
- id: newNodeId,
101
- type: 'follow-up',
102
- position: {
103
- x: 0,
104
- y: 0,
105
- },
106
- data: {
107
- messages,
108
- selectedModels,
109
- },
110
- }
111
- const newEdge: Edge = {
112
- id: `edge-${crypto.randomUUID()}`,
113
- source: id,
114
- target: newNodeId,
 
 
 
115
  }
116
- updateNodes((currentNodes) => [...currentNodes, newNode]);
117
- updateEdges((currentEdges) => [...currentEdges, newEdge]);
118
- break;
119
- }
120
 
121
- content += decoder.decode(value, { stream: true });
122
- messages = messages.map((m, i) =>
123
- i === assistantIndex ? { ...m, content } : m
124
- );
 
 
 
125
  }
126
-
127
- messages = messages.map((m, i) =>
128
- i === assistantIndex ? { ...m, content, timestamp: Date.now() - start } : m
129
- );
130
- } catch (error) {
131
- console.error(error);
132
- } finally {
133
- loading = false;
134
- }
135
  }
136
 
137
- onMount(() => {
138
- if (nodeData.current?.data.prompt) {
139
- handleTriggerAiCall();
140
- }
141
- });
142
  </script>
143
 
144
  <article class="bg-white border border-gray-200 shadow-lg/5 p-5 w-[600px] rounded-3xl">
@@ -149,12 +142,14 @@
149
  <Button variant="outline" size="sm" class="font-normal! shadow-none! relative group">
150
  <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
151
  {model.modelName}
152
- <Button variant="default" size="icon-3xs" class="!shadow-none! absolute -top-1 -right-1 rounded-full! opacity-0 group-hover:opacity-100 transition-opacity duration-300" onclick={() => removeModel(model)}>
153
- <X class="size-3" />
154
- </Button>
 
 
155
  </Button>
156
  {/each}
157
- {#if selectedModels.length < 3 && !loading && !aiCallDone}
158
  <ComboBoxModels
159
  onSelect={addModel}
160
  excludeIds={selectedModels.map((m) => m.id)}
@@ -162,18 +157,18 @@
162
  {/if}
163
  </div>
164
  </header>
165
- <Messages {messages} />
166
- {#if !aiCallDone}
167
- <footer class="flex transition-all duration-300 {!loading ? 'flex-col items-end' : 'items-start mt-4 gap-2'}">
168
- {#if loading}
169
- <input
170
- name="message"
171
- id="message"
172
- placeholder="Ask me anything..."
173
- disabled={loading}
174
- class="w-full resize-none bg-transparent border rounded-lg py-1.5 px-3 outline-none text-sm text-muted-foreground"
175
- bind:value={prompt}
176
- onkeydown={(e: KeyboardEvent) => {
177
  if (e.key === 'Enter' && !e.shiftKey) {
178
  e.preventDefault();
179
  prompt = prompt.trim();
@@ -181,26 +176,8 @@
181
  handleTriggerAction();
182
  }
183
  }
184
- }} />
185
- {:else}
186
- <textarea
187
- name="message"
188
- id="message"
189
- placeholder="Ask me anything..."
190
- disabled={loading}
191
- class="w-full resize-none bg-transparent border-none outline-none text-base text-accent-foreground"
192
- bind:value={prompt}
193
- onkeydown={(e: KeyboardEvent) => {
194
- if (e.key === 'Enter' && !e.shiftKey) {
195
- e.preventDefault();
196
- prompt = prompt.trim();
197
- if (prompt) {
198
- handleTriggerAction();
199
- }
200
- }
201
- }}
202
- ></textarea>
203
- {/if}
204
  <Button variant="outline" size="icon-sm" class="" disabled={!selectedModels.length || !prompt || loading} onclick={handleTriggerAction}>
205
  {#if loading}
206
  <Spinner className="size-5"/>
 
6
  import { Button } from '$lib/components/ui/button';
7
  import ComboBoxModels from '$lib/components/model/ComboBoxModels.svelte';
8
  import Spinner from '$lib/components/loading/Spinner.svelte';
9
+ import Message from './Message.svelte';
 
10
 
11
  let { id }: NodeProps = $props();
12
 
13
  // svelte-ignore state_referenced_locally
14
  const nodeData = useNodesData(id)
15
+ const { update: updateNodes } = useNodes();
16
+ const { update: updateEdges } = useEdges();
17
  const { fitView, updateNodeData } = useSvelteFlow();
18
+
19
  let selectedModels = $state.raw<ChatModel[]>(nodeData.current?.data.selectedModels as ChatModel[] ?? []);
20
  let prompt = $state.raw<string>(nodeData.current?.data.prompt as string ?? '');
 
21
  let loading = $state.raw<boolean>(false);
22
  let aiCallDone = $state.raw<boolean>(false);
23
+ let messages = $state.raw<ChatMessage[]>(nodeData.current?.data?.messages as ChatMessage[] ?? []);
 
24
 
25
  function addModel(model: ChatModel) {
26
  if (!selectedModels.some((m) => m.id === model.id)) {
 
33
 
34
  function handleTriggerAction() {
35
  const newNodes: Node[] = [];
36
+ const newEdges: Edge[] = [];
37
+ messages = [...messages, { role: 'user', content: prompt }];
38
+ updateNodeData(id, { messages }, { replace: true });
39
+
40
+ selectedModels.forEach((m) => {
41
+ const newNodeId = `assistant-${crypto.randomUUID()}`;
42
+ const newNode: Node = {
43
+ id: newNodeId,
44
+ type: 'assistant',
45
+ position: {
46
+ x: 0,
47
  y: 0,
48
+ },
49
+ data: {
50
+ role: "assistant",
51
+ selectedModels: [m],
52
+ content: "",
53
+ loading: true,
54
  }
55
+ }
56
+ const newEdge: Edge = {
57
+ id: `edge-${crypto.randomUUID()}`,
58
+ source: id,
59
+ target: newNodeId,
60
+ }
61
+ newNodes.push(newNode);
62
+ newEdges.push(newEdge);
63
+ })
 
 
 
64
  updateNodes((currentNodes) => [...currentNodes, ...newNodes]);
65
+ updateEdges((currentEdges) => [...currentEdges, ...newEdges]);
66
  fitView({
67
  maxZoom: 1,
68
  minZoom: 1,
69
  interpolate: 'smooth',
70
  duration: 500,
71
  })
72
+ handleTriggerAiCall(newNodes);
73
  }
74
 
75
+ async function handleTriggerAiCall(newNodes: Node[]) {
76
  const start = Date.now();
77
+ newNodes.forEach(async (node) => {
78
+ const model = (node?.data?.selectedModels as ChatModel[])?.length > 0 ? (node?.data?.selectedModels as ChatModel[])[0] : null;
79
+ if (!model) return;
80
+ try {
81
+ const response = await fetch('/api', {
82
+ method: 'POST',
83
+ body: JSON.stringify({
84
+ model: model.id,
85
+ prompt,
86
+ }),
87
+ });
88
+ if (!response.ok) throw new Error(response.statusText);
89
+ if (!response.body) throw new Error('No response body');
90
 
91
+ let content = '';
 
 
92
 
93
+ const reader = response.body.getReader();
94
+ const decoder = new TextDecoder();
95
 
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done) {
99
+ const newNodeId = `user-${crypto.randomUUID()}`;
100
+ const newNode: Node = {
101
+ id: newNodeId,
102
+ type: 'user',
103
+ position: {
104
+ x: 0,
105
+ y: 0,
106
+ },
107
+ data: {
108
+ role: 'user',
109
+ messages: [...messages, { role: 'assistant', content }],
110
+ selectedModels: [model],
111
+ },
112
+ }
113
+ const newEdge: Edge = {
114
+ id: `edge-${crypto.randomUUID()}`,
115
+ source: node.id,
116
+ target: newNodeId,
117
+ }
118
+ updateNodes((currentNodes) => [...currentNodes, newNode]);
119
+ updateEdges((currentEdges) => [...currentEdges, newEdge]);
120
+ break;
121
  }
 
 
 
 
122
 
123
+ content += decoder.decode(value, { stream: true });
124
+ updateNodeData(node.id, { ...node.data,content, loading: false }, { replace: true });
125
+ }
126
+ } catch (error) {
127
+ console.error(error);
128
+ } finally {
129
+ loading = false;
130
  }
131
+ });
 
 
 
 
 
 
 
 
132
  }
133
 
134
+ let lastMessage = $derived(messages?.length > 0 && messages[messages.length - 1].role === 'user' ? messages[messages.length - 1] : null);
 
 
 
 
135
  </script>
136
 
137
  <article class="bg-white border border-gray-200 shadow-lg/5 p-5 w-[600px] rounded-3xl">
 
142
  <Button variant="outline" size="sm" class="font-normal! shadow-none! relative group">
143
  <img src={model.avatarUrl} alt={model.modelName} class="size-3.5 rounded-full" />
144
  {model.modelName}
145
+ {#if !lastMessage}
146
+ <Button variant="default" size="icon-3xs" class="!shadow-none! absolute -top-1 -right-1 rounded-full! opacity-0 group-hover:opacity-100 transition-opacity duration-300" onclick={() => removeModel(model)}>
147
+ <X class="size-3" />
148
+ </Button>
149
+ {/if}
150
  </Button>
151
  {/each}
152
+ {#if selectedModels.length < 3 && !loading && !lastMessage}
153
  <ComboBoxModels
154
  onSelect={addModel}
155
  excludeIds={selectedModels.map((m) => m.id)}
 
157
  {/if}
158
  </div>
159
  </header>
160
+ {#if lastMessage}
161
+ <Message message={lastMessage} />
162
+ {:else}
163
+ <footer class="flex transition-all duration-300 flex-col items-end">
164
+ <textarea
165
+ name="message"
166
+ id="message"
167
+ placeholder="Ask me anything..."
168
+ disabled={loading}
169
+ class="w-full resize-none bg-transparent border-none outline-none text-base text-accent-foreground"
170
+ bind:value={prompt}
171
+ onkeydown={(e: KeyboardEvent) => {
172
  if (e.key === 'Enter' && !e.shiftKey) {
173
  e.preventDefault();
174
  prompt = prompt.trim();
 
176
  handleTriggerAction();
177
  }
178
  }
179
+ }}
180
+ ></textarea>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  <Button variant="outline" size="icon-sm" class="" disabled={!selectedModels.length || !prompt || loading} onclick={handleTriggerAction}>
182
  {#if loading}
183
  <Spinner className="size-5"/>
src/lib/components/chat/markdown/Blockquote.svelte ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { children }: { children?: Snippet } = $props();
4
+ </script>
5
+
6
+ <blockquote class="border-l-2 border-muted-foreground/30 pl-3 my-3 text-muted-foreground italic">
7
+ {@render children?.()}
8
+ </blockquote>
src/lib/components/chat/markdown/Code.svelte ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ let { lang, text }: { lang?: string; text: string } = $props();
3
+ </script>
4
+
5
+ <div class="relative my-3 rounded-lg overflow-hidden border border-border/60 bg-muted/50">
6
+ {#if lang}
7
+ <div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border/60">
8
+ <span class="text-[11px] font-mono text-muted-foreground">{lang}</span>
9
+ </div>
10
+ {/if}
11
+ <pre class="overflow-x-auto p-3"><code class="text-[13px] leading-relaxed font-mono text-foreground/90">{text}</code></pre>
12
+ </div>
src/lib/components/chat/markdown/Codespan.svelte ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ let { raw }: { raw: string } = $props();
3
+ </script>
4
+
5
+ <code class="px-1.5 py-0.5 rounded-md bg-muted text-[13px] font-mono text-foreground/85 border border-border/40">{raw.replace(/`/g, '')}</code>
src/lib/components/chat/markdown/Heading.svelte ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { depth, children }: { depth: number; raw?: string; text?: string; children?: Snippet } = $props();
4
+ </script>
5
+
6
+ {#if depth === 1}
7
+ <h1 class="text-xl font-semibold text-foreground mt-4 mb-2 first:mt-0">{@render children?.()}</h1>
8
+ {:else if depth === 2}
9
+ <h2 class="text-lg font-semibold text-foreground mt-3.5 mb-2 first:mt-0">{@render children?.()}</h2>
10
+ {:else if depth === 3}
11
+ <h3 class="text-base font-semibold text-foreground mt-3 mb-1.5 first:mt-0">{@render children?.()}</h3>
12
+ {:else}
13
+ <h4 class="text-sm font-semibold text-foreground mt-2.5 mb-1 first:mt-0">{@render children?.()}</h4>
14
+ {/if}
src/lib/components/chat/markdown/Hr.svelte ADDED
@@ -0,0 +1 @@
 
 
1
+ <hr class="my-4 border-t border-border/60" />
src/lib/components/chat/markdown/Link.svelte ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { href = '', title, children }: { href?: string; title?: string; children?: Snippet } = $props();
4
+ </script>
5
+
6
+ <a {href} {title} target="_blank" rel="noopener noreferrer" class="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary transition-colors">
7
+ {@render children?.()}
8
+ </a>
src/lib/components/chat/markdown/List.svelte ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { ordered, start, children }: { ordered: boolean; start?: number; children?: Snippet } = $props();
4
+ </script>
5
+
6
+ {#if ordered}
7
+ <ol class="list-decimal list-outside pl-5 my-2 space-y-1 text-sm text-foreground/90 marker:text-muted-foreground" {start}>
8
+ {@render children?.()}
9
+ </ol>
10
+ {:else}
11
+ <ul class="list-disc list-outside pl-5 my-2 space-y-1 text-sm text-foreground/90 marker:text-muted-foreground">
12
+ {@render children?.()}
13
+ </ul>
14
+ {/if}
src/lib/components/chat/markdown/ListItem.svelte ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { children }: { children?: Snippet } = $props();
4
+ </script>
5
+
6
+ <li class="leading-relaxed pl-0.5">{@render children?.()}</li>
src/lib/components/chat/markdown/Paragraph.svelte ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { children }: { children?: Snippet } = $props();
4
+ </script>
5
+
6
+ <p class="text-sm leading-relaxed text-foreground/90 mb-3 last:mb-0">{@render children?.()}</p>
src/lib/components/flow/FitViewOnResize.svelte CHANGED
@@ -9,8 +9,8 @@
9
  // Fallback dimensions (used before nodes are measured by xyflow)
10
  const DEFAULT_WIDTH = 600;
11
  const DEFAULT_HEIGHT = 200;
12
- const H_SPACING = 100;
13
- const V_SPACING = 80;
14
 
15
  const { fitView } = useSvelteFlow();
16
  const nodesStore = useNodes();
@@ -18,9 +18,6 @@
18
 
19
  let lastLayoutKey = $state<string | null>(null);
20
 
21
- const isChat = (n: Node) => n.type === 'chat';
22
- const isFollowUp = (n: Node) => n.type === 'followUp' || n.type === 'follow-up';
23
-
24
  /** Get the actual measured height of a node, or fallback */
25
  function getMeasuredHeight(node: Node): number {
26
  return node.measured?.height ?? DEFAULT_HEIGHT;
@@ -62,7 +59,7 @@
62
  function handleWindowResize() {
63
  fitView({
64
  maxZoom: 1,
65
- minZoom: 0.5,
66
  interpolate: 'smooth',
67
  duration: 500,
68
  });
@@ -77,88 +74,96 @@
77
  });
78
 
79
  /**
80
- * Custom layout:
81
- * - All chat nodes on the same horizontal row
82
- * - Follow-up nodes below their source, using real measured heights
83
- * - Multiple follow-ups for one source: stacked vertically
 
84
  */
85
  function computeLayout(nodes: Node[], edges: Edge[]): Node[] {
86
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
87
- const chatNodes = nodes.filter(isChat).sort((a, b) => a.id.localeCompare(b.id));
88
- const followUpNodes = nodes.filter(isFollowUp);
89
 
90
- // Build source->targets mapping from edges
91
- const sourceByTarget = new Map<string, string>();
 
92
  for (const e of edges) {
93
- sourceByTarget.set(e.target, e.source);
 
 
 
94
  }
95
 
96
- const followUpsBySource = new Map<string, string[]>();
97
- for (const node of followUpNodes) {
98
- const sourceId = sourceByTarget.get(node.id);
99
- if (sourceId) {
100
- const list = followUpsBySource.get(sourceId) ?? [];
101
- list.push(node.id);
102
- followUpsBySource.set(sourceId, list);
103
- }
104
- }
105
 
106
- const positions = new Map<string, { x: number; y: number }>();
 
107
 
108
- // Chat nodes: same horizontal row, spaced by max width + H_SPACING
109
- chatNodes.forEach((node, i) => {
110
- positions.set(node.id, {
111
- x: i * (getMeasuredWidth(node) + H_SPACING),
112
- y: 0,
113
- });
114
- });
115
 
116
- // Place follow-ups recursively, depth-first
117
- const processed = new Set<string>();
 
 
118
 
119
- const placeFollowUps = (sourceId: string) => {
120
- const targets = followUpsBySource.get(sourceId);
121
- if (!targets?.length) return;
 
122
 
123
- const sourcePos = positions.get(sourceId);
124
- if (!sourcePos) return;
 
 
125
 
126
- const sourceNode = nodeMap.get(sourceId);
127
- const sourceHeight = sourceNode ? getMeasuredHeight(sourceNode) : DEFAULT_HEIGHT;
 
128
 
129
- let nextY = sourcePos.y + sourceHeight + V_SPACING;
 
130
 
131
- for (const targetId of targets) {
132
- if (processed.has(targetId)) continue;
 
 
 
133
 
134
- positions.set(targetId, { x: sourcePos.x, y: nextY });
135
- processed.add(targetId);
 
136
 
137
- const targetNode = nodeMap.get(targetId);
138
- const targetHeight = targetNode ? getMeasuredHeight(targetNode) : DEFAULT_HEIGHT;
139
 
140
- // Recurse: place any follow-ups of this follow-up
141
- placeFollowUps(targetId);
142
 
143
- // Advance Y past this target and all its descendants
144
- nextY = getSubtreeBottom(targetId, nodeMap, positions, followUpsBySource) + V_SPACING;
 
 
145
  }
146
- };
147
 
148
- // Start from chat nodes
149
- for (const chat of chatNodes) {
150
- placeFollowUps(chat.id);
 
 
 
151
  }
152
 
153
- // Orphan follow-ups (no source found)
154
- for (const node of followUpNodes) {
155
  if (positions.has(node.id)) continue;
156
  const allY = Array.from(positions.values()).map((p) => p.y);
157
  const maxY = allY.length > 0 ? Math.max(...allY) : 0;
158
- positions.set(node.id, {
159
- x: 0,
160
- y: maxY + DEFAULT_HEIGHT + V_SPACING,
161
- });
162
  }
163
 
164
  return nodes.map((node) => ({
@@ -167,28 +172,6 @@
167
  }));
168
  }
169
 
170
- /** Get the bottom Y edge of a node and all its follow-up descendants */
171
- function getSubtreeBottom(
172
- nodeId: string,
173
- nodeMap: Map<string, Node>,
174
- positions: Map<string, { x: number; y: number }>,
175
- followUpsBySource: Map<string, string[]>,
176
- ): number {
177
- const pos = positions.get(nodeId);
178
- const node = nodeMap.get(nodeId);
179
- if (!pos) return 0;
180
- const height = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT;
181
- let bottom = pos.y + height;
182
-
183
- const children = followUpsBySource.get(nodeId);
184
- if (children) {
185
- for (const childId of children) {
186
- bottom = Math.max(bottom, getSubtreeBottom(childId, nodeMap, positions, followUpsBySource));
187
- }
188
- }
189
- return bottom;
190
- }
191
-
192
  function runLayout(nodes: Node[], edges: Edge[]) {
193
  const result = computeLayout(nodes, edges);
194
 
@@ -207,7 +190,7 @@
207
 
208
  fitView({
209
  maxZoom: 1,
210
- minZoom: 0.5,
211
  interpolate: 'smooth',
212
  duration: 250,
213
  });
 
9
  // Fallback dimensions (used before nodes are measured by xyflow)
10
  const DEFAULT_WIDTH = 600;
11
  const DEFAULT_HEIGHT = 200;
12
+ const H_SPACING = 40;
13
+ const V_SPACING = 40;
14
 
15
  const { fitView } = useSvelteFlow();
16
  const nodesStore = useNodes();
 
18
 
19
  let lastLayoutKey = $state<string | null>(null);
20
 
 
 
 
21
  /** Get the actual measured height of a node, or fallback */
22
  function getMeasuredHeight(node: Node): number {
23
  return node.measured?.height ?? DEFAULT_HEIGHT;
 
59
  function handleWindowResize() {
60
  fitView({
61
  maxZoom: 1,
62
+ minZoom: 0.8,
63
  interpolate: 'smooth',
64
  duration: 500,
65
  });
 
74
  });
75
 
76
  /**
77
+ * Custom tree layout:
78
+ * - Root nodes (no incoming edge) on the same horizontal row
79
+ * - Siblings (children sharing the same source) on the same horizontal row
80
+ * - Parent centered above its children
81
+ * - Works recursively for any depth (user -> assistant -> user -> ...)
82
  */
83
  function computeLayout(nodes: Node[], edges: Edge[]): Node[] {
84
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
 
 
85
 
86
+ // Build parent->children mapping from edges
87
+ const childrenBySource = new Map<string, string[]>();
88
+ const hasParent = new Set<string>();
89
  for (const e of edges) {
90
+ hasParent.add(e.target);
91
+ const list = childrenBySource.get(e.source) ?? [];
92
+ list.push(e.target);
93
+ childrenBySource.set(e.source, list);
94
  }
95
 
96
+ // Root nodes: no incoming edge
97
+ const rootNodes = nodes.filter((n) => !hasParent.has(n.id));
 
 
 
 
 
 
 
98
 
99
+ // 1) Compute the horizontal space each subtree needs
100
+ const subtreeWidths = new Map<string, number>();
101
 
102
+ function computeSubtreeWidth(nodeId: string): number {
103
+ if (subtreeWidths.has(nodeId)) return subtreeWidths.get(nodeId)!;
104
+ const node = nodeMap.get(nodeId);
105
+ const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
106
+ const children = childrenBySource.get(nodeId) ?? [];
 
 
107
 
108
+ if (children.length === 0) {
109
+ subtreeWidths.set(nodeId, nodeWidth);
110
+ return nodeWidth;
111
+ }
112
 
113
+ const childrenTotalWidth = children.reduce(
114
+ (sum, childId) => sum + computeSubtreeWidth(childId),
115
+ 0,
116
+ ) + (children.length - 1) * H_SPACING;
117
 
118
+ const width = Math.max(nodeWidth, childrenTotalWidth);
119
+ subtreeWidths.set(nodeId, width);
120
+ return width;
121
+ }
122
 
123
+ for (const root of rootNodes) {
124
+ computeSubtreeWidth(root.id);
125
+ }
126
 
127
+ // 2) Place nodes top-down: each node is centered in its allocated subtree width
128
+ const positions = new Map<string, { x: number; y: number }>();
129
 
130
+ function placeNode(nodeId: string, allocatedX: number, y: number) {
131
+ const node = nodeMap.get(nodeId);
132
+ const nodeWidth = node ? getMeasuredWidth(node) : DEFAULT_WIDTH;
133
+ const nodeHeight = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT;
134
+ const stWidth = subtreeWidths.get(nodeId) ?? nodeWidth;
135
 
136
+ // Center node within its allocated subtree space
137
+ const x = allocatedX + (stWidth - nodeWidth) / 2;
138
+ positions.set(nodeId, { x, y });
139
 
140
+ const children = childrenBySource.get(nodeId) ?? [];
141
+ if (children.length === 0) return;
142
 
143
+ const childY = y + nodeHeight + V_SPACING;
144
+ let childX = allocatedX;
145
 
146
+ for (const childId of children) {
147
+ const childStWidth = subtreeWidths.get(childId) ?? DEFAULT_WIDTH;
148
+ placeNode(childId, childX, childY);
149
+ childX += childStWidth + H_SPACING;
150
  }
151
+ }
152
 
153
+ // Place root nodes side by side
154
+ let rootX = 0;
155
+ for (const root of rootNodes) {
156
+ computeSubtreeWidth(root.id);
157
+ placeNode(root.id, rootX, 0);
158
+ rootX += (subtreeWidths.get(root.id) ?? DEFAULT_WIDTH) + H_SPACING;
159
  }
160
 
161
+ // Orphan nodes (safety net)
162
+ for (const node of nodes) {
163
  if (positions.has(node.id)) continue;
164
  const allY = Array.from(positions.values()).map((p) => p.y);
165
  const maxY = allY.length > 0 ? Math.max(...allY) : 0;
166
+ positions.set(node.id, { x: 0, y: maxY + DEFAULT_HEIGHT + V_SPACING });
 
 
 
167
  }
168
 
169
  return nodes.map((node) => ({
 
172
  }));
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  function runLayout(nodes: Node[], edges: Edge[]) {
176
  const result = computeLayout(nodes, edges);
177
 
 
190
 
191
  fitView({
192
  maxZoom: 1,
193
+ minZoom: 0.8,
194
  interpolate: 'smooth',
195
  duration: 250,
196
  });
src/lib/components/flow/actions/PanelRightActions.svelte ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Plus, Contrast } from '@lucide/svelte';
3
+ import { Panel } from '@xyflow/svelte';
4
+
5
+ import { Button } from '$lib/components/ui/button';
6
+
7
+
8
+ let { onReset }: { onReset: () => void } = $props();
9
+
10
+ function handleReset() {
11
+ const ok = confirm('Are you sure you want to reset the flow?');
12
+ if (ok) {
13
+ onReset();
14
+ }
15
+ }
16
+ </script>
17
+
18
+ <Panel position="top-right" class="p-3 flex items-center justify-end gap-2">
19
+ <Button variant="outline" size="icon" class="" onclick={handleReset}>
20
+ <Plus />
21
+ </Button>
22
+ </Panel>
src/routes/+layout.svelte CHANGED
@@ -1,5 +1,6 @@
1
  <script lang="ts">
2
  import { onMount } from 'svelte';
 
3
 
4
  import './layout.css';
5
  import HuggingFaceLogo from '$lib/assets/hf-logo.svg';
@@ -27,6 +28,7 @@
27
  <meta name="robots" content="index, follow">
28
  <meta name="google" content="notranslate">
29
  </svelte:head>
 
30
  <svelte:boundary>
31
  <div class="min-h-screen bg-white overflow-hidden">
32
  {#if modelsState.loading}
 
1
  <script lang="ts">
2
  import { onMount } from 'svelte';
3
+ import { ModeWatcher } from "mode-watcher";
4
 
5
  import './layout.css';
6
  import HuggingFaceLogo from '$lib/assets/hf-logo.svg';
 
28
  <meta name="robots" content="index, follow">
29
  <meta name="google" content="notranslate">
30
  </svelte:head>
31
+ <ModeWatcher />
32
  <svelte:boundary>
33
  <div class="min-h-screen bg-white overflow-hidden">
34
  {#if modelsState.loading}
src/routes/+page.svelte CHANGED
@@ -3,25 +3,29 @@
3
  import '@xyflow/svelte/dist/style.css';
4
 
5
  import { modelsState } from '$lib/state/models.svelte';
6
- import Chat from '$lib/components/chat/Chat.svelte';
 
7
  import HFLogo from '$lib/assets/hf-logo.svg';
8
  import type { ChatModel } from '$lib/helpers/types';
9
  import FitViewOnResize from '$lib/components/flow/FitViewOnResize.svelte';
10
- import FollowUp from '$lib/components/chat/FollowUp.svelte';
11
 
12
  const nodeTypes = {
13
- chat: Chat,
14
- followUp: Chat
15
  }
16
 
17
- const initialNodes = [{
18
- id: '1',
19
- type: 'chat',
20
- position: { x: 0, y: 0 },
21
- data: {
22
- selectedModels: modelsState.models.slice(0, 1) as ChatModel[],
23
- },
24
- }];
 
 
 
25
 
26
  let nodes = $state.raw<Node[]>(initialNodes);
27
  let edges = $state.raw<Edge[]>([]);
@@ -32,13 +36,13 @@
32
  bind:nodes
33
  bind:edges
34
  nodeTypes={nodeTypes}
35
- minZoom={0.5}
36
  maxZoom={1.5}
37
  fitView
38
  proOptions={{ hideAttribution: true }}
39
  fitViewOptions={{
40
  maxZoom: 1,
41
- minZoom: 0.5,
42
  interpolate: "smooth",
43
  duration: 500,
44
  }}
@@ -48,10 +52,13 @@
48
  <FitViewOnResize initialNodes={initialNodes} />
49
  <MiniMap />
50
  <Background variant={BackgroundVariant.Lines} gap={30} patternColor="rgba(0, 0, 0, 0.04)" />
51
- <Panel position="top-right" class="p-3">
52
- (user-panel)
53
- </Panel>
54
- <Panel position="bottom-left" class="py-1.5 px-2.5 flex items-center justify-center gap-1.5 rounded-md border border-border bg-background shadow-xs">
 
 
 
55
  <img src={HFLogo} alt="HF Logo" class="size-7" />
56
  <p class="text-xs text-accent-foreground">
57
  Hugging Face Playground
 
3
  import '@xyflow/svelte/dist/style.css';
4
 
5
  import { modelsState } from '$lib/state/models.svelte';
6
+ import User from '$lib/components/chat/User.svelte';
7
+ import Assistant from '$lib/components/chat/Assistant.svelte';
8
  import HFLogo from '$lib/assets/hf-logo.svg';
9
  import type { ChatModel } from '$lib/helpers/types';
10
  import FitViewOnResize from '$lib/components/flow/FitViewOnResize.svelte';
11
+ import PanelRightActions from '$lib/components/flow/actions/PanelRightActions.svelte';
12
 
13
  const nodeTypes = {
14
+ user: User,
15
+ assistant: Assistant
16
  }
17
 
18
+ function getInitialNodes() {
19
+ return [{
20
+ id: `user-${crypto.randomUUID()}`,
21
+ type: 'user',
22
+ position: { x: 0, y: 0 },
23
+ data: {
24
+ selectedModels: modelsState.models.slice(0, 2) as ChatModel[],
25
+ },
26
+ }]
27
+ }
28
+ const initialNodes = getInitialNodes();
29
 
30
  let nodes = $state.raw<Node[]>(initialNodes);
31
  let edges = $state.raw<Edge[]>([]);
 
36
  bind:nodes
37
  bind:edges
38
  nodeTypes={nodeTypes}
39
+ minZoom={0.8}
40
  maxZoom={1.5}
41
  fitView
42
  proOptions={{ hideAttribution: true }}
43
  fitViewOptions={{
44
  maxZoom: 1,
45
+ minZoom: 0.8,
46
  interpolate: "smooth",
47
  duration: 500,
48
  }}
 
52
  <FitViewOnResize initialNodes={initialNodes} />
53
  <MiniMap />
54
  <Background variant={BackgroundVariant.Lines} gap={30} patternColor="rgba(0, 0, 0, 0.04)" />
55
+ <PanelRightActions
56
+ onReset={() => {
57
+ nodes = getInitialNodes();
58
+ edges = [];
59
+ }}
60
+ />
61
+ <Panel position="bottom-left" class="py-1.5 pl-2.5 pr-3.5 flex items-center justify-center gap-1.5 rounded-lg border border-border bg-background shadow-xs">
62
  <img src={HFLogo} alt="HF Logo" class="size-7" />
63
  <p class="text-xs text-accent-foreground">
64
  Hugging Face Playground
src/routes/layout.css CHANGED
@@ -1,121 +1,121 @@
1
- @import "tailwindcss";
2
 
3
- @import "tw-animate-css";
4
 
5
  @custom-variant dark (&:is(.dark *));
6
 
7
  :root {
8
- --radius: 0.625rem;
9
- --background: oklch(1 0 0);
10
- --foreground: oklch(0.13 0.028 261.692);
11
- --card: oklch(1 0 0);
12
- --card-foreground: oklch(0.13 0.028 261.692);
13
- --popover: oklch(1 0 0);
14
- --popover-foreground: oklch(0.13 0.028 261.692);
15
- --primary: oklch(0.21 0.034 264.665);
16
- --primary-foreground: oklch(0.985 0.002 247.839);
17
- --secondary: oklch(0.967 0.003 264.542);
18
- --secondary-foreground: oklch(0.21 0.034 264.665);
19
- --muted: oklch(0.967 0.003 264.542);
20
- --muted-foreground: oklch(0.551 0.027 264.364);
21
- --accent: oklch(0.967 0.003 264.542);
22
- --accent-foreground: oklch(0.21 0.034 264.665);
23
- --destructive: oklch(0.577 0.245 27.325);
24
- --border: oklch(0.928 0.006 264.531);
25
- --input: oklch(0.928 0.006 264.531);
26
- --ring: oklch(0.707 0.022 261.325);
27
- --chart-1: oklch(0.646 0.222 41.116);
28
- --chart-2: oklch(0.6 0.118 184.704);
29
- --chart-3: oklch(0.398 0.07 227.392);
30
- --chart-4: oklch(0.828 0.189 84.429);
31
- --chart-5: oklch(0.769 0.188 70.08);
32
- --sidebar: oklch(0.985 0.002 247.839);
33
- --sidebar-foreground: oklch(0.13 0.028 261.692);
34
- --sidebar-primary: oklch(0.21 0.034 264.665);
35
- --sidebar-primary-foreground: oklch(0.985 0.002 247.839);
36
- --sidebar-accent: oklch(0.967 0.003 264.542);
37
- --sidebar-accent-foreground: oklch(0.21 0.034 264.665);
38
- --sidebar-border: oklch(0.928 0.006 264.531);
39
- --sidebar-ring: oklch(0.707 0.022 261.325);
40
  }
41
 
42
  .dark {
43
- --background: oklch(0.13 0.028 261.692);
44
- --foreground: oklch(0.985 0.002 247.839);
45
- --card: oklch(0.21 0.034 264.665);
46
- --card-foreground: oklch(0.985 0.002 247.839);
47
- --popover: oklch(0.21 0.034 264.665);
48
- --popover-foreground: oklch(0.985 0.002 247.839);
49
- --primary: oklch(0.928 0.006 264.531);
50
- --primary-foreground: oklch(0.21 0.034 264.665);
51
- --secondary: oklch(0.278 0.033 256.848);
52
- --secondary-foreground: oklch(0.985 0.002 247.839);
53
- --muted: oklch(0.278 0.033 256.848);
54
- --muted-foreground: oklch(0.707 0.022 261.325);
55
- --accent: oklch(0.278 0.033 256.848);
56
- --accent-foreground: oklch(0.985 0.002 247.839);
57
- --destructive: oklch(0.704 0.191 22.216);
58
- --border: oklch(1 0 0 / 10%);
59
- --input: oklch(1 0 0 / 15%);
60
- --ring: oklch(0.551 0.027 264.364);
61
- --chart-1: oklch(0.488 0.243 264.376);
62
- --chart-2: oklch(0.696 0.17 162.48);
63
- --chart-3: oklch(0.769 0.188 70.08);
64
- --chart-4: oklch(0.627 0.265 303.9);
65
- --chart-5: oklch(0.645 0.246 16.439);
66
- --sidebar: oklch(0.21 0.034 264.665);
67
- --sidebar-foreground: oklch(0.985 0.002 247.839);
68
- --sidebar-primary: oklch(0.488 0.243 264.376);
69
- --sidebar-primary-foreground: oklch(0.985 0.002 247.839);
70
- --sidebar-accent: oklch(0.278 0.033 256.848);
71
- --sidebar-accent-foreground: oklch(0.985 0.002 247.839);
72
- --sidebar-border: oklch(1 0 0 / 10%);
73
- --sidebar-ring: oklch(0.551 0.027 264.364);
74
  }
75
 
76
  @theme inline {
77
- --radius-sm: calc(var(--radius) - 4px);
78
- --radius-md: calc(var(--radius) - 2px);
79
- --radius-lg: var(--radius);
80
- --radius-xl: calc(var(--radius) + 4px);
81
- --color-background: var(--background);
82
- --color-foreground: var(--foreground);
83
- --color-card: var(--card);
84
- --color-card-foreground: var(--card-foreground);
85
- --color-popover: var(--popover);
86
- --color-popover-foreground: var(--popover-foreground);
87
- --color-primary: var(--primary);
88
- --color-primary-foreground: var(--primary-foreground);
89
- --color-secondary: var(--secondary);
90
- --color-secondary-foreground: var(--secondary-foreground);
91
- --color-muted: var(--muted);
92
- --color-muted-foreground: var(--muted-foreground);
93
- --color-accent: var(--accent);
94
- --color-accent-foreground: var(--accent-foreground);
95
- --color-destructive: var(--destructive);
96
- --color-border: var(--border);
97
- --color-input: var(--input);
98
- --color-ring: var(--ring);
99
- --color-chart-1: var(--chart-1);
100
- --color-chart-2: var(--chart-2);
101
- --color-chart-3: var(--chart-3);
102
- --color-chart-4: var(--chart-4);
103
- --color-chart-5: var(--chart-5);
104
- --color-sidebar: var(--sidebar);
105
- --color-sidebar-foreground: var(--sidebar-foreground);
106
- --color-sidebar-primary: var(--sidebar-primary);
107
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
108
- --color-sidebar-accent: var(--sidebar-accent);
109
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
110
- --color-sidebar-border: var(--sidebar-border);
111
- --color-sidebar-ring: var(--sidebar-ring);
112
  }
113
 
114
  @layer base {
115
- * {
116
- @apply border-border outline-ring/50;
117
- }
118
- body {
119
- @apply bg-background text-foreground;
120
- }
121
- }
 
1
+ @import 'tailwindcss';
2
 
3
+ @import 'tw-animate-css';
4
 
5
  @custom-variant dark (&:is(.dark *));
6
 
7
  :root {
8
+ --radius: 0.625rem;
9
+ --background: oklch(1 0 0);
10
+ --foreground: oklch(0.13 0.028 261.692);
11
+ --card: oklch(1 0 0);
12
+ --card-foreground: oklch(0.13 0.028 261.692);
13
+ --popover: oklch(1 0 0);
14
+ --popover-foreground: oklch(0.13 0.028 261.692);
15
+ --primary: oklch(0.21 0.034 264.665);
16
+ --primary-foreground: oklch(0.985 0.002 247.839);
17
+ --secondary: oklch(0.967 0.003 264.542);
18
+ --secondary-foreground: oklch(0.21 0.034 264.665);
19
+ --muted: oklch(0.967 0.003 264.542);
20
+ --muted-foreground: oklch(0.551 0.027 264.364);
21
+ --accent: oklch(0.967 0.003 264.542);
22
+ --accent-foreground: oklch(0.21 0.034 264.665);
23
+ --destructive: oklch(0.577 0.245 27.325);
24
+ --border: oklch(0.928 0.006 264.531);
25
+ --input: oklch(0.928 0.006 264.531);
26
+ --ring: oklch(0.707 0.022 261.325);
27
+ --chart-1: oklch(0.646 0.222 41.116);
28
+ --chart-2: oklch(0.6 0.118 184.704);
29
+ --chart-3: oklch(0.398 0.07 227.392);
30
+ --chart-4: oklch(0.828 0.189 84.429);
31
+ --chart-5: oklch(0.769 0.188 70.08);
32
+ --sidebar: oklch(0.985 0.002 247.839);
33
+ --sidebar-foreground: oklch(0.13 0.028 261.692);
34
+ --sidebar-primary: oklch(0.21 0.034 264.665);
35
+ --sidebar-primary-foreground: oklch(0.985 0.002 247.839);
36
+ --sidebar-accent: oklch(0.967 0.003 264.542);
37
+ --sidebar-accent-foreground: oklch(0.21 0.034 264.665);
38
+ --sidebar-border: oklch(0.928 0.006 264.531);
39
+ --sidebar-ring: oklch(0.707 0.022 261.325);
40
  }
41
 
42
  .dark {
43
+ --background: oklch(0.13 0.028 261.692);
44
+ --foreground: oklch(0.985 0.002 247.839);
45
+ --card: oklch(0.21 0.034 264.665);
46
+ --card-foreground: oklch(0.985 0.002 247.839);
47
+ --popover: oklch(0.21 0.034 264.665);
48
+ --popover-foreground: oklch(0.985 0.002 247.839);
49
+ --primary: oklch(0.928 0.006 264.531);
50
+ --primary-foreground: oklch(0.21 0.034 264.665);
51
+ --secondary: oklch(0.278 0.033 256.848);
52
+ --secondary-foreground: oklch(0.985 0.002 247.839);
53
+ --muted: oklch(0.278 0.033 256.848);
54
+ --muted-foreground: oklch(0.707 0.022 261.325);
55
+ --accent: oklch(0.278 0.033 256.848);
56
+ --accent-foreground: oklch(0.985 0.002 247.839);
57
+ --destructive: oklch(0.704 0.191 22.216);
58
+ --border: oklch(1 0 0 / 10%);
59
+ --input: oklch(1 0 0 / 15%);
60
+ --ring: oklch(0.551 0.027 264.364);
61
+ --chart-1: oklch(0.488 0.243 264.376);
62
+ --chart-2: oklch(0.696 0.17 162.48);
63
+ --chart-3: oklch(0.769 0.188 70.08);
64
+ --chart-4: oklch(0.627 0.265 303.9);
65
+ --chart-5: oklch(0.645 0.246 16.439);
66
+ --sidebar: oklch(0.21 0.034 264.665);
67
+ --sidebar-foreground: oklch(0.985 0.002 247.839);
68
+ --sidebar-primary: oklch(0.488 0.243 264.376);
69
+ --sidebar-primary-foreground: oklch(0.985 0.002 247.839);
70
+ --sidebar-accent: oklch(0.278 0.033 256.848);
71
+ --sidebar-accent-foreground: oklch(0.985 0.002 247.839);
72
+ --sidebar-border: oklch(1 0 0 / 10%);
73
+ --sidebar-ring: oklch(0.551 0.027 264.364);
74
  }
75
 
76
  @theme inline {
77
+ --radius-sm: calc(var(--radius) - 4px);
78
+ --radius-md: calc(var(--radius) - 2px);
79
+ --radius-lg: var(--radius);
80
+ --radius-xl: calc(var(--radius) + 4px);
81
+ --color-background: var(--background);
82
+ --color-foreground: var(--foreground);
83
+ --color-card: var(--card);
84
+ --color-card-foreground: var(--card-foreground);
85
+ --color-popover: var(--popover);
86
+ --color-popover-foreground: var(--popover-foreground);
87
+ --color-primary: var(--primary);
88
+ --color-primary-foreground: var(--primary-foreground);
89
+ --color-secondary: var(--secondary);
90
+ --color-secondary-foreground: var(--secondary-foreground);
91
+ --color-muted: var(--muted);
92
+ --color-muted-foreground: var(--muted-foreground);
93
+ --color-accent: var(--accent);
94
+ --color-accent-foreground: var(--accent-foreground);
95
+ --color-destructive: var(--destructive);
96
+ --color-border: var(--border);
97
+ --color-input: var(--input);
98
+ --color-ring: var(--ring);
99
+ --color-chart-1: var(--chart-1);
100
+ --color-chart-2: var(--chart-2);
101
+ --color-chart-3: var(--chart-3);
102
+ --color-chart-4: var(--chart-4);
103
+ --color-chart-5: var(--chart-5);
104
+ --color-sidebar: var(--sidebar);
105
+ --color-sidebar-foreground: var(--sidebar-foreground);
106
+ --color-sidebar-primary: var(--sidebar-primary);
107
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
108
+ --color-sidebar-accent: var(--sidebar-accent);
109
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
110
+ --color-sidebar-border: var(--sidebar-border);
111
+ --color-sidebar-ring: var(--sidebar-ring);
112
  }
113
 
114
  @layer base {
115
+ * {
116
+ @apply border-border outline-ring/50;
117
+ }
118
+ body {
119
+ @apply bg-background text-foreground;
120
+ }
121
+ }