Thomas G. Lopes
better disabled
33620bc
<script lang="ts">
import { previewImage } from "$lib/components/inference-playground/img-preview.svelte";
import { TextareaAutosize } from "$lib/spells/textarea-autosize.svelte";
import { images } from "$lib/state/images.svelte.js";
import { models } from "$lib/state/models.svelte";
import { token } from "$lib/state/token.svelte";
import { PipelineTag, type Model } from "$lib/types.js";
import { AsyncQueue } from "$lib/utils/queue.js";
import { InferenceClient } from "@huggingface/inference";
import type { ChatCompletionInputMessage } from "@huggingface/tasks";
import { Handle, Position, useSvelteFlow, type Edge, type Node, type NodeProps } from "@xyflow/svelte";
import { marked } from "marked";
import { FileUpload } from "melt/builders";
import { ElementSize } from "runed";
import { onMount } from "svelte";
import { fade } from "svelte/transition";
import IconImage from "~icons/carbon/image-reference";
import IconMaximize from "~icons/carbon/maximize";
import IconCode from "~icons/lucide/code";
import IconCopy from "~icons/lucide/copy";
import IconLoading from "~icons/lucide/loader-2";
import IconAttachment from "~icons/lucide/paperclip";
import IconAdd from "~icons/lucide/plus";
import IconSend from "~icons/lucide/send";
import IconStop from "~icons/lucide/square";
import IconX from "~icons/lucide/x";
import ModelPicker from "./model-picker.svelte";
import ProviderPicker from "./provider-picker.svelte";
import { edges, nodes } from "./state.js";
type Props = Omit<NodeProps, "data"> & {
data: { query: string; response: string; modelId?: Model["id"]; provider?: string; imageIds?: string[] };
};
let { id, data }: Props = $props();
let { updateNodeData, updateNode, getNode, getViewport } = useSvelteFlow();
onMount(() => {
if (!data.modelId) data.modelId = models.trending[0]?.id;
if (!data.provider) data.provider = "auto";
updateNode(id, { height: undefined });
});
const autosized = new TextareaAutosize();
let isLoading = $state(false);
let abortController = $state<AbortController | null>(null);
const history = $derived.by(function getNodeHistory() {
const node = nodes.current.find(n => n.id === id);
if (!node) return [];
let history: Array<Omit<Node, "data"> & { data: Props["data"] }> = [
node as Omit<Node, "data"> & { data: Props["data"] },
];
let target = node.id;
while (true) {
const parentEdge = edges.current.find(edge => edge.target === target);
if (!parentEdge) break; // No more parents found
const parentNode = nodes.current.find(n => n.id === parentEdge.source);
if (!parentNode) {
// Optional: clean up broken edges
// edges.current = edges.current.filter(e => e.id !== parentEdge.id);
break;
}
history.unshift(parentNode as Omit<Node, "data"> & { data: Props["data"] });
target = parentNode.id; // Move up the chain
}
return history;
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
isLoading = true;
abortController = new AbortController();
updateNodeData(id, { response: "" });
try {
const client = new InferenceClient(token.value);
const messages: ChatCompletionInputMessage[] = await Promise.all(
history.flatMap(async n => {
const res: ChatCompletionInputMessage[] = [];
if (n.data.query) {
let content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> = n.data.query;
// If node has images, convert to multimodal format
if (n.data.imageIds && n.data.imageIds.length > 0) {
const urls = await Promise.all(n.data.imageIds.map(k => images.get(k)));
content = [
{
type: "text",
text: n.data.query,
},
...n.data.imageIds.map((_imgKey, i) => ({
type: "image_url",
image_url: { url: urls[i] as string },
})),
];
}
res.push({
role: "user",
content,
});
}
if (n.data.response) {
res.push({
role: "assistant",
content: n.data.response,
});
}
return res;
}),
).then(arr => arr.flat());
const stream = client.chatCompletionStream(
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: (data.provider || "auto") as any,
model: data.modelId,
messages,
temperature: 0.5,
top_p: 0.7,
},
{
signal: abortController.signal,
},
);
for await (const chunk of stream) {
if (abortController.signal.aborted) {
break;
}
if (chunk.choices && chunk.choices.length > 0) {
const newContent = chunk.choices[0]?.delta.content ?? "";
updateNodeData(id, { response: data.response + newContent });
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Generation was aborted, this is expected
return;
}
// Re-throw other errors
throw error;
} finally {
isLoading = false;
abortController = null;
}
}
function stopGeneration(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
if (abortController) {
abortController.abort();
}
}
let node = $state<HTMLElement>();
const size = new ElementSize(() => node);
const parsedResponse = $derived.by(() => {
if (!data.response) return "";
return marked(data.response);
});
const characterCount = $derived(data.query?.length || 0);
function insertCodeSnippet() {
const currentQuery = data.query || "";
const codeTemplate = "```\n\n```";
const newQuery = currentQuery + (currentQuery ? "\n\n" : "") + codeTemplate;
updateNodeData(id, { query: newQuery });
setTimeout(() => {
const textarea = document.getElementById(`message-${id}`) as HTMLTextAreaElement;
if (textarea) {
const cursorPosition = newQuery.length - 4;
textarea.focus();
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
}, 0);
}
// Helper function to get actual node dimensions from DOM, converted to flow coordinates
function getNodeDimensions(nodeId: string) {
const nodeElement = document.querySelector(`[data-id="${nodeId}"]`);
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect();
const viewport = getViewport();
// Convert from screen coordinates to flow coordinates
const flowWidth = rect.width / viewport.zoom;
const flowHeight = rect.height / viewport.zoom;
return { width: flowWidth, height: flowHeight };
}
// Fallback to default size
return { width: 500, height: 200 };
}
// Helper function to find a non-overlapping position
function findAvailablePosition(
startX: number,
startY: number,
newNodeWidth = 500,
newNodeHeight = 200,
constrainBelow = false,
) {
const spacing = 40;
let x = startX;
let y = startY;
// Check if position overlaps with any existing node
const isOverlapping = (testX: number, testY: number) => {
return nodes.current.some(node => {
if (node.id === id) return false; // Don't check against self
const existingDims = getNodeDimensions(node.id);
const nodeWidth = existingDims.width;
const nodeHeight = existingDims.height;
// Check for overlap with proper spacing
return !(
testX >= node.position.x + nodeWidth + spacing ||
testX + newNodeWidth + spacing <= node.position.x ||
testY >= node.position.y + nodeHeight + spacing ||
testY + newNodeHeight + spacing <= node.position.y
);
});
};
// For add node (constrainBelow = true), maintain same Y level and search horizontally
if (constrainBelow) {
const fixedY = y; // Always use the same Y distance from parent
// Try the preferred X position first
if (!isOverlapping(x, fixedY)) return { x, y: fixedY };
// Search horizontally at the fixed Y level, checking each position carefully
let testX = x;
let attempts = 0;
while (attempts < 50) {
// Try moving right by small increments
testX += 50;
if (!isOverlapping(testX, fixedY)) {
return { x: testX, y: fixedY };
}
attempts++;
}
// Try going left from original position
testX = x;
attempts = 0;
while (attempts < 50) {
testX -= 50;
if (!isOverlapping(testX, fixedY)) {
return { x: testX, y: fixedY };
}
attempts++;
}
// Fallback: force position far to the right
return { x: x + 2000, y: fixedY };
}
// For duplicate (constrainBelow = false), use spiral pattern
let offset = 0;
while (offset < 1000) {
// Try right
if (!isOverlapping(x + offset, y)) return { x: x + offset, y };
// Try left
if (!isOverlapping(x - offset, y)) return { x: x - offset, y };
// Try down
if (!isOverlapping(x, y + offset)) return { x, y: y + offset };
// Try up
if (!isOverlapping(x, y - offset)) return { x, y: y - offset };
// Try diagonal combinations
if (!isOverlapping(x + offset, y + offset)) return { x: x + offset, y: y + offset };
if (!isOverlapping(x - offset, y + offset)) return { x: x - offset, y: y + offset };
if (!isOverlapping(x + offset, y - offset)) return { x: x + offset, y: y - offset };
if (!isOverlapping(x - offset, y - offset)) return { x: x - offset, y: y - offset };
offset += 50;
}
// Fallback: return original position with larger offset
return { x: x + 150, y: y + 150 };
}
const currentModel = $derived(models.all.find(m => m.id === data.modelId));
const supportsImgUpload = $derived(currentModel?.pipeline_tag === PipelineTag.ImageTextToText);
const fileQueue = new AsyncQueue();
const fileUpload = new FileUpload({
accept: "image/*",
multiple: true,
disabled: () => !supportsImgUpload,
async onAccept(file) {
fileQueue.add(async () => {
const key = await images.upload(file);
const prev = data.imageIds ?? [];
await updateNodeData(id, { imageIds: [...prev, key] });
// We're dealing with files ourselves, so we don't want fileUpload to have any internal state,
// to avoid conflicts
if (fileQueue.queue.length <= 1) fileUpload.clear();
});
},
});
</script>
<div
class="chat-node group relative flex h-full min-h-[200px] w-full max-w-[1000px]
min-w-[700px] flex-col items-stretch rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"
bind:this={node}
>
<!-- Model and Provider selectors -->
<div class="mb-4 flex gap-3">
<div class="flex-[3]">
<ModelPicker modelId={data.modelId} onModelSelect={modelId => updateNodeData(id, { modelId })} />
</div>
<div class="flex-[2]">
<ProviderPicker
provider={data.provider}
modelId={data.modelId}
onProviderSelect={provider => updateNodeData(id, { provider })}
/>
</div>
</div>
<form class="flex flex-col" onsubmit={handleSubmit}>
<div class="relative">
<label for={`message-${id}`} class="mb-1.5 block text-xs font-medium text-gray-600">Message</label>
<!-- Message container with top bar, textarea, and bottom bar -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 focus-within:border-gray-900 focus-within:ring-2 focus-within:ring-gray-900/10"
{...fileUpload.dropzone}
onclick={undefined}
>
{#if fileUpload.isDragging}
<div
class="absolute inset-2 z-10 flex flex-col items-center justify-center rounded-xl bg-gray-800/50 backdrop-blur-md"
transition:fade={{ duration: 100 }}
>
<IconImage />
<p>Drop the image here to upload</p>
</div>
{/if}
<!-- Top bar with buttons and character count -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<div class="flex items-center gap-2">
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-600 transition-colors"
class:opacity-50={!supportsImgUpload}
class:cursor-not-allowed={!supportsImgUpload}
class:hover:bg-gray-200={supportsImgUpload}
class:hover:text-gray-900={supportsImgUpload}
title={supportsImgUpload ? "Attach file" : "Model doesn't support images"}
{...fileUpload.trigger}
>
<IconAttachment class="h-4 w-4" />
<input {...fileUpload.input} />
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-900"
title="Code snippet"
onclick={insertCodeSnippet}
>
<IconCode class="h-4 w-4" />
</button>
</div>
<div class="text-xs text-gray-500">
{characterCount}
</div>
</div>
<!-- Textarea -->
<textarea
id={`message-${id}`}
class="nodrag min-h-[80px] w-full resize-none border-0 bg-transparent px-4
py-3 text-sm text-gray-900 placeholder-gray-500 focus:ring-0 focus:outline-none"
placeholder="Type your message..."
value={data.query}
oninput={evt => {
updateNodeData(id, { query: evt.currentTarget.value });
}}
{@attach autosized.attachment}
></textarea>
</div>
</div>
<div class="mt-2">
<div class="flex items-center gap-2">
{#each data.imageIds ?? [] as imgKey (imgKey)}
{#await images.get(imgKey)}
<!-- nothing -->
{:then imgSrc}
<div class="group/img relative">
<button
aria-label="expand"
class="absolute inset-0 z-10 grid place-items-center rounded-md bg-gray-800/70 text-white opacity-0 group-hover/img:opacity-100"
onclick={() => previewImage(imgSrc)}
>
<IconMaximize />
</button>
<img src={imgSrc} alt="uploaded" class="size-12 rounded-md object-cover" />
<button
aria-label="remove"
type="button"
onclick={async e => {
e.stopPropagation();
updateNodeData(id, { imageIds: data.imageIds?.filter(i => i !== imgKey) });
images.delete(imgKey);
}}
class="invisible absolute -top-1 -right-1 z-20 grid size-5 place-items-center rounded-full bg-gray-800 text-xs text-white group-hover/img:visible hover:bg-gray-700"
>
</button>
</div>
{/await}
{/each}
</div>
</div>
<!-- Bottom bar with hint and send button (outside textarea) -->
<div class="mt-2 flex items-center justify-between">
<div class="text-xs text-gray-500">Shift + Enter for newline</div>
<button
type={isLoading ? "button" : "submit"}
onclick={isLoading ? stopGeneration : undefined}
class="flex items-center gap-2 rounded-xl
bg-black px-4 py-2 text-sm font-medium
text-white transition-all hover:scale-[1.02] hover:bg-gray-900
focus:ring-2 focus:ring-gray-900/20 focus:outline-none
active:scale-[0.98]"
>
{#if isLoading}
<IconStop class="h-4 w-4" />
Stop
{:else}
<IconSend class="h-4 w-4" />
Send
{/if}
</button>
</div>
</form>
{#if data.response || isLoading}
<div class="mt-4">
<div class="mb-2 flex items-center gap-2 text-xs font-medium text-gray-600">
Response
{#if isLoading}
<IconLoading class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if data.response}
<div class="prose prose-sm max-w-none text-gray-800">
{@html parsedResponse}
</div>
{:else if isLoading}
<div class="text-sm text-gray-500">Generating response...</div>
{/if}
</div>
{/if}
<!-- Action buttons -->
<div class="abs-x-center absolute -bottom-4 z-10 flex gap-2 opacity-0 transition-all group-hover:opacity-100">
<!-- Duplicate button -->
<button
class="flex items-center gap-1.5 rounded-full bg-gray-700
px-4 py-2 text-xs font-medium text-white
shadow-sm transition-all hover:scale-[1.02]
hover:bg-gray-600 focus:ring-2 focus:ring-gray-600/20 focus:outline-none active:scale-[0.98]"
onclick={() => {
const curr = getNode(id);
const newNodeId = crypto.randomUUID();
const currentDims = getNodeDimensions(id);
const preferredPos = findAvailablePosition(
(curr?.position.x ?? 100) + 50,
(curr?.position.y ?? 0) + 50,
currentDims.width,
currentDims.height,
);
const newNode: Node = {
id: newNodeId,
position: preferredPos,
data: {
query: data.query,
response: data.response,
modelId: data.modelId,
provider: data.provider,
},
type: "chat",
width: undefined,
height: undefined,
};
nodes.current.push(newNode);
// Copy only incoming edges (parent connections)
const incomingEdges = edges.current.filter(edge => edge.target === id);
for (const edge of incomingEdges) {
const newEdge: Edge = {
id: crypto.randomUUID(),
source: edge.source,
target: newNodeId,
animated: edge.animated,
label: edge.label,
data: edge.data,
};
edges.current.push(newEdge);
}
}}
>
<IconCopy class="h-3 w-3" />
Duplicate
</button>
<!-- Add node button -->
<button
class="flex items-center gap-1.5 rounded-full bg-black
px-4 py-2 text-xs font-medium text-white
shadow-sm transition-all hover:scale-[1.02]
hover:bg-gray-900 focus:ring-2 focus:ring-gray-900/20 focus:outline-none active:scale-[0.98]"
onclick={() => {
const curr = getNode(id);
const currentDims = getNodeDimensions(id);
const preferredPos = findAvailablePosition(
curr?.position.x ?? 100,
(curr?.position.y ?? 0) + size.height + 40,
currentDims.width,
currentDims.height,
true, // constrainBelow = true for Add Node
);
const newNode: Node = {
id: crypto.randomUUID(),
position: preferredPos,
data: { query: "", response: "", modelId: data.modelId, provider: data.provider },
type: "chat",
width: undefined,
height: undefined,
};
nodes.current.push(newNode);
const edge: Edge = {
id: crypto.randomUUID(),
source: curr!.id,
target: newNode.id,
animated: true,
label: "",
data: {},
};
edges.current.push(edge);
}}
>
<IconAdd class="h-3 w-3" />
Add Node
</button>
</div>
<!-- Close button -->
<button
class="absolute top-3 right-3 rounded-lg p-1.5 text-gray-400 transition-colors
hover:bg-red-50 hover:text-red-500 focus:ring-2 focus:ring-red-500/20 focus:outline-none"
onclick={() => (nodes.current = nodes.current.filter(n => n.id !== id))}
>
<IconX class="h-4 w-4" />
</button>
</div>
<Handle type="target" position={Position.Top} class="h-3 w-3 border-2 border-white bg-gray-500 shadow-sm" />
<Handle
type="source"
position={Position.Bottom}
class="h-3 w-3 border-2 border-white bg-gray-500 opacity-0 shadow-sm"
/>
<!-- <NodeResizeControl minWidth={200} minHeight={150}> -->
<!-- <IconResize class="absolute right-2 bottom-2" /> -->
<!-- </NodeResizeControl> -->