enzostvs's picture
enzostvs HF Staff
can update previous message
822d5b9
<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" />