kepler-chat/src/routes/chat/[id]/message.svelte
2025-06-19 12:21:35 -05:00

238 lines
6.6 KiB
Svelte

<script lang="ts">
import { cn } from '$lib/utils/utils';
import { tv } from 'tailwind-variants';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { CopyButton } from '$lib/components/ui/copy-button';
import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte';
import { ImageModal } from '$lib/components/ui/image-modal';
import { sanitizeHtml } from '$lib/utils/markdown-it';
import { on } from 'svelte/events';
import { isHtmlElement } from '$lib/utils/is';
import { Button } from '$lib/components/ui/button';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { useConvexClient } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte';
import { ResultAsync } from 'neverthrow';
import { goto } from '$app/navigation';
import { callGenerateMessage } from '../../api/generate-message/call';
import * as Icons from '$lib/components/icons';
const style = tv({
base: 'prose rounded-xl p-2 max-w-full',
variants: {
role: {
user: 'bg-secondary/50 border border-secondary/70 px-3 py-2 !text-black/80 dark:!text-primary-foreground self-end',
assistant: 'text-foreground',
},
},
});
type Props = {
message: Doc<'messages'>;
};
const client = useConvexClient();
let { message }: Props = $props();
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
open: false,
imageUrl: '',
fileName: '',
});
function openImageModal(imageUrl: string, fileName: string) {
imageModal = {
open: true,
imageUrl,
fileName,
};
}
async function createBranchedConversation() {
const res = await ResultAsync.fromPromise(
client.mutation(api.conversations.createBranched, {
conversation_id: message.conversation_id as Id<'conversations'>,
from_message_id: message._id,
session_token: session.current?.session.token ?? '',
}),
(e) => e
);
if (res.isErr()) {
console.error(res.error);
return;
}
await goto(`/chat/${res.value}`);
}
async function branchAndGenerate() {
const res = await ResultAsync.fromPromise(
client.mutation(api.conversations.createBranched, {
conversation_id: message.conversation_id as Id<'conversations'>,
from_message_id: message._id,
session_token: session.current?.session.token ?? '',
}),
(e) => e
);
if (res.isErr()) {
console.error(res.error);
return;
}
const cid = res.value;
const generateRes = await callGenerateMessage({
session_token: session.current?.session.token ?? '',
conversation_id: cid,
model_id: message.model_id!,
images: message.images,
web_search_enabled: message.web_search_enabled,
});
if (generateRes.isErr()) {
// TODO: add error toast
return;
}
await goto(`/chat/${cid}`);
}
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
<div
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
{@attach (node) => {
return on(node, 'click', (e) => {
const el = e.target as HTMLElement;
const closestCopyButton = el.closest('.copy[data-code]');
if (!isHtmlElement(closestCopyButton)) return;
const code = closestCopyButton.dataset.code;
if (!code) return;
navigator.clipboard.writeText(code);
closestCopyButton.classList.add('copied');
setTimeout(() => closestCopyButton.classList.remove('copied'), 3000);
});
}}
>
{#if message.images && message.images.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each message.images as image (image.storage_id)}
<button
type="button"
onclick={() => openImageModal(image.url, image.fileName || 'image')}
class="rounded-lg"
>
<img
src={image.url}
alt={image.fileName || 'Uploaded'}
class="max-w-xs rounded-lg transition-opacity hover:opacity-80"
/>
</button>
{/each}
</div>
{/if}
<div class={style({ role: message.role })}>
{#if message.error}
<div class="text-destructive">
<pre class="!bg-sidebar"><code>{message.error}</code></pre>
</div>
{:else if message.content_html}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitizeHtml(message.content_html)}
{:else}
<svelte:boundary>
<MarkdownRenderer content={message.content} />
{#snippet failed(error)}
<div class="text-destructive">
<span>Error rendering markdown:</span>
<pre class="!bg-sidebar"><code
>{error instanceof Error ? error.message : String(error)}</code
></pre>
</div>
{/snippet}
</svelte:boundary>
{/if}
</div>
<div
class={cn(
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
{
'justify-end': message.role === 'user',
}
)}
>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="icon"
variant="ghost"
class={cn('group order-2 size-7', { 'order-1': message.role === 'user' })}
onClickPromise={createBranchedConversation}
{...tooltip.trigger}
>
<Icons.Branch class="group-data-[loading=true]:opacity-0" />
</Button>
{/snippet}
Branch off this message
</Tooltip>
{#if message.role === 'user'}
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="icon"
variant="ghost"
class={cn('group order-0 size-7')}
onClickPromise={branchAndGenerate}
{...tooltip.trigger}
>
<Icons.BranchAndRegen class="group-data-[loading=true]:opacity-0" />
</Button>
{/snippet}
Branch and regenerate
</Tooltip>
{/if}
{#if message.content.length > 0}
<Tooltip>
{#snippet trigger(tooltip)}
<CopyButton
class={cn('order-1 size-7', { 'order-2': message.role === 'user' })}
text={message.content}
{...tooltip.trigger}
/>
{/snippet}
Copy
</Tooltip>
{/if}
{#if message.role === 'assistant'}
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}
{#if message.web_search_enabled}
<span class="text-muted-foreground text-xs"> Web search enabled </span>
{/if}
{#if message.cost_usd !== undefined}
<span class="text-muted-foreground text-xs">
${message.cost_usd.toFixed(6)}
</span>
{/if}
{/if}
</div>
</div>
{#if message.images && message.images.length > 0}
<ImageModal
bind:open={imageModal.open}
imageUrl={imageModal.imageUrl}
fileName={imageModal.fileName}
/>
{/if}
{/if}