Spaces:
Running
Running
| <script lang="ts"> | |
| import { | |
| Handle, | |
| useNodesData, | |
| Position, | |
| type NodeProps, | |
| useNodes, | |
| useEdges, | |
| useUpdateNodeInternals, | |
| useSvelteFlow, | |
| type Edge, | |
| type Node | |
| } from '@xyflow/svelte'; | |
| import { MessageCirclePlus } from '@lucide/svelte'; | |
| import { mode } from 'mode-watcher'; | |
| import type { ChatMessage, TokenUsage } from '$lib/helpers/types'; | |
| import { Button } from '$lib/components/ui/button'; | |
| import Message from './Message.svelte'; | |
| import Spinner from '$lib/components/loading/Spinner.svelte'; | |
| import { formatUsageCost } from '$lib'; | |
| import { modelsState } from '$lib/state/models.svelte'; | |
| import { PROVIDER_SELECTION_MODES } from '$lib/consts'; | |
| import ListModels from '$lib/components/model/ListModels.svelte'; | |
| let { id }: NodeProps = $props(); | |
| // svelte-ignore state_referenced_locally | |
| const nodeData = useNodesData(id); | |
| const { update: updateNodes } = useNodes(); | |
| const { update: updateEdges } = useEdges(); | |
| const updateNodeInternals = useUpdateNodeInternals(); | |
| const { fitView } = useSvelteFlow(); | |
| let selectedModel = $derived((nodeData.current?.data.selectedModel as string) ?? ''); | |
| let messages = $derived((nodeData.current?.data.messages as ChatMessage[]) ?? []); | |
| let loading = $derived((nodeData.current?.data.loading as boolean) ?? false); | |
| let usage = $derived((nodeData.current?.data.usage as TokenUsage) ?? null); | |
| let message = $derived( | |
| nodeData.current?.data.content != null | |
| ? ({ | |
| role: 'assistant', | |
| content: String(nodeData.current?.data.content ?? ''), | |
| reasoning: String(nodeData.current?.data.reasoning ?? ''), | |
| timestamp: (nodeData.current?.data.timestamp as number) ?? 0 | |
| } as unknown as ChatMessage) | |
| : null | |
| ); | |
| let containerRef: HTMLDivElement | null = $state(null); | |
| let articleRef: HTMLElement | null = $state(null); | |
| let selectedText = $state<string | null>(null); | |
| let selectedTextPosition = $state<{ y: number } | null>(null); | |
| // $effect(() => { | |
| // document.addEventListener('selectionchange', handleTextSelectionChange); | |
| // return () => { | |
| // document.removeEventListener('selectionchange', handleTextSelectionChange); | |
| // }; | |
| // }); | |
| function handleTextSelectionChange(e: Event) { | |
| const selection = document.getSelection(); | |
| const text = selection?.toString(); | |
| if (!text || text.trim() === '') { | |
| selectedText = null; | |
| return; | |
| } | |
| if (containerRef?.contains(selection?.anchorNode as unknown as HTMLElement)) { | |
| selectedText = text; | |
| let rect = selection?.getRangeAt(0).getBoundingClientRect(); | |
| if (rect) { | |
| selectedTextPosition = { | |
| y: rect.top - containerRef.getBoundingClientRect().top | |
| }; | |
| } else { | |
| selectedTextPosition = { | |
| y: | |
| (selection?.anchorNode?.parentElement?.getBoundingClientRect().top ?? 0) - | |
| containerRef.getBoundingClientRect().top | |
| }; | |
| } | |
| } else { | |
| selectedText = null; | |
| } | |
| } | |
| function handleAddFollowUpMessage(prompt: string) { | |
| document.getSelection()?.removeAllRanges(); | |
| const newNodes: Node[] = []; | |
| const newEdges: Edge[] = []; | |
| const newNodeId = `user-follow-up-${crypto.randomUUID()}`; | |
| const newNode: Node = { | |
| id: newNodeId, | |
| type: 'user-follow-up', | |
| position: { | |
| x: 0, | |
| y: 0 | |
| }, | |
| data: { | |
| role: 'user', | |
| prompt, | |
| selectedModels: [selectedModel], | |
| messages: [...messages, message] | |
| } | |
| }; | |
| const newEdge: Edge = { | |
| id: `edge-${crypto.randomUUID()} | |
| { | |
| if (!articleRef) return; | |
| const observer = new ResizeObserver(() => { | |
| updateNodeInternals(id); | |
| clearTimeout(fitViewTimeout); | |
| fitViewTimeout = setTimeout(() => { | |
| fitView({ duration: 300 }); | |
| }, 150); | |
| }); | |
| observer.observe(articleRef); | |
| return () => { | |
| observer.disconnect(); | |
| clearTimeout(fitViewTimeout); | |
| }; | |
| }); | |
| </script> | |
| <article | |
| bind:this={articleRef} | |
| class="group/assistant relative flex w-[calc(100dvw-2rem)] flex-col rounded-3xl border border-border bg-background p-5 shadow-lg/5 lg:w-[600px]" | |
| > | |
| <div class="nodrag pointer-events-auto flex flex-1 cursor-auto flex-col justify-between"> | |
| <div> | |
| <header class="mb-3 flex items-center justify-between"> | |
| <div class="flex flex-wrap items-center gap-1"> | |
| <ListModels selectedModels={[selectedModel]} showSelector={false} /> | |
| </div> | |
| </header> | |
| {#if loading} | |
| <Spinner className="text-sm text-muted-foreground/70">Reaching out...</Spinner> | |
| {/if} | |
| {#if message} | |
| <div bind:this={containerRef}> | |
| <Message {message} /> | |
| </div> | |
| {/if} | |
| </div> | |
| {#if usage && !loading && message} | |
| {@const modelSettings = modelsState.models.find((m) => m.id === selectedModel)} | |
| {@const provider = modelSettings?.provider ?? 'auto'} | |
| <div class="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3"> | |
| <p class="text-xs text-muted-foreground"> | |
| {usage.total_tokens} tokens | |
| {#if formatUsageCost(modelSettings?.providers.find((p) => p.provider === provider)?.pricing, usage)} | |
| <span class="mx-0.5">·</span> | |
| {formatUsageCost( | |
| modelSettings?.providers.find((p) => p.provider === provider)?.pricing, | |
| usage | |
| )} | |
| {/if} | |
| {#if message.timestamp} | |
| <span class="mx-0.5">·</span> Latency {message.timestamp}ms | |
| {/if} | |
| </p> | |
| <p class="text-xs text-muted-foreground"> | |
| <span class="inline-flex items-center gap-0.5"> | |
| Using | |
| <span class="inline-flex items-center gap-1 rounded-full bg-muted py-0.5 pr-2 pl-1"> | |
| {#if PROVIDER_SELECTION_MODES.find((m) => m.value === provider)} | |
| {@const mode = PROVIDER_SELECTION_MODES.find((m) => m.value === provider)!} | |
| <span | |
| class="inline-flex size-4 items-center justify-center rounded-full {mode.class}" | |
| > | |
| <mode.icon class="size-2.5 {mode.iconClass}" /> | |
| </span> | |
| {:else} | |
| <img | |
| src={`https://huggingface.co/api/avatars/${provider}`} | |
| alt={provider} | |
| class="size-4 rounded-full" | |
| /> | |
| {/if} | |
| {provider} | |
| </span> | |
| provider | |
| </span> | |
| </p> | |
| </div> | |
| {/if} | |
| </div> | |
| <Button | |
| variant={mode.current === 'dark' ? 'default' : 'outline'} | |
| size="icon-lg" | |
| class="absolute top-0 right-0 z-50 translate-x-1/2 {selectedText | |
| ? 'opacity-100' | |
| : 'pointer-events-none opacity-0'}" | |
| style="top: {(selectedTextPosition?.y ?? 0) + 50}px;" | |
| onclick={() => selectedText && handleAddFollowUpMessage(selectedText)} | |
| > | |
| <MessageCirclePlus class="size-5" /> | |
| </Button> | |
| </article> | |
| <Handle type="target" id="t-top" class="opacity-0" position={Position.Top} /> | |
| <Handle type="target" id="t-left" class="opacity-0" position={Position.Left} /> | |
| <Handle type="target" id="t-right" class="opacity-0" position={Position.Right} /> | |
| <Handle type="source" id="s-bottom" class="opacity-0" position={Position.Bottom} /> | |
| <Handle type="source" id="s-left" class="opacity-0" position={Position.Left} /> | |
| <Handle type="source" id="s-right" class="opacity-0" position={Position.Right} /> | |