|
<script lang="ts"> |
|
import { marked } from "marked"; |
|
import markedKatex from "marked-katex-extension"; |
|
import type { Message } from "$lib/types/Message"; |
|
import { afterUpdate, createEventDispatcher } from "svelte"; |
|
import { deepestChild } from "$lib/utils/deepestChild"; |
|
import { page } from "$app/stores"; |
|
|
|
import CodeBlock from "../CodeBlock.svelte"; |
|
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte"; |
|
import IconLoading from "../icons/IconLoading.svelte"; |
|
import CarbonRotate360 from "~icons/carbon/rotate-360"; |
|
import CarbonDownload from "~icons/carbon/download"; |
|
import CarbonThumbsUp from "~icons/carbon/thumbs-up"; |
|
import CarbonThumbsDown from "~icons/carbon/thumbs-down"; |
|
import { PUBLIC_SEP_TOKEN } from "$lib/constants/publicSepToken"; |
|
import type { Model } from "$lib/types/Model"; |
|
|
|
import OpenWebSearchResults from "../OpenWebSearchResults.svelte"; |
|
import type { WebSearchUpdate } from "$lib/types/MessageUpdate"; |
|
|
|
function sanitizeMd(md: string) { |
|
let ret = md |
|
.replace(/<\|[a-z]*$/, "") |
|
.replace(/<\|[a-z]+\|$/, "") |
|
.replace(/<$/, "") |
|
.replaceAll(PUBLIC_SEP_TOKEN, " ") |
|
.replaceAll(/<\|[a-z]+\|>/g, " ") |
|
.replaceAll(/<br\s?\/?>/gi, "\n") |
|
.replaceAll("<", "<") |
|
.trim(); |
|
|
|
for (const stop of [...(model.parameters?.stop ?? []), "<|endoftext|>"]) { |
|
if (ret.endsWith(stop)) { |
|
ret = ret.slice(0, -stop.length).trim(); |
|
} |
|
} |
|
|
|
return ret; |
|
} |
|
function unsanitizeMd(md: string) { |
|
return md.replaceAll("<", "<"); |
|
} |
|
|
|
export let model: Model; |
|
export let message: Message; |
|
export let loading = false; |
|
export let isAuthor = true; |
|
export let readOnly = false; |
|
export let isTapped = false; |
|
|
|
export let webSearchMessages: WebSearchUpdate[]; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
retry: { content: string; id: Message["id"] }; |
|
vote: { score: Message["score"]; id: Message["id"] }; |
|
}>(); |
|
|
|
let contentEl: HTMLElement; |
|
let loadingEl: IconLoading; |
|
let pendingTimeout: ReturnType<typeof setTimeout>; |
|
let isCopied = false; |
|
|
|
const renderer = new marked.Renderer(); |
|
|
|
renderer.codespan = (code) => { |
|
// Unsanitize double-sanitized code |
|
return `<code>${code.replaceAll("&", "&")}</code>`; |
|
}; |
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars |
|
const { extensions, ...defaults } = marked.getDefaults() as marked.MarkedOptions & { |
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|
extensions: any; |
|
}; |
|
const options: marked.MarkedOptions = { |
|
...defaults, |
|
gfm: true, |
|
breaks: true, |
|
renderer, |
|
}; |
|
|
|
marked.use( |
|
markedKatex({ |
|
throwOnError: false, |
|
// output: "html", |
|
}) |
|
); |
|
|
|
$: tokens = marked.lexer(sanitizeMd(message.content)); |
|
|
|
afterUpdate(() => { |
|
loadingEl?.$destroy(); |
|
clearTimeout(pendingTimeout); |
|
|
|
// Add loading animation to the last message if update takes more than 600ms |
|
if (loading) { |
|
pendingTimeout = setTimeout(() => { |
|
if (contentEl) { |
|
loadingEl = new IconLoading({ |
|
target: deepestChild(contentEl), |
|
props: { classNames: "loading inline ml-2" }, |
|
}); |
|
} |
|
}, 600); |
|
} |
|
}); |
|
|
|
let searchUpdates: WebSearchUpdate[] = []; |
|
|
|
$: searchUpdates = ((webSearchMessages.length > 0 |
|
? webSearchMessages |
|
: message.updates?.filter(({ type }) => type === "webSearch")) ?? []) as WebSearchUpdate[]; |
|
|
|
$: downloadLink = |
|
message.from === "user" ? `${$page.url.pathname}/message/${message.id}/prompt` : undefined; |
|
|
|
let webSearchIsDone = true; |
|
|
|
$: webSearchIsDone = |
|
searchUpdates.length > 0 && searchUpdates[searchUpdates.length - 1].messageType === "sources"; |
|
|
|
$: webSearchSources = |
|
searchUpdates && |
|
searchUpdates?.filter(({ messageType }) => messageType === "sources")?.[0]?.sources; |
|
|
|
$: if (isCopied) { |
|
setTimeout(() => { |
|
isCopied = false; |
|
}, 1000); |
|
} |
|
</script> |
|
|
|
{#if message.from === "assistant"} |
|
<div |
|
class="group relative -mb-8 flex items-start justify-start gap-4 pb-8 leading-relaxed" |
|
role="presentation" |
|
on:click={() => (isTapped = !isTapped)} |
|
on:keypress={() => (isTapped = !isTapped)} |
|
> |
|
<img |
|
alt="" |
|
src="https://huggingface.co/avatars/2edb18bd0206c16b433841a47f53fa8e.svg" |
|
class="mt-5 h-3 w-3 flex-none select-none rounded-full shadow-lg" |
|
/> |
|
<div |
|
class="relative min-h-[calc(2rem+theme(spacing[3.5])*2)] min-w-[60px] break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/40 dark:text-gray-300" |
|
> |
|
{#if searchUpdates && searchUpdates.length > 0} |
|
<OpenWebSearchResults |
|
classNames={tokens.length ? "mb-3.5" : ""} |
|
webSearchMessages={searchUpdates} |
|
loading={!(searchUpdates[searchUpdates.length - 1]?.messageType === "sources")} |
|
/> |
|
{/if} |
|
{#if !message.content && (webSearchIsDone || (webSearchMessages && webSearchMessages.length === 0))} |
|
<IconLoading /> |
|
{/if} |
|
|
|
<div |
|
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900" |
|
bind:this={contentEl} |
|
> |
|
{#each tokens as token} |
|
{#if token.type === "code"} |
|
<CodeBlock lang={token.lang} code={unsanitizeMd(token.text)} /> |
|
{:else} |
|
<!-- eslint-disable-next-line svelte/no-at-html-tags --> |
|
{@html marked.parse(token.raw, options)} |
|
{/if} |
|
{/each} |
|
</div> |
|
<!-- Web Search sources --> |
|
{#if webSearchSources?.length} |
|
<div class="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm"> |
|
<div class="text-gray-400">Sources:</div> |
|
{#each webSearchSources as { link, title, hostname }} |
|
<a |
|
class="flex items-center gap-2 whitespace-nowrap rounded-lg border bg-white px-2 py-1.5 leading-none hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700" |
|
href={link} |
|
target="_blank" |
|
> |
|
<img |
|
class="h-3.5 w-3.5 rounded" |
|
src="https://www.google.com/s2/favicons?sz=64&domain_url={hostname}" |
|
alt="{title} favicon" |
|
/> |
|
<div>{hostname.replace(/^www\./, "")}</div> |
|
</a> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{#if isAuthor && !loading && message.content} |
|
<div |
|
class="absolute bottom-1 right-0 flex max-md:transition-all md:bottom-0 md:group-hover:visible md:group-hover:opacity-100 |
|
{message.score ? 'visible opacity-100' : 'invisible max-md:-translate-y-4 max-md:opacity-0'} |
|
{isTapped || isCopied ? 'max-md:visible max-md:translate-y-0 max-md:opacity-100' : ''} |
|
" |
|
> |
|
<button |
|
class="btn rounded-sm p-1 text-sm text-gray-400 focus:ring-0 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 |
|
{message.score && message.score > 0 |
|
? 'text-green-500 hover:text-green-500 dark:text-green-400 hover:dark:text-green-400' |
|
: ''}" |
|
title={message.score === 1 ? "Remove +1" : "+1"} |
|
type="button" |
|
on:click={() => dispatch("vote", { score: message.score === 1 ? 0 : 1, id: message.id })} |
|
> |
|
<CarbonThumbsUp class="h-[1.14em] w-[1.14em]" /> |
|
</button> |
|
<button |
|
class="btn rounded-sm p-1 text-sm text-gray-400 focus:ring-0 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 |
|
{message.score && message.score < 0 |
|
? 'text-red-500 hover:text-red-500 dark:text-red-400 hover:dark:text-red-400' |
|
: ''}" |
|
title={message.score === -1 ? "Remove -1" : "-1"} |
|
type="button" |
|
on:click={() => |
|
dispatch("vote", { score: message.score === -1 ? 0 : -1, id: message.id })} |
|
> |
|
<CarbonThumbsDown class="h-[1.14em] w-[1.14em]" /> |
|
</button> |
|
<CopyToClipBoardBtn |
|
on:click={() => { |
|
isCopied = true; |
|
}} |
|
classNames="ml-1.5 !rounded-sm !p-1 !text-sm !text-gray-400 focus:!ring-0 hover:!text-gray-500 dark:!text-gray-400 dark:hover:!text-gray-300 !border-none !shadow-none" |
|
value={message.content} |
|
/> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{#if message.from === "user"} |
|
<div class="group relative flex items-start justify-start gap-4 max-sm:text-sm"> |
|
<div class="flex flex-col"> |
|
{#if message.files && message.files.length > 0} |
|
<div class="mx-auto grid w-fit grid-cols-2 gap-5 px-5"> |
|
{#each message.files as file} |
|
|
|
{#if file.length === 64} |
|
<img |
|
src={$page.url.pathname + "/output/" + file} |
|
alt="input from user" |
|
class="my-2 aspect-auto max-h-48 rounded-lg shadow-lg" |
|
/> |
|
{:else} |
|
|
|
<img |
|
src={"data:image/*;base64," + file} |
|
alt="input from user" |
|
class="my-2 aspect-auto max-h-48 rounded-lg shadow-lg" |
|
/> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
<div |
|
class="max-w-full whitespace-break-spaces break-words rounded-2xl px-5 py-3.5 text-gray-500 dark:text-gray-400" |
|
> |
|
{message.content.trim()} |
|
</div> |
|
{#if !loading} |
|
<div class="absolute right-0 top-3.5 flex gap-2 lg:-right-2"> |
|
{#if downloadLink} |
|
<a |
|
class="rounded-lg border border-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 dark:border-gray-800 dark:text-gray-400 dark:hover:text-gray-300 md:hidden" |
|
title="Download prompt and parameters" |
|
type="button" |
|
target="_blank" |
|
href={downloadLink} |
|
> |
|
<CarbonDownload /> |
|
</a> |
|
{/if} |
|
{#if !readOnly} |
|
<button |
|
class="cursor-pointer rounded-lg border border-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 dark:border-gray-800 dark:text-gray-400 dark:hover:text-gray-300 md:hidden lg:-right-2" |
|
title="Retry" |
|
type="button" |
|
on:click={() => dispatch("retry", { content: message.content, id: message.id })} |
|
> |
|
<CarbonRotate360 /> |
|
</button> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
|