Spaces:
Running
Running
| <script lang="ts"> | |
| import { 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'; | |
| 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 prompt = $state.raw<string>((nodeData.current?.data.prompt as string) ?? ''); | |
| let loading = $state.raw<boolean>(false); | |
| let errorMessage = $state.raw<Set<string>>(new Set()); | |
| 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 } | |
| ); | |
| } | |
| 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, | |
| selectedModels: models | |
| }, | |
| { 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, | |
| 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 | |
| }; | |
| updateNodes((currentNodes) => [...currentNodes, newNode]); | |
| updateEdges((currentEdges) => [...currentEdges, newEdge]); | |
| } | |
| 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-background p-5 shadow-lg/5 lg:w-[600px]" | |
| > | |
| <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 !lastMessage && !loading} | |
| <ComboBoxModels onSelect={toggleModel} excludeIds={selectedModels} /> | |
| {/if} | |
| </div> | |
| </header> | |
| <ErrorMessage bind:error={errorMessage} /> | |
| {#if lastMessage} | |
| <Message nodeId={id} message={lastMessage} onEdit={handleEditMessage} /> | |
| {:else} | |
| <footer class="flex flex-col items-end transition-all duration-300"> | |
| <textarea | |
| 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} | |
| 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 isFirstNode && !loading && !lastMessage} | |
| <div class="items flex w-full gap-1"> | |
| {#each randomSuggestions as 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} | |
| </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} | |
| </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" /> | |