Spaces:
Running
Running
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 +1 -0
- pnpm-lock.yaml +47 -0
- src/lib/components/chat/Assistant.svelte +50 -0
- src/lib/components/chat/Chat.svelte +0 -220
- src/lib/components/chat/Message.svelte +43 -0
- src/lib/components/chat/Messages.svelte +0 -26
- src/lib/components/chat/{FollowUp.svelte → User.svelte} +104 -127
- src/lib/components/chat/markdown/Blockquote.svelte +8 -0
- src/lib/components/chat/markdown/Code.svelte +12 -0
- src/lib/components/chat/markdown/Codespan.svelte +5 -0
- src/lib/components/chat/markdown/Heading.svelte +14 -0
- src/lib/components/chat/markdown/Hr.svelte +1 -0
- src/lib/components/chat/markdown/Link.svelte +8 -0
- src/lib/components/chat/markdown/List.svelte +14 -0
- src/lib/components/chat/markdown/ListItem.svelte +6 -0
- src/lib/components/chat/markdown/Paragraph.svelte +6 -0
- src/lib/components/flow/FitViewOnResize.svelte +68 -85
- src/lib/components/flow/actions/PanelRightActions.svelte +22 -0
- src/routes/+layout.svelte +2 -0
- src/routes/+page.svelte +25 -18
- src/routes/layout.css +107 -107
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
|
| 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 {
|
| 17 |
-
const {
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
y: 0,
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
const assistantIndex = messages.length - 1;
|
| 88 |
-
let content = '';
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
-
updateNodes((currentNodes) => [...currentNodes, newNode]);
|
| 117 |
-
updateEdges((currentEdges) => [...currentEdges, newEdge]);
|
| 118 |
-
break;
|
| 119 |
-
}
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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 |
-
|
| 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 |
-
|
| 153 |
-
<
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
</Button>
|
| 156 |
{/each}
|
| 157 |
-
{#if selectedModels.length < 3 && !loading && !
|
| 158 |
<ComboBoxModels
|
| 159 |
onSelect={addModel}
|
| 160 |
excludeIds={selectedModels.map((m) => m.id)}
|
|
@@ -162,18 +157,18 @@
|
|
| 162 |
{/if}
|
| 163 |
</div>
|
| 164 |
</header>
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 178 |
e.preventDefault();
|
| 179 |
prompt = prompt.trim();
|
|
@@ -181,26 +176,8 @@
|
|
| 181 |
handleTriggerAction();
|
| 182 |
}
|
| 183 |
}
|
| 184 |
-
}}
|
| 185 |
-
|
| 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 =
|
| 13 |
-
const V_SPACING =
|
| 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.
|
| 66 |
interpolate: 'smooth',
|
| 67 |
duration: 500,
|
| 68 |
});
|
|
@@ -77,88 +74,96 @@
|
|
| 77 |
});
|
| 78 |
|
| 79 |
/**
|
| 80 |
-
* Custom layout:
|
| 81 |
-
* -
|
| 82 |
-
* -
|
| 83 |
-
* -
|
|
|
|
| 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
|
| 91 |
-
const
|
|
|
|
| 92 |
for (const e of edges) {
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 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 |
-
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
});
|
| 114 |
-
});
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
-
const
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
|
| 129 |
-
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
-
}
|
| 147 |
|
| 148 |
-
//
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
|
| 153 |
-
// Orphan
|
| 154 |
-
for (const node of
|
| 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.
|
| 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
|
|
|
|
| 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
|
| 11 |
|
| 12 |
const nodeTypes = {
|
| 13 |
-
|
| 14 |
-
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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.
|
| 36 |
maxZoom={1.5}
|
| 37 |
fitView
|
| 38 |
proOptions={{ hideAttribution: true }}
|
| 39 |
fitViewOptions={{
|
| 40 |
maxZoom: 1,
|
| 41 |
-
minZoom: 0.
|
| 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 |
-
<
|
| 52 |
-
(
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
@import
|
| 4 |
|
| 5 |
@custom-variant dark (&:is(.dark *));
|
| 6 |
|
| 7 |
:root {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
}
|
| 41 |
|
| 42 |
.dark {
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
}
|
| 75 |
|
| 76 |
@theme inline {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
}
|
| 113 |
|
| 114 |
@layer base {
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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 |
+
}
|