Spaces:
Sleeping
Sleeping
| <script lang="ts"> | |
| import { Image, Info, Paperclip, PenLine, Send, X } from '@lucide/svelte'; | |
| import { | |
| Handle, | |
| useEdges, | |
| useNodes, | |
| useNodesData, | |
| Position, | |
| type NodeProps, | |
| type Edge, | |
| type Node, | |
| useSvelteFlow | |
| } from '@xyflow/svelte'; | |
| import { onMount } from 'svelte'; | |
| import ErrorMessage from '$lib/components/error/Error.svelte'; | |
| import type { ChatMessage } from '$lib/helpers/types'; | |
| import { Button } from '$lib/components/ui/button'; | |
| import ComboBoxModels from '$lib/components/model/ComboBoxModels.svelte'; | |
| import Spinner from '$lib/components/loading/Spinner.svelte'; | |
| import Message from './Message.svelte'; | |
| import { MAX_SUGGESTIONS } from '$lib'; | |
| import { triggerAiCall } from '$lib/chat'; | |
| import { SUGGESTIONS_PROMPT } from '$lib/consts'; | |
| import { authState } from '$lib/state/auth.svelte'; | |
| import { signinModalState } from '$lib/state/signin-modal.svelte'; | |
| import Welcome from './Welcome.svelte'; | |
| import ListModels from '$lib/components/model/ListModels.svelte'; | |
| import { breakpointsState } from '$lib/state/breakpoints.svelte'; | |
| import { autosize } from '$lib/actions/autosize'; | |
| import { modelsState } from '$lib/state/models.svelte'; | |
| import { personasState } from '$lib/state/personas.svelte'; | |
| let { id }: NodeProps = $props(); | |
| // svelte-ignore state_referenced_locally | |
| const nodeData = useNodesData(id); | |
| const { update: updateNodes } = useNodes(); | |
| const { update: updateEdges } = useEdges(); | |
| const { updateNodeData, deleteElements } = useSvelteFlow(); | |
| let selectedModels = $derived<string[]>( | |
| (nodeData.current?.data.selectedModels as string[]) ?? [] | |
| ); | |
| let messages = $derived((nodeData.current?.data.messages as ChatMessage[]) ?? []); | |
| let isFirstNode = $derived((nodeData.current?.data.isFirstNode as boolean) ?? false); | |
| let showWelcome = $derived((nodeData.current?.data.showWelcome as boolean) ?? true); | |
| let isParentNode = $derived((nodeData.current?.data.isParentNode as boolean) ?? false); | |
| let isFromEdit = $derived((nodeData.current?.data.isFromEdit as boolean) ?? false); | |
| let imageData = $derived((nodeData.current?.data.imageData as string) ?? null); | |
| let imageInput = $state.raw<HTMLInputElement | null>(null); | |
| let hasModelWithImageSupport = $derived( | |
| selectedModels.some( | |
| (m) => | |
| modelsState.models | |
| .find((model) => model.id === m) | |
| ?.architecture?.input_modalities?.includes('image') ?? false | |
| ) | |
| ); | |
| let modelsWithoutImageSupport = $derived( | |
| selectedModels.filter( | |
| (m) => | |
| !modelsState.models | |
| .find((model) => model.id === m) | |
| ?.architecture?.input_modalities?.includes('image') | |
| ) | |
| ); | |
| let prompt = $state.raw<string>((nodeData.current?.data.prompt as string) ?? ''); | |
| let loading = $state.raw<boolean>(false); | |
| let errorMessage = $state.raw<Set<string>>(new Set()); | |
| let isDragOver = $state.raw<boolean>(false); | |
| const randomSuggestions = SUGGESTIONS_PROMPT.sort(() => Math.random() - 0.5).slice( | |
| 0, | |
| breakpointsState.isMobile ? 1 : MAX_SUGGESTIONS | |
| ); | |
| function toggleModel(modelId: string) { | |
| updateNodeData( | |
| id, | |
| { | |
| ...nodeData.current?.data, | |
| selectedModels: selectedModels.includes(modelId) | |
| ? selectedModels.filter((m) => m !== modelId) | |
| : [...selectedModels, modelId] | |
| }, | |
| { replace: true } | |
| ); | |
| if (lastMessage) { | |
| handleTriggerAction([modelId]); | |
| } | |
| } | |
| function handleTriggerAction(models: string[] = selectedModels) { | |
| if (!authState.user) { | |
| signinModalState.open = true; | |
| return; | |
| } | |
| errorMessage = new Set(); | |
| const newNodes: Node[] = []; | |
| const newEdges: Edge[] = []; | |
| const newMessages = [...messages, { role: 'user', content: prompt }] as ChatMessage[]; | |
| updateNodeData( | |
| id, | |
| { | |
| ...nodeData.current?.data, | |
| messages: newMessages | |
| }, | |
| { replace: true } | |
| ); | |
| models.forEach((m) => { | |
| const newNodeId = `assistant-${crypto.randomUUID()}`; | |
| const newNode: Node = { | |
| id: newNodeId, | |
| type: 'assistant', | |
| position: { | |
| x: 0, | |
| y: 0 | |
| }, | |
| data: { | |
| role: 'assistant', | |
| selectedModel: m, | |
| content: '', | |
| loading: true, | |
| persona: personasState.selectedPersona, | |
| messages: newMessages | |
| } | |
| }; | |
| const newEdge: Edge = { | |
| id: `edge-${crypto.randomUUID()}`, | |
| source: id, | |
| target: newNodeId | |
| }; | |
| newNodes.push(newNode); | |
| newEdges.push(newEdge); | |
| }); | |
| updateNodes((currentNodes) => [...currentNodes, ...newNodes]); | |
| updateEdges((currentEdges) => [...currentEdges, ...newEdges]); | |
| triggerAiCall({ | |
| userId: id, | |
| newNodes, | |
| messages: newMessages, | |
| selectedModels: models, | |
| prompt, | |
| nodeData: nodeData.current?.data as Record<string, unknown>, | |
| authToken: authState.token ?? '', | |
| billingOption: authState.user?.billingOption ?? 'personal', | |
| updateNodeData: (nodeId, data, opts) => | |
| updateNodeData(nodeId, data, { replace: opts?.replace ?? true }), | |
| updateNodes, | |
| updateEdges, | |
| onLoadingChange: (v) => (loading = v), | |
| onError: (msg) => { | |
| errorMessage = new Set([...errorMessage, msg]); | |
| } | |
| }); | |
| } | |
| let lastMessage = $derived( | |
| messages?.length > 0 && messages[messages.length - 1].role === 'user' | |
| ? messages[messages.length - 1] | |
| : null | |
| ); | |
| function handlePromptInput(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) { | |
| const value = (e.target as HTMLTextAreaElement).value; | |
| if (isFirstNode) { | |
| if (value.trim() === '') { | |
| updateNodeData(id, { ...nodeData.current?.data, showWelcome: true }, { replace: true }); | |
| } else { | |
| updateNodeData(id, { ...nodeData.current?.data, showWelcome: false }, { replace: true }); | |
| } | |
| } | |
| } | |
| function handleDeleteNode() { | |
| deleteElements({ nodes: [{ id: id }] }); | |
| } | |
| function handleEditMessage(newContent: string) { | |
| const newNodeId = `user-${crypto.randomUUID()}`; | |
| const prevMessages = messages.slice(0, -1); | |
| const newNode: Node = { | |
| id: newNodeId, | |
| type: 'user', | |
| position: { x: 0, y: 0 }, | |
| data: { | |
| role: 'user', | |
| selectedModels, | |
| messages: prevMessages, | |
| isFirstNode: false, | |
| isFromEdit: true, | |
| showWelcome: false, | |
| isParentNode: true, | |
| prompt: newContent | |
| } | |
| }; | |
| const newEdge: Edge = { | |
| id: `edge-${crypto.randomUUID()}`, | |
| source: id, | |
| target: newNodeId, | |
| sourceHandle: 's-right', | |
| targetHandle: 't-left' | |
| }; | |
| updateNodes((currentNodes) => [...currentNodes, newNode]); | |
| updateEdges((currentEdges) => [...currentEdges, newEdge]); | |
| } | |
| function handlePasteImage(e: ClipboardEvent) { | |
| const items = e.clipboardData?.items; | |
| for (const item of items ?? []) { | |
| if (item.type.indexOf('image') !== -1 && hasModelWithImageSupport) { | |
| e.preventDefault(); | |
| const file = item.getAsFile(); | |
| const blob = new Blob([file as BlobPart], { type: file?.type ?? '' }); | |
| handleTransformAndSet(blob); | |
| } | |
| } | |
| } | |
| function handleDropImage(e: DragEvent) { | |
| if (lastMessage || !hasModelWithImageSupport) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| isDragOver = false; | |
| const files = e.dataTransfer?.files; | |
| for (const file of files ?? []) { | |
| if (file.type.indexOf('image') !== -1 && hasModelWithImageSupport) { | |
| e.preventDefault(); | |
| handleTransformAndSet(file); | |
| } | |
| } | |
| } | |
| function handleUploadImage(e: Event & { currentTarget: EventTarget & HTMLInputElement }) { | |
| const file = (e.target as HTMLInputElement).files?.[0]; | |
| if (file) { | |
| handleTransformAndSet(file); | |
| } | |
| } | |
| function handleTransformAndSet(file: Blob) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const imgBase64 = e.target?.result as string; | |
| updateNodeData(id, { ...nodeData.current?.data, imageData: imgBase64 }, { replace: true }); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| onMount(() => { | |
| if (prompt.trim() !== '' && prompt) { | |
| handleTriggerAction(); | |
| prompt = ''; | |
| } | |
| }); | |
| </script> | |
| <article | |
| class="group/user relative z-10 w-[calc(100dvw-2rem)] rounded-3xl border border-border bg-card p-5 lg:w-[600px]" | |
| ondragover={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (lastMessage) return; | |
| isDragOver = true; | |
| }} | |
| ondragleave={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (lastMessage) return; | |
| isDragOver = false; | |
| }} | |
| ondrop={handleDropImage} | |
| > | |
| <div class="nodrag pointer-events-auto cursor-auto"> | |
| {#if isFromEdit} | |
| <span | |
| class="mb-2 inline-flex items-center justify-center gap-1 rounded-md bg-accent px-2 py-1 text-[11px] text-muted-foreground" | |
| > | |
| <PenLine class="size-2.5" /> | |
| Edited message | |
| </span> | |
| {/if} | |
| <header class="mb-3 flex items-center justify-between"> | |
| <div class="flex flex-wrap items-center gap-1"> | |
| <ListModels {selectedModels} showSelector={!lastMessage} onToggleModel={toggleModel} /> | |
| {#if selectedModels.length <= 6} | |
| <ComboBoxModels onSelect={toggleModel} excludeIds={selectedModels} /> | |
| {/if} | |
| </div> | |
| </header> | |
| <ErrorMessage bind:error={errorMessage} /> | |
| {#if imageData} | |
| <div class="mb-2.5 flex flex-wrap gap-1"> | |
| <div class="group relative h-14 w-20"> | |
| <img | |
| src={imageData} | |
| alt="Pasted file" | |
| class="h-full w-full rounded-xl border-2 border-background object-cover ring ring-border" | |
| /> | |
| {#if !lastMessage} | |
| <Button | |
| variant="default" | |
| size="icon-3xs" | |
| class="!shadow-none! absolute -top-1 -right-1 rounded-full! opacity-0 transition-opacity duration-300 group-hover:opacity-100" | |
| onclick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| updateNodeData( | |
| id, | |
| { ...nodeData.current?.data, imageData: null }, | |
| { replace: true } | |
| ); | |
| }} | |
| > | |
| <X class="size-3" /> | |
| </Button> | |
| {/if} | |
| </div> | |
| </div> | |
| {#if modelsWithoutImageSupport?.length > 0} | |
| <div | |
| class="mb-1 max-w-max -translate-y-1 rounded bg-accent px-2 py-1 text-xs text-muted-foreground" | |
| > | |
| <Info class="mr-0.5 inline-block size-3 -translate-y-px" /> | |
| <span> | |
| Some models do not support image input, it will be ignored. | |
| {#each modelsWithoutImageSupport as model (model)} | |
| <span class="text-[10px] italic">({model})</span> | |
| {/each} | |
| </span> | |
| </div> | |
| {/if} | |
| {/if} | |
| {#if lastMessage} | |
| <Message message={lastMessage} onEdit={handleEditMessage} /> | |
| {:else} | |
| <footer class="flex flex-col items-end transition-all duration-300"> | |
| <textarea | |
| use:autosize={{ minRows: 1, maxRows: 8, value: prompt }} | |
| name="message" | |
| id="message" | |
| placeholder="Ask me anything..." | |
| disabled={loading} | |
| class="w-full resize-none border-none bg-transparent text-base text-accent-foreground outline-none" | |
| bind:value={prompt} | |
| oninput={handlePromptInput} | |
| onpaste={handlePasteImage} | |
| onkeydown={(e: KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| prompt = prompt.trim(); | |
| if (prompt) { | |
| handleTriggerAction(); | |
| } | |
| } | |
| }} | |
| ></textarea> | |
| <div class="flex w-full items-end justify-between gap-1"> | |
| {#if !loading && !lastMessage} | |
| <div class="items flex w-full gap-1"> | |
| {#if isFirstNode && !prompt} | |
| {#each randomSuggestions as suggestion (suggestion)} | |
| <Button | |
| variant="outline" | |
| size="2xs" | |
| class="rounded-full! shadow-none!" | |
| disabled={!selectedModels.length} | |
| onclick={() => { | |
| updateNodeData( | |
| id, | |
| { ...nodeData.current?.data, showWelcome: false }, | |
| { replace: true } | |
| ); | |
| prompt = suggestion; | |
| handleTriggerAction(); | |
| }} | |
| > | |
| {suggestion} | |
| </Button> | |
| {/each} | |
| {:else} | |
| <Button | |
| variant="outline" | |
| size="icon-xs" | |
| class="rounded-full! shadow-none!" | |
| onclick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| imageInput?.click(); | |
| }} | |
| > | |
| <Paperclip class="size-3" /> | |
| </Button> | |
| <input | |
| bind:this={imageInput} | |
| type="file" | |
| hidden | |
| accept="image/*" | |
| onchange={handleUploadImage} | |
| /> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div></div> | |
| {/if} | |
| <Button | |
| variant={!selectedModels.length || !prompt ? 'outline' : 'default'} | |
| size="icon-sm" | |
| class="" | |
| disabled={!selectedModels.length || !prompt || loading} | |
| onclick={() => handleTriggerAction()} | |
| > | |
| {#if loading} | |
| <Spinner className="size-5" /> | |
| {:else} | |
| <Send /> | |
| {/if} | |
| </Button> | |
| </div> | |
| </footer> | |
| {/if} | |
| {#if !lastMessage} | |
| <div | |
| class="border-accent-500/40 pointer-events-none absolute inset-0 flex h-full w-full flex-col items-center justify-center gap-2 rounded-3xl border-3 border-dashed bg-accent/60 p-4 text-muted-foreground transition-all duration-200 {isDragOver | |
| ? '' | |
| : 'opacity-0'}" | |
| > | |
| <Image class="w-6" /> | |
| <p class="text-xs">Drag and drop an image here</p> | |
| </div> | |
| {/if} | |
| </div> | |
| {#if isFirstNode} | |
| <Welcome bind:showWelcome /> | |
| {:else if isParentNode} | |
| <Button | |
| size="icon-lg" | |
| variant="outline" | |
| class="absolute -top-2 -right-2 opacity-0 transition-opacity duration-300 group-hover/user:opacity-100" | |
| onclick={handleDeleteNode} | |
| > | |
| <X /> | |
| </Button> | |
| {/if} | |
| </article> | |
| <Handle type="target" id="t-top" position={Position.Top} class="opacity-0" /> | |
| <Handle type="target" id="t-left" position={Position.Left} class="opacity-0" /> | |
| <Handle type="target" id="t-right" position={Position.Right} class="opacity-0" /> | |
| <Handle type="source" id="s-bottom" position={Position.Bottom} class="opacity-0" /> | |
| <Handle type="source" id="s-left" position={Position.Left} class="opacity-0" /> | |
| <Handle type="source" id="s-right" position={Position.Right} class="opacity-0" /> | |