Thomas G. Lopes commited on
Commit
5152615
·
1 Parent(s): 9b3602c
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 style:width="100vw" style:height="100vh">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 IconCross from "~icons/carbon/x";
 
 
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"] }> = [node as any];
 
 
29
  let target = node.id;
30
 
31
  while (true) {
@@ -39,92 +45,138 @@
39
  break;
40
  }
41
 
42
- history.unshift(parentNode as any);
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
- return res;
71
- });
72
-
73
- const stream = client.chatCompletionStream({
74
- provider: "auto",
75
- model: data.modelId,
76
- messages,
77
- temperature: 0.5,
78
- top_p: 0.7,
79
- });
80
-
81
- for await (const chunk of stream) {
82
- if (chunk.choices && chunk.choices.length > 0) {
83
- const newContent = chunk.choices[0]?.delta.content ?? "";
84
- updateNodeData(id, { response: data.response + newContent });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  }
 
 
86
  }
87
  }
88
  </script>
89
 
90
  <div
91
- class="chat-node relative flex h-full min-h-[150px] w-full max-w-[500px] min-w-[200px]
92
- flex-col items-stretch rounded border bg-white p-8 shadow-xs"
93
  >
94
- <select class="block border" bind:value={() => data.modelId, modelId => updateNodeData(id, { modelId })}>
95
- {#each models.all as model}
96
- <option value={model.id}>{model.id}</option>
97
- {/each}
98
- </select>
99
-
100
- <form class="mt-2 flex flex-col gap-2" onsubmit={handleSubmit}>
101
- <textarea
102
- class="nodrag block min-w-0 shrink grow resize-none border"
103
- placeholder="Type your message here..."
104
- value={data.query}
105
- oninput={evt => {
106
- updateNodeData(id, { query: evt.currentTarget.value });
107
- }}
108
- {@attach autosized.attachment}
109
- ></textarea>
110
- <button class="self-center bg-blue-500 px-4 py-1 text-white hover:bg-blue-400"> Send </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </form>
112
 
113
- {#if data.response}
114
- <div class="mt-2 border-t border-gray-200 p-2">
115
- <pre class="whitespace-pre-wrap">{data.response}</pre>
 
 
 
 
 
 
 
 
116
  </div>
117
  {/if}
118
 
119
- <!-- Add node -->
120
  <button
121
- class="abs-x-center absolute bottom-0 z-10 translate-y-1/2 rounded bg-black px-3 py-1
122
- text-xs text-white hover:bg-neutral-700"
 
 
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) + 500 },
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
- Add
 
146
  </button>
147
 
148
- <button class="absolute top-1 right-1" onclick={() => (nodes.current = nodes.current.filter(n => n.id !== id))}>
149
- <IconCross />
 
 
 
 
 
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" /> -->