Spaces:
Runtime error
Runtime error
| <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> --> | |