Spaces:
Running
Running
| <script lang="ts"> | |
| import type { Message } from "$lib/types/Message"; | |
| import { tick } from "svelte"; | |
| import { usePublicConfig } from "$lib/utils/PublicConfig.svelte"; | |
| const publicConfig = usePublicConfig(); | |
| 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 CarbonPen from "~icons/carbon/pen"; | |
| import UploadedFile from "./UploadedFile.svelte"; | |
| import MarkdownRenderer from "./MarkdownRenderer.svelte"; | |
| import OpenReasoningResults from "./OpenReasoningResults.svelte"; | |
| import Alternatives from "./Alternatives.svelte"; | |
| import MessageAvatar from "./MessageAvatar.svelte"; | |
| import { PROVIDERS_HUB_ORGS } from "@huggingface/inference"; | |
| import { requireAuthUser } from "$lib/utils/auth"; | |
| interface Props { | |
| message: Message; | |
| loading?: boolean; | |
| isAuthor?: boolean; | |
| readOnly?: boolean; | |
| isTapped?: boolean; | |
| alternatives?: Message["id"][]; | |
| editMsdgId?: Message["id"] | null; | |
| isLast?: boolean; | |
| onretry?: (payload: { id: Message["id"]; content?: string }) => void; | |
| onshowAlternateMsg?: (payload: { id: Message["id"] }) => void; | |
| } | |
| let { | |
| message, | |
| loading = false, | |
| isAuthor: _isAuthor = true, | |
| readOnly: _readOnly = false, | |
| isTapped = $bindable(false), | |
| alternatives = [], | |
| editMsdgId = $bindable(null), | |
| isLast = false, | |
| onretry, | |
| onshowAlternateMsg, | |
| }: Props = $props(); | |
| let contentEl: HTMLElement | undefined = $state(); | |
| let isCopied = $state(false); | |
| let messageWidth: number = $state(0); | |
| let messageInfoWidth: number = $state(0); | |
| $effect(() => { | |
| // referenced to appease linter for currently-unused props | |
| void _isAuthor; | |
| void _readOnly; | |
| }); | |
| function handleKeyDown(e: KeyboardEvent) { | |
| if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { | |
| editFormEl?.requestSubmit(); | |
| } | |
| if (e.key === "Escape") { | |
| editMsdgId = null; | |
| } | |
| } | |
| let editContentEl: HTMLTextAreaElement | undefined = $state(); | |
| let editFormEl: HTMLFormElement | undefined = $state(); | |
| // Zero-config reasoning autodetection: detect <think> blocks in content | |
| const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/gi; | |
| let hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1); | |
| // Strip think blocks for clipboard copy (always, regardless of detection) | |
| let contentWithoutThink = $derived.by(() => | |
| message.content.replace(THINK_BLOCK_REGEX, "").trim() | |
| ); | |
| $effect(() => { | |
| if (isCopied) { | |
| setTimeout(() => { | |
| isCopied = false; | |
| }, 1000); | |
| } | |
| }); | |
| let editMode = $derived(editMsdgId === message.id); | |
| $effect(() => { | |
| if (editMode) { | |
| tick(); | |
| if (editContentEl) { | |
| editContentEl.value = message.content; | |
| editContentEl?.focus(); | |
| } | |
| } | |
| }); | |
| </script> | |
| {#if message.from === "assistant"} | |
| <div | |
| bind:offsetWidth={messageWidth} | |
| class="group relative -mb-4 flex w-fit max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata && | |
| messageInfoWidth >= messageWidth | |
| ? 'mb-1' | |
| : ''}" | |
| data-message-id={message.id} | |
| data-message-role="assistant" | |
| role="presentation" | |
| onclick={() => (isTapped = !isTapped)} | |
| onkeydown={() => (isTapped = !isTapped)} | |
| > | |
| <MessageAvatar | |
| classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden" | |
| animating={isLast && loading} | |
| /> | |
| <div | |
| class="relative flex min-w-[60px] flex-col gap-2 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/80 dark:text-gray-300" | |
| > | |
| {#if message.files?.length} | |
| <div class="flex h-fit flex-wrap gap-x-5 gap-y-2"> | |
| {#each message.files as file (file.value)} | |
| <UploadedFile {file} canClose={false} /> | |
| {/each} | |
| </div> | |
| {/if} | |
| <div bind:this={contentEl}> | |
| {#if isLast && loading && message.content.length === 0} | |
| <IconLoading classNames="loading inline ml-2 first:ml-0" /> | |
| {/if} | |
| {#if hasClientThink} | |
| {#each message.content.split(THINK_BLOCK_REGEX) as part, _i} | |
| {#if part && part.startsWith("<think>")} | |
| {@const isClosed = part.endsWith("</think>")} | |
| {@const thinkContent = part.slice(7, isClosed ? -8 : undefined)} | |
| {@const isInterrupted = !isClosed && !loading} | |
| {@const summary = | |
| isClosed || isInterrupted | |
| ? thinkContent.trim().split(/\n+/)[0] || "Reasoning" | |
| : "Thinking..."} | |
| <OpenReasoningResults | |
| {summary} | |
| content={thinkContent} | |
| loading={isLast && loading && !isClosed} | |
| /> | |
| {:else if part && part.trim().length > 0} | |
| <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" | |
| > | |
| <MarkdownRenderer content={part} loading={isLast && loading} /> | |
| </div> | |
| {/if} | |
| {/each} | |
| {:else} | |
| <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" | |
| > | |
| <MarkdownRenderer content={message.content} loading={isLast && loading} /> | |
| </div> | |
| {/if} | |
| </div> | |
| </div> | |
| {#if message.routerMetadata || (!loading && message.content)} | |
| <div | |
| class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth | |
| ? 'left-1 pl-1 lg:pl-7' | |
| : 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5" | |
| bind:offsetWidth={messageInfoWidth} | |
| > | |
| {#if message.routerMetadata && (message.routerMetadata.route || message.routerMetadata.model || message.routerMetadata.provider) && (!isLast || !loading)} | |
| <div | |
| class="mr-2 flex items-center gap-1.5 truncate whitespace-nowrap text-[.65rem] text-gray-400 dark:text-gray-400 sm:text-xs" | |
| > | |
| {#if message.routerMetadata.route && message.routerMetadata.model} | |
| <span class="truncate rounded bg-gray-100 px-1 font-mono dark:bg-gray-800 sm:py-px"> | |
| {message.routerMetadata.route} | |
| </span> | |
| <span class="text-gray-500">with</span> | |
| {#if publicConfig.isHuggingChat} | |
| <a | |
| href="/chat/settings/{message.routerMetadata.model}" | |
| class="flex items-center gap-1 truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 sm:py-px" | |
| > | |
| {message.routerMetadata.model.split("/").pop()} | |
| </a> | |
| {:else} | |
| <span | |
| class="truncate rounded bg-gray-100 px-1.5 font-mono dark:bg-gray-800 sm:py-px" | |
| > | |
| {message.routerMetadata.model.split("/").pop()} | |
| </span> | |
| {/if} | |
| {/if} | |
| {#if message.routerMetadata.provider} | |
| { hubOrg = PROVIDERS_HUB_ORGS[message.routerMetadata.provider]} | |
| <span class="text-gray-500 max-sm:hidden">via</span> | |
| <a | |
| target="_blank" | |
| href="https://huggingface.co/{hubOrg}" | |
| class="flex items-center gap-1 truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 max-sm:hidden sm:py-px" | |
| > | |
| <img | |
| src="https://huggingface.co/api/organizations/{hubOrg}/avatar" | |
| alt="{message.routerMetadata.provider} logo" | |
| class="size-2.5 flex-none rounded-sm" | |
| onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = "none")} | |
| /> | |
| {message.routerMetadata.provider} | |
| </a> | |
| {/if} | |
| </div> | |
| {/if} | |
| {#if !isLast || !loading} | |
| <CopyToClipBoardBtn | |
| onClick={() => { | |
| isCopied = true; | |
| }} | |
| classNames="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300" | |
| value={contentWithoutThink} | |
| iconClassNames="text-xs" | |
| /> | |
| <button | |
| class="btn rounded-sm p-1 text-xs text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300" | |
| title="Retry" | |
| type="button" | |
| onclick={() => { | |
| onretry?.({ id: message.id }); | |
| }} | |
| > | |
| <CarbonRotate360 /> | |
| </button> | |
| {#if alternatives.length > 1 && editMsdgId === null} | |
| <Alternatives | |
| {message} | |
| {alternatives} | |
| {loading} | |
| onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)} | |
| /> | |
| {/if} | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| {/if} | |
| {#if message.from === "user"} | |
| <div | |
| class="group relative {alternatives.length > 1 && editMsdgId === null | |
| ? 'mb-7' | |
| : ''} w-full items-start justify-start gap-4 max-sm:text-sm" | |
| data-message-id={message.id} | |
| data-message-type="user" | |
| role="presentation" | |
| onclick={() => (isTapped = !isTapped)} | |
| onkeydown={() => (isTapped = !isTapped)} | |
| > | |
| <div class="flex w-full flex-col gap-2"> | |
| {#if message.files?.length} | |
| <div class="flex w-fit gap-4 px-5"> | |
| {#each message.files as file} | |
| <UploadedFile {file} canClose={false} /> | |
| {/each} | |
| </div> | |
| {/if} | |
| <div class="flex w-full flex-row flex-nowrap"> | |
| {#if !editMode} | |
| <p | |
| class="disabled w-full appearance-none whitespace-break-spaces text-wrap break-words bg-inherit px-5 py-3.5 text-gray-500 dark:text-gray-400" | |
| > | |
| {message.content.trim()} | |
| </p> | |
| {:else} | |
| <form | |
| class="mt-3 flex w-full flex-col" | |
| bind:this={editFormEl} | |
| onsubmit={(e) => { | |
| e.preventDefault(); | |
| onretry?.({ content: editContentEl?.value, id: message.id }); | |
| editMsdgId = null; | |
| }} | |
| > | |
| <textarea | |
| class="w-full whitespace-break-spaces break-words rounded-xl bg-gray-100 px-5 py-3.5 text-gray-500 *:h-max focus:outline-none dark:bg-gray-800 dark:text-gray-400" | |
| rows="5" | |
| bind:this={editContentEl} | |
| value={message.content.trim()} | |
| onkeydown={handleKeyDown} | |
| required | |
| ></textarea> | |
| <div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2"> | |
| <button | |
| type="submit" | |
| class="btn rounded-lg px-3 py-1.5 text-sm | |
| {loading | |
| ? 'bg-gray-300 text-gray-400 dark:bg-gray-700 dark:text-gray-600' | |
| : 'bg-gray-200 text-gray-600 hover:text-gray-800 focus:ring-0 dark:bg-gray-800 dark:text-gray-300 dark:hover:text-gray-200'} | |
| " | |
| disabled={loading} | |
| > | |
| Send | |
| </button> | |
| <button | |
| type="button" | |
| class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300" | |
| onclick={() => { | |
| editMsdgId = null; | |
| }} | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </form> | |
| {/if} | |
| </div> | |
| <div class="absolute -bottom-4 ml-3.5 flex w-full gap-1.5"> | |
| {#if alternatives.length > 1 && editMsdgId === null} | |
| <Alternatives | |
| {message} | |
| {alternatives} | |
| {loading} | |
| onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)} | |
| /> | |
| {/if} | |
| {#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)} | |
| <button | |
| class="hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2" | |
| title="Edit" | |
| type="button" | |
| onclick={() => { | |
| if (requireAuthUser()) return; | |
| editMsdgId = message.id; | |
| }} | |
| > | |
| <CarbonPen /> | |
| Edit | |
| </button> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| <style> | |
| loading { | |
| to { | |
| stroke-dashoffset: 122.9; | |
| } | |
| } | |
| </style> | |