enzostvs's picture
enzostvs HF Staff
dispaly persona
67dc589
<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" />