Thomas G. Lopes
fix overlaps
aecbec0
raw
history blame
13.1 kB
<script lang="ts">
import { TextareaAutosize } from "$lib/spells/textarea-autosize.svelte";
import { models } from "$lib/state/models.svelte";
import { token } from "$lib/state/token.svelte";
import type { Model } from "$lib/types.js";
import { InferenceClient } from "@huggingface/inference";
import { Handle, Position, useSvelteFlow, type Edge, type Node, type NodeProps } from "@xyflow/svelte";
import { onMount } from "svelte";
import { edges, nodes } from "./state.js";
import IconLoading from "~icons/lucide/loader-2";
import IconAdd from "~icons/lucide/plus";
import IconX from "~icons/lucide/x";
import IconStop from "~icons/lucide/square";
import IconCopy from "~icons/lucide/copy";
import type { ChatCompletionInputMessage } from "@huggingface/tasks";
import ModelPicker from "./model-picker.svelte";
import ProviderPicker from "./provider-picker.svelte";
import { ElementSize } from "runed";
import { marked } from "marked";
type Props = Omit<NodeProps, "data"> & {
data: { query: string; response: string; modelId?: Model["id"]; provider?: 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[] = history.flatMap(n => {
const res: ChatCompletionInputMessage[] = [];
if (n.data.query) {
res.push({
role: "user",
content: n.data.query,
});
}
if (n.data.response) {
res.push({
role: "assistant",
content: n.data.response,
});
}
return res;
});
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);
});
// 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 };
}
</script>
<div
class="chat-node group relative flex h-full min-h-[200px] w-full max-w-[800px]
min-w-[500px] 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 space-y-3">
<ModelPicker modelId={data.modelId} onModelSelect={modelId => updateNodeData(id, { modelId })} />
<ProviderPicker
provider={data.provider}
modelId={data.modelId}
onProviderSelect={provider => updateNodeData(id, { provider })}
/>
</div>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
<div class="relative">
<label for={`message-${id}`} class="mb-1.5 block text-xs font-medium text-gray-600">Message</label>
<textarea
id={`message-${id}`}
class="nodrag min-h-[80px] w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-4
py-3 text-sm text-gray-900 placeholder-gray-500 transition-colors
focus:border-gray-900 focus:ring-2 focus:ring-gray-900/10 focus:outline-none"
placeholder="Type your message here..."
value={data.query}
oninput={evt => {
updateNodeData(id, { query: evt.currentTarget.value });
}}
{@attach autosized.attachment}
></textarea>
</div>
<button
type={isLoading ? "button" : "submit"}
onclick={isLoading ? stopGeneration : undefined}
class="flex items-center justify-center gap-2 self-center rounded-xl
bg-black px-6 py-2.5 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 Generation
{:else}
Send Message
{/if}
</button>
</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> -->