Spaces:
Runtime error
Runtime error
Thomas G. Lopes
commited on
Commit
·
5152615
1
Parent(s):
9b3602c
styling
Browse files- src/routes/canvas/+page.svelte +40 -5
- src/routes/canvas/chat-node.svelte +126 -68
src/routes/canvas/+page.svelte
CHANGED
|
@@ -3,17 +3,52 @@
|
|
| 3 |
import "@xyflow/svelte/dist/style.css";
|
| 4 |
import ChatNode from "./chat-node.svelte";
|
| 5 |
import { edges, nodes } from "./state.js";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const nodeTypes = { chat: ChatNode } as const;
|
| 8 |
|
| 9 |
-
// Make edges non-editable
|
| 10 |
const edgeOptions = {
|
| 11 |
deletable: false,
|
| 12 |
selectable: false,
|
|
|
|
| 13 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</script>
|
| 15 |
|
| 16 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
<SvelteFlow
|
| 18 |
bind:nodes={nodes.current}
|
| 19 |
bind:edges={edges.current}
|
|
@@ -21,8 +56,8 @@
|
|
| 21 |
{nodeTypes}
|
| 22 |
defaultEdgeOptions={edgeOptions}
|
| 23 |
>
|
| 24 |
-
<MiniMap />
|
| 25 |
-
<Controls />
|
| 26 |
-
<Background />
|
| 27 |
</SvelteFlow>
|
| 28 |
</div>
|
|
|
|
| 3 |
import "@xyflow/svelte/dist/style.css";
|
| 4 |
import ChatNode from "./chat-node.svelte";
|
| 5 |
import { edges, nodes } from "./state.js";
|
| 6 |
+
import type { Node } from "@xyflow/svelte";
|
| 7 |
+
import IconAdd from "~icons/lucide/plus";
|
| 8 |
|
| 9 |
const nodeTypes = { chat: ChatNode } as const;
|
| 10 |
|
| 11 |
+
// Make edges non-editable with clean styling
|
| 12 |
const edgeOptions = {
|
| 13 |
deletable: false,
|
| 14 |
selectable: false,
|
| 15 |
+
style: "stroke: #9ca3af; stroke-width: 2px;",
|
| 16 |
};
|
| 17 |
+
|
| 18 |
+
function addNewNode() {
|
| 19 |
+
const newNode: Node = {
|
| 20 |
+
id: crypto.randomUUID(),
|
| 21 |
+
position: { x: Math.random() * 500, y: Math.random() * 300 },
|
| 22 |
+
data: { query: "", response: "", modelId: undefined },
|
| 23 |
+
type: "chat",
|
| 24 |
+
width: undefined,
|
| 25 |
+
height: undefined,
|
| 26 |
+
};
|
| 27 |
+
nodes.current.push(newNode);
|
| 28 |
+
}
|
| 29 |
</script>
|
| 30 |
|
| 31 |
+
<div class="h-screen w-screen bg-gray-50">
|
| 32 |
+
<!-- Header -->
|
| 33 |
+
<header class="absolute top-6 left-6 z-50 flex items-center gap-4">
|
| 34 |
+
<div
|
| 35 |
+
class="flex items-center gap-3 rounded-xl border border-gray-200/80 bg-white/95 px-6
|
| 36 |
+
py-3 shadow-sm backdrop-blur-md"
|
| 37 |
+
>
|
| 38 |
+
<h1 class="text-lg font-medium text-gray-900">Canvas</h1>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<button
|
| 42 |
+
onclick={addNewNode}
|
| 43 |
+
class="flex items-center gap-2 rounded-xl bg-black px-5 py-3 text-sm
|
| 44 |
+
font-medium text-white shadow-sm transition-all hover:scale-[1.02] hover:bg-gray-900
|
| 45 |
+
focus:ring-2 focus:ring-gray-900/20 focus:outline-hidden active:scale-[0.98]"
|
| 46 |
+
>
|
| 47 |
+
<IconAdd class="h-4 w-4" />
|
| 48 |
+
Add Node
|
| 49 |
+
</button>
|
| 50 |
+
</header>
|
| 51 |
+
|
| 52 |
<SvelteFlow
|
| 53 |
bind:nodes={nodes.current}
|
| 54 |
bind:edges={edges.current}
|
|
|
|
| 56 |
{nodeTypes}
|
| 57 |
defaultEdgeOptions={edgeOptions}
|
| 58 |
>
|
| 59 |
+
<MiniMap pannable zoomable class="rounded-xl border border-gray-200 bg-white shadow-sm" />
|
| 60 |
+
<Controls class="rounded-xl border border-gray-200 bg-white shadow-sm" />
|
| 61 |
+
<Background gap={20} size={1} />
|
| 62 |
</SvelteFlow>
|
| 63 |
</div>
|
src/routes/canvas/chat-node.svelte
CHANGED
|
@@ -7,7 +7,9 @@
|
|
| 7 |
import { Handle, Position, useSvelteFlow, type Edge, type Node, type NodeProps } from "@xyflow/svelte";
|
| 8 |
import { onMount } from "svelte";
|
| 9 |
import { edges, nodes } from "./state.js";
|
| 10 |
-
import
|
|
|
|
|
|
|
| 11 |
import type { ChatCompletionInputMessage } from "@huggingface/tasks";
|
| 12 |
|
| 13 |
type Props = Omit<NodeProps, "data"> & { data: { query: string; response: string; modelId?: Model["id"] } };
|
|
@@ -21,11 +23,15 @@
|
|
| 21 |
|
| 22 |
const autosized = new TextareaAutosize();
|
| 23 |
|
|
|
|
|
|
|
| 24 |
const history = $derived.by(function getNodeHistory() {
|
| 25 |
const node = nodes.current.find(n => n.id === id);
|
| 26 |
if (!node) return [];
|
| 27 |
|
| 28 |
-
let history: Array<Omit<Node, "data"> & { data: Props["data"] }> = [
|
|
|
|
|
|
|
| 29 |
let target = node.id;
|
| 30 |
|
| 31 |
while (true) {
|
|
@@ -39,92 +45,138 @@
|
|
| 39 |
break;
|
| 40 |
}
|
| 41 |
|
| 42 |
-
history.unshift(parentNode as
|
| 43 |
target = parentNode.id; // Move up the chain
|
| 44 |
}
|
| 45 |
|
| 46 |
return history;
|
| 47 |
});
|
| 48 |
-
$inspect(data.query, history);
|
| 49 |
|
| 50 |
async function handleSubmit(e: SubmitEvent) {
|
| 51 |
e.preventDefault();
|
|
|
|
| 52 |
updateNodeData(id, { response: "" });
|
| 53 |
-
const client = new InferenceClient(token.value);
|
| 54 |
-
|
| 55 |
-
const messages: ChatCompletionInputMessage[] = history.flatMap(n => {
|
| 56 |
-
const res: ChatCompletionInputMessage[] = [];
|
| 57 |
-
if (n.data.query) {
|
| 58 |
-
res.push({
|
| 59 |
-
role: "user",
|
| 60 |
-
content: n.data.query,
|
| 61 |
-
});
|
| 62 |
-
}
|
| 63 |
-
if (n.data.response) {
|
| 64 |
-
res.push({
|
| 65 |
-
role: "assistant",
|
| 66 |
-
content: n.data.response,
|
| 67 |
-
});
|
| 68 |
-
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
}
|
| 88 |
</script>
|
| 89 |
|
| 90 |
<div
|
| 91 |
-
class="chat-node relative flex h-full min-h-[
|
| 92 |
-
flex-col items-stretch rounded border bg-white p-
|
| 93 |
>
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</form>
|
| 112 |
|
| 113 |
-
{#if data.response}
|
| 114 |
-
<div class="mt-
|
| 115 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
{/if}
|
| 118 |
|
| 119 |
-
<!-- Add node -->
|
| 120 |
<button
|
| 121 |
-
class="abs-x-center absolute bottom-
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
onclick={() => {
|
| 124 |
const curr = getNode(id);
|
| 125 |
const newNode: Node = {
|
| 126 |
id: crypto.randomUUID(),
|
| 127 |
-
position: { x: curr?.position.x ?? 100, y: (curr?.position.y ?? 0) +
|
| 128 |
data: { query: "", response: "", modelId: data.modelId },
|
| 129 |
type: "chat",
|
| 130 |
width: undefined,
|
|
@@ -142,18 +194,24 @@
|
|
| 142 |
edges.current.push(edge);
|
| 143 |
}}
|
| 144 |
>
|
| 145 |
-
|
|
|
|
| 146 |
</button>
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
</button>
|
| 151 |
</div>
|
| 152 |
|
| 153 |
-
<Handle type="target" position={Position.Top} />
|
| 154 |
-
<Handle type="source" position={Position.Bottom} />
|
| 155 |
-
<Handle type="source" position={Position.Left} />
|
| 156 |
-
<Handle type="source" position={Position.Right} />
|
| 157 |
|
| 158 |
<!-- <NodeResizeControl minWidth={200} minHeight={150}> -->
|
| 159 |
<!-- <IconResize class="absolute right-2 bottom-2" /> -->
|
|
|
|
| 7 |
import { Handle, Position, useSvelteFlow, type Edge, type Node, type NodeProps } from "@xyflow/svelte";
|
| 8 |
import { onMount } from "svelte";
|
| 9 |
import { edges, nodes } from "./state.js";
|
| 10 |
+
import IconLoading from "~icons/lucide/loader-2";
|
| 11 |
+
import IconAdd from "~icons/lucide/plus";
|
| 12 |
+
import IconX from "~icons/lucide/x";
|
| 13 |
import type { ChatCompletionInputMessage } from "@huggingface/tasks";
|
| 14 |
|
| 15 |
type Props = Omit<NodeProps, "data"> & { data: { query: string; response: string; modelId?: Model["id"] } };
|
|
|
|
| 23 |
|
| 24 |
const autosized = new TextareaAutosize();
|
| 25 |
|
| 26 |
+
let isLoading = $state(false);
|
| 27 |
+
|
| 28 |
const history = $derived.by(function getNodeHistory() {
|
| 29 |
const node = nodes.current.find(n => n.id === id);
|
| 30 |
if (!node) return [];
|
| 31 |
|
| 32 |
+
let history: Array<Omit<Node, "data"> & { data: Props["data"] }> = [
|
| 33 |
+
node as Omit<Node, "data"> & { data: Props["data"] },
|
| 34 |
+
];
|
| 35 |
let target = node.id;
|
| 36 |
|
| 37 |
while (true) {
|
|
|
|
| 45 |
break;
|
| 46 |
}
|
| 47 |
|
| 48 |
+
history.unshift(parentNode as Omit<Node, "data"> & { data: Props["data"] });
|
| 49 |
target = parentNode.id; // Move up the chain
|
| 50 |
}
|
| 51 |
|
| 52 |
return history;
|
| 53 |
});
|
|
|
|
| 54 |
|
| 55 |
async function handleSubmit(e: SubmitEvent) {
|
| 56 |
e.preventDefault();
|
| 57 |
+
isLoading = true;
|
| 58 |
updateNodeData(id, { response: "" });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
+
try {
|
| 61 |
+
const client = new InferenceClient(token.value);
|
| 62 |
+
|
| 63 |
+
const messages: ChatCompletionInputMessage[] = history.flatMap(n => {
|
| 64 |
+
const res: ChatCompletionInputMessage[] = [];
|
| 65 |
+
if (n.data.query) {
|
| 66 |
+
res.push({
|
| 67 |
+
role: "user",
|
| 68 |
+
content: n.data.query,
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
if (n.data.response) {
|
| 72 |
+
res.push({
|
| 73 |
+
role: "assistant",
|
| 74 |
+
content: n.data.response,
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return res;
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const stream = client.chatCompletionStream({
|
| 82 |
+
provider: "auto",
|
| 83 |
+
model: data.modelId,
|
| 84 |
+
messages,
|
| 85 |
+
temperature: 0.5,
|
| 86 |
+
top_p: 0.7,
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
for await (const chunk of stream) {
|
| 90 |
+
if (chunk.choices && chunk.choices.length > 0) {
|
| 91 |
+
const newContent = chunk.choices[0]?.delta.content ?? "";
|
| 92 |
+
updateNodeData(id, { response: data.response + newContent });
|
| 93 |
+
}
|
| 94 |
}
|
| 95 |
+
} finally {
|
| 96 |
+
isLoading = false;
|
| 97 |
}
|
| 98 |
}
|
| 99 |
</script>
|
| 100 |
|
| 101 |
<div
|
| 102 |
+
class="chat-node relative flex h-full min-h-[200px] w-full max-w-[500px] min-w-[300px]
|
| 103 |
+
flex-col items-stretch rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"
|
| 104 |
>
|
| 105 |
+
<!-- Model selector -->
|
| 106 |
+
<div class="mb-4">
|
| 107 |
+
<label class="mb-1.5 block text-xs font-medium text-gray-600"> Model </label>
|
| 108 |
+
<select
|
| 109 |
+
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm
|
| 110 |
+
text-gray-900 transition-colors focus:border-gray-900 focus:ring-2 focus:ring-gray-900/10
|
| 111 |
+
focus:outline-none"
|
| 112 |
+
bind:value={() => data.modelId, modelId => updateNodeData(id, { modelId })}
|
| 113 |
+
>
|
| 114 |
+
{#each models.all as model}
|
| 115 |
+
<option value={model.id}>{model.id}</option>
|
| 116 |
+
{/each}
|
| 117 |
+
</select>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
|
| 121 |
+
<div class="relative">
|
| 122 |
+
<label for={`message-${id}`} class="mb-1.5 block text-xs font-medium text-gray-600">Message</label>
|
| 123 |
+
<textarea
|
| 124 |
+
id={`message-${id}`}
|
| 125 |
+
class="nodrag min-h-[80px] w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-4
|
| 126 |
+
py-3 text-sm text-gray-900 placeholder-gray-500 transition-colors
|
| 127 |
+
focus:border-gray-900 focus:ring-2 focus:ring-gray-900/10 focus:outline-none"
|
| 128 |
+
placeholder="Type your message here..."
|
| 129 |
+
value={data.query}
|
| 130 |
+
oninput={evt => {
|
| 131 |
+
updateNodeData(id, { query: evt.currentTarget.value });
|
| 132 |
+
}}
|
| 133 |
+
{@attach autosized.attachment}
|
| 134 |
+
></textarea>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<button
|
| 138 |
+
type="submit"
|
| 139 |
+
disabled={isLoading}
|
| 140 |
+
class="flex items-center justify-center gap-2 self-center rounded-xl
|
| 141 |
+
bg-black px-6 py-2.5 text-sm font-medium
|
| 142 |
+
text-white transition-all hover:scale-[1.02] hover:bg-gray-900
|
| 143 |
+
focus:ring-2 focus:ring-gray-900/20 focus:outline-none
|
| 144 |
+
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50"
|
| 145 |
+
>
|
| 146 |
+
{#if isLoading}
|
| 147 |
+
<IconLoading class="h-4 w-4 animate-spin" />
|
| 148 |
+
Sending...
|
| 149 |
+
{:else}
|
| 150 |
+
Send Message
|
| 151 |
+
{/if}
|
| 152 |
+
</button>
|
| 153 |
</form>
|
| 154 |
|
| 155 |
+
{#if data.response || isLoading}
|
| 156 |
+
<div class="mt-4 rounded-lg border border-gray-100 bg-gray-50/50 p-4">
|
| 157 |
+
<div class="mb-2 text-xs font-medium text-gray-600">Response</div>
|
| 158 |
+
{#if data.response}
|
| 159 |
+
<pre class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">{data.response}</pre>
|
| 160 |
+
{:else if isLoading}
|
| 161 |
+
<div class="flex items-center gap-2 text-sm text-gray-500">
|
| 162 |
+
<IconLoading class="h-4 w-4 animate-spin" />
|
| 163 |
+
Generating response...
|
| 164 |
+
</div>
|
| 165 |
+
{/if}
|
| 166 |
</div>
|
| 167 |
{/if}
|
| 168 |
|
| 169 |
+
<!-- Add node button -->
|
| 170 |
<button
|
| 171 |
+
class="abs-x-center absolute -bottom-4 flex items-center gap-1.5 rounded-full
|
| 172 |
+
bg-black px-4 py-2 text-xs font-medium
|
| 173 |
+
text-white shadow-sm transition-all hover:scale-[1.02]
|
| 174 |
+
hover:bg-gray-900 focus:ring-2 focus:ring-gray-900/20 focus:outline-none active:scale-[0.98]"
|
| 175 |
onclick={() => {
|
| 176 |
const curr = getNode(id);
|
| 177 |
const newNode: Node = {
|
| 178 |
id: crypto.randomUUID(),
|
| 179 |
+
position: { x: curr?.position.x ?? 100, y: (curr?.position.y ?? 0) + 400 },
|
| 180 |
data: { query: "", response: "", modelId: data.modelId },
|
| 181 |
type: "chat",
|
| 182 |
width: undefined,
|
|
|
|
| 194 |
edges.current.push(edge);
|
| 195 |
}}
|
| 196 |
>
|
| 197 |
+
<IconAdd class="h-3 w-3" />
|
| 198 |
+
Add Node
|
| 199 |
</button>
|
| 200 |
|
| 201 |
+
<!-- Close button -->
|
| 202 |
+
<button
|
| 203 |
+
class="absolute top-3 right-3 rounded-lg p-1.5 text-gray-400 transition-colors
|
| 204 |
+
hover:bg-red-50 hover:text-red-500 focus:ring-2 focus:ring-red-500/20 focus:outline-none"
|
| 205 |
+
onclick={() => (nodes.current = nodes.current.filter(n => n.id !== id))}
|
| 206 |
+
>
|
| 207 |
+
<IconX class="h-4 w-4" />
|
| 208 |
</button>
|
| 209 |
</div>
|
| 210 |
|
| 211 |
+
<Handle type="target" position={Position.Top} class="h-3 w-3 border-2 border-white bg-gray-500 shadow-sm" />
|
| 212 |
+
<Handle type="source" position={Position.Bottom} class="h-3 w-3 border-2 border-white bg-gray-500 shadow-sm" />
|
| 213 |
+
<Handle type="source" position={Position.Left} class="h-3 w-3 border-2 border-white bg-gray-500 shadow-sm" />
|
| 214 |
+
<Handle type="source" position={Position.Right} class="h-3 w-3 border-2 border-white bg-gray-500 shadow-sm" />
|
| 215 |
|
| 216 |
<!-- <NodeResizeControl minWidth={200} minHeight={150}> -->
|
| 217 |
<!-- <IconResize class="absolute right-2 bottom-2" /> -->
|