Mike0021's picture
Use upstream chat-ui frontend
016ed07 verified
Raw
History Blame Contribute Delete
35.3 kB
<script lang="ts">
import type { Message, MessageFile } from "$lib/types/Message";
import { onDestroy, tick } from "svelte";
import ArtifactPanel from "./ArtifactPanel.svelte";
import { collectArtifacts } from "$lib/utils/artifacts";
import { setArtifactsContext } from "$lib/utils/artifactsContext";
import { artifactPanel } from "$lib/stores/artifactPanel.svelte";
import IconOmni from "$lib/components/icons/IconOmni.svelte";
import IconCheap from "$lib/components/icons/IconCheap.svelte";
import IconFast from "$lib/components/icons/IconFast.svelte";
import CarbonCaretDown from "~icons/carbon/caret-down";
import { PROVIDERS_HUB_ORGS } from "@huggingface/inference";
import CarbonDirectionRight from "~icons/carbon/direction-right-01";
import IconArrowUp from "~icons/lucide/arrow-up";
import IconMic from "~icons/lucide/mic";
import ChatInput from "./ChatInput.svelte";
import VoiceRecorder from "./VoiceRecorder.svelte";
import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
import type { Model } from "$lib/types/Model";
import FileDropzone from "./FileDropzone.svelte";
import RetryBtn from "../RetryBtn.svelte";
import file2base64 from "$lib/utils/file2base64";
import { base } from "$app/paths";
import ChatMessage from "./ChatMessage.svelte";
import ThinkingEffortChip from "./ThinkingEffortChip.svelte";
import ScrollToBottomBtn from "../ScrollToBottomBtn.svelte";
import ScrollToPreviousBtn from "../ScrollToPreviousBtn.svelte";
import { browser } from "$app/environment";
import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
import SystemPromptModal from "../SystemPromptModal.svelte";
import ShareConversationModal from "../ShareConversationModal.svelte";
import ChatIntroduction from "./ChatIntroduction.svelte";
import UploadedFile from "./UploadedFile.svelte";
import { useSettingsStore } from "$lib/stores/settings";
import { error } from "$lib/stores/errors";
import ModelSwitch from "./ModelSwitch.svelte";
import { routerExamples } from "$lib/constants/routerExamples";
import { mcpExamples } from "$lib/constants/mcpExamples";
import type { RouterFollowUp, RouterExample } from "$lib/constants/routerExamples";
import { allBaseServersEnabled, mcpServersLoaded } from "$lib/stores/mcpServers";
import { shareModal } from "$lib/stores/shareModal";
import IconShare from "$lib/components/icons/IconShare.svelte";
import FeatureAnnouncementToast from "../FeatureAnnouncementToast.svelte";
import { getActiveAnnouncement } from "$lib/utils/featureAnnouncements";
import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
import { pendingChatInput } from "$lib/stores/pendingChatInput";
import LucideHammer from "~icons/lucide/hammer";
import LucideSparkles from "~icons/lucide/sparkles";
import { fly } from "svelte/transition";
import { cubicInOut } from "svelte/easing";
import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard";
import { requireAuthUser } from "$lib/utils/auth";
import { tap, error as hapticError } from "$lib/utils/haptics";
import { page } from "$app/state";
import {
isMessageToolCallUpdate,
isMessageToolErrorUpdate,
isMessageToolResultUpdate,
} from "$lib/utils/messageUpdates";
import type { ToolFront } from "$lib/types/Tool";
interface Props {
messages?: Message[];
messagesAlternatives?: Message["id"][][];
loading?: boolean;
pending?: boolean;
shared?: boolean;
currentModel: Model;
models: Model[];
preprompt?: string | undefined;
files?: File[];
onmessage?: (content: string) => void;
onstop?: () => void;
onretry?: (payload: { id: Message["id"]; content?: string }) => void;
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
draft?: string;
}
let {
messages = [],
messagesAlternatives = [],
loading = false,
pending = false,
shared = false,
currentModel,
models,
preprompt = undefined,
files = $bindable([]),
draft = $bindable(""),
onmessage,
onstop,
onretry,
onshowAlternateMsg,
}: Props = $props();
let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
const publicConfig = usePublicConfig();
let canShare = $derived(
publicConfig.isHuggingChat &&
Boolean(page.params?.id) &&
page.route.id?.startsWith("/conversation/")
);
// Feature announcement toast: home screen only, gone as soon as a chat starts.
let featureAnnouncement = $derived(
getActiveAnnouncement(publicConfig.PUBLIC_FEATURE_ANNOUNCEMENTS)
);
let showFeatureAnnouncement = $derived(page.route.id === "/" && !messages.length && !loading);
// Artifacts: fold <artifact> operations from the visible message path into a
// versioned registry, shared with the inline cards and the side panel.
// Only the message currently receiving tokens can have a streaming artifact;
// unclosed tags anywhere else are interrupted generations, not live ones.
let artifactRegistry = $derived(
collectArtifacts(messages, loading ? messages.at(-1)?.id : undefined)
);
setArtifactsContext({
get registry() {
return artifactRegistry;
},
panel: artifactPanel,
});
// Auto-open the panel when a new artifact version starts streaming in
// (once per version, so closing it mid-stream sticks).
$effect(() => {
const streaming = artifactRegistry.streaming;
if (!streaming || !loading) return;
artifactPanel.maybeAutoOpen(streaming.identifier, streaming.version);
});
let shareModalOpen = $state(false);
let editMsdgId: Message["id"] | null = $state(null);
let pastedLongContent = $state(false);
// Voice recording state
let isRecording = $state(false);
let isTranscribing = $state(false);
let transcriptionEnabled = $derived(
!!(page.data as { transcriptionEnabled?: boolean }).transcriptionEnabled
);
let isTouchDevice = $derived(browser && navigator.maxTouchPoints > 0);
const handleSubmit = () => {
if (requireAuthUser() || loading || !draft) return;
tap();
onmessage?.(draft);
draft = "";
};
let lastTarget: EventTarget | null = null;
let onDrag = $state(false);
const onDragEnter = (e: DragEvent) => {
lastTarget = e.target;
onDrag = true;
};
const onDragLeave = (e: DragEvent) => {
if (e.target === lastTarget) {
onDrag = false;
}
};
const onPaste = (e: ClipboardEvent) => {
const textContent = e.clipboardData?.getData("text");
if (!$settings.directPaste && textContent && textContent.length >= 3984) {
e.preventDefault();
pastedLongContent = true;
setTimeout(() => {
pastedLongContent = false;
}, 1000);
const pastedFile = new File([textContent], "Pasted Content", {
type: "application/vnd.chatui.clipboard",
});
files = [...files, pastedFile];
}
if (!e.clipboardData) {
return;
}
// paste of files
const pastedFiles = Array.from(e.clipboardData.files);
if (pastedFiles.length !== 0) {
e.preventDefault();
// filter based on activeMimeTypes, including wildcards
const filteredFiles = pastedFiles.filter((file) => {
return activeMimeTypes.some((mimeType: string) => {
const [type, subtype] = mimeType.split("/");
const [fileType, fileSubtype] = file.type.split("/");
return (
(type === "*" || fileType === type) && (subtype === "*" || fileSubtype === subtype)
);
});
});
files = [...files, ...filteredFiles];
}
};
let lastMessage = $derived(browser && (messages.at(-1) as Message));
let showPendingPlaceholder = $derived(
pending &&
!(lastMessage && lastMessage.from === "assistant" && (lastMessage.content ?? "").length === 0)
);
let streamingAssistantMessage = $derived(
(() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (candidate.from === "assistant") {
return candidate;
}
}
return undefined;
})()
);
let streamingRouterMetadata = $derived(streamingAssistantMessage?.routerMetadata ?? null);
let streamingRouterModelName = $derived(
streamingRouterMetadata?.model
? (streamingRouterMetadata.model.split("/").pop() ?? streamingRouterMetadata.model)
: ""
);
let lastIsError = $derived(
!loading &&
(streamingAssistantMessage?.updates?.findIndex(
(u) => u.type === "status" && u.status === "error"
) ?? -1) !== -1
);
// Expose currently running tool call name (if any) from the streaming assistant message
const availableTools: ToolFront[] = $derived.by(
() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
);
let streamingToolCallName = $derived.by(() => {
const updates = streamingAssistantMessage?.updates ?? [];
if (!updates.length) return null;
const done = new Set<string>();
for (const u of updates) {
if (isMessageToolResultUpdate(u) || isMessageToolErrorUpdate(u)) done.add(u.uuid);
}
for (let i = updates.length - 1; i >= 0; i -= 1) {
const u = updates[i];
if (isMessageToolCallUpdate(u) && !done.has(u.uuid)) {
return u.call.name;
}
}
return null;
});
let showRouterDetails = $state(false);
let routerDetailsTimeout: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
if (!currentModel.isRouter || !loading) {
showRouterDetails = false;
if (routerDetailsTimeout) {
clearTimeout(routerDetailsTimeout);
routerDetailsTimeout = undefined;
}
return;
}
if (routerDetailsTimeout) {
clearTimeout(routerDetailsTimeout);
}
showRouterDetails = false;
routerDetailsTimeout = setTimeout(() => {
showRouterDetails = true;
}, 500);
});
let sources = $derived(
files?.map<Promise<MessageFile>>((file) =>
file2base64(file).then((value) => ({
type: "base64",
value,
mime: file.type,
name: file.name,
}))
)
);
const unsubscribeShareModal = shareModal.subscribe((value) => {
shareModalOpen = value;
});
onDestroy(() => {
unsubscribeShareModal();
shareModal.close();
if (routerDetailsTimeout) {
clearTimeout(routerDetailsTimeout);
}
});
let chatContainer: HTMLElement | undefined = $state();
// Force scroll to bottom when user sends a new message or switches conversation
let prevMessageCount = $state(0);
// svelte-ignore state_referenced_locally
let prevFirstMessageId = $state(messages.at(0)?.id);
let forceReattach = $state(0);
let scrollBehavior: "auto" | "instant" | "smooth" = $state("instant");
$effect(() => {
const firstMessageId = messages.at(0)?.id;
// Conversation switch: first message ID changed
if (firstMessageId !== prevFirstMessageId) {
prevFirstMessageId = firstMessageId;
scrollBehavior = "instant";
forceReattach++;
spacerActive = 0;
spacerHeight = MIN_SPACER_PX;
prevMessageCount = messages.length;
artifactPanel.reset();
return;
}
// New user message: user message + empty assistant message added together
if (messages.length > prevMessageCount) {
const last = messages.at(-1);
const secondLast = messages.at(-2);
const userJustSentMessage =
messages.length === prevMessageCount + 2 &&
secondLast?.from === "user" &&
last?.from === "assistant" &&
last?.content === "";
if (userJustSentMessage) {
scrollBehavior = "smooth";
forceReattach++;
// Only activate dynamic spacer after the first exchange
// (first user+assistant pair scrolls normally)
spacerActive = prevMessageCount >= 2 ? spacerActive + 1 : 0;
}
}
prevMessageCount = messages.length;
});
// Shared conversations containing artifacts usually exist to show one off:
// open the most recent artifact on load. Desktop only, since on mobile the
// panel is a fullscreen overlay that would hide the conversation entirely.
// Declared after the conversation-switch effect so its reset() can never
// close the panel after this opens it within the same flush.
let autoOpenedSharedArtifact = false;
$effect(() => {
if (autoOpenedSharedArtifact || !shared) return;
const latest = [...artifactRegistry.artifacts.values()].at(-1);
if (!latest) return;
autoOpenedSharedArtifact = true;
if (!window.matchMedia("(min-width: 768px)").matches) return;
artifactPanel.openArtifact(latest.identifier, null);
});
// Combined scroll dependency for the action
let scrollDependency = $derived({ forceReattach, scrollBehavior });
// Dynamic bottom spacer for ChatGPT-style scroll (new message appears near top of viewport)
const MIN_SPACER_PX = 208; // equivalent to pb-52
const SPACER_TOP_OFFSET_PX = 50; // breathing room above the user message
let spacerEl: HTMLElement | undefined = $state();
let messagesEl: HTMLElement | undefined = $state();
let spacerHeight = $state(MIN_SPACER_PX);
let spacerActive = $state(0); // 0 = inactive, >0 = active (counter to force effect re-run)
function computeSpacerHeight(): number {
if (!chatContainer || !spacerEl) return MIN_SPACER_PX;
const userMsgs = chatContainer.querySelectorAll('[data-message-type="user"]');
const lastUserMsg = userMsgs[userMsgs.length - 1] as HTMLElement | undefined;
if (!lastUserMsg) return MIN_SPACER_PX;
const viewportHeight = chatContainer.clientHeight;
const containerRect = chatContainer.getBoundingClientRect();
const scrollTop = chatContainer.scrollTop;
// Use the spacer element's own position as reference — this naturally accounts
// for all flex gaps, padding, and layout between the user message and the spacer.
const userMsgScrollTop =
lastUserMsg.getBoundingClientRect().top - containerRect.top + scrollTop;
const spacerScrollTop = spacerEl.getBoundingClientRect().top - containerRect.top + scrollTop;
const contentHeight = spacerScrollTop - userMsgScrollTop;
return Math.max(MIN_SPACER_PX, viewportHeight - contentHeight - SPACER_TOP_OFFSET_PX);
}
$effect(() => {
// Don't gate on `loading` — the spacer must be computed immediately when
// spacerActive is set (same tick as forceReattach++) so that the spacer
// height is correct BEFORE snapScrollToBottom's scrollToBottom() fires.
if (!spacerActive || !chatContainer || !messagesEl) return;
const container = chatContainer;
// Observe the messages wrapper (has h-max, resizes with content)
// instead of the mx-auto container (has h-full, may not resize).
const observer = new ResizeObserver(() => {
spacerHeight = computeSpacerHeight();
// The mx-auto container has h-full so its ResizeObserver in
// snapScrollToBottom may not fire during streaming. Scroll here
// to keep up with growing content, but only if user is near bottom.
tick().then(() => {
const dist = container.scrollHeight - container.scrollTop - container.clientHeight;
// Use a tight threshold (matching snapScrollToBottom's BOTTOM_THRESHOLD)
// to avoid overriding user scroll intent during streaming.
if (dist < 50) {
container.scrollTo({ top: container.scrollHeight });
}
});
});
observer.observe(messagesEl);
spacerHeight = computeSpacerHeight();
return () => observer.disconnect();
});
const settings = useSettingsStore();
let hideRouterExamples = $derived($settings.hidePromptExamples?.[currentModel.id] ?? false);
// Respect per‑model multimodal toggle from settings (force enable)
let modelIsMultimodalOverride = $derived($settings.multimodalOverrides?.[currentModel.id]);
let modelIsMultimodal = $derived((modelIsMultimodalOverride ?? currentModel.multimodal) === true);
// Determine tool support for the current model (server-provided capability with user override)
let modelSupportsTools = $derived(
($settings.toolsOverrides?.[currentModel.id] ??
(currentModel as unknown as { supportsTools?: boolean }).supportsTools) === true
);
// Get provider override for the current model (HuggingChat only)
let providerOverride = $derived($settings.providerOverrides?.[currentModel.id]);
let hasProviderOverride = $derived(
providerOverride && providerOverride !== "auto" && !currentModel.isRouter
);
// Always allow common text-like files; add images only when model is multimodal
import { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from "$lib/constants/mime";
let activeMimeTypes = $derived(
Array.from(
new Set([
...TEXT_MIME_ALLOWLIST,
...(modelIsMultimodal
? (currentModel.multimodalAcceptedMimetypes ?? [...IMAGE_MIME_ALLOWLIST_DEFAULT])
: []),
])
)
);
let isFileUploadEnabled = $derived(activeMimeTypes.length > 0);
let focused = $state(false);
let activeRouterExamplePrompt = $state<string | null>(null);
// Use MCP examples when all base servers are enabled, otherwise use router examples
let activeExamples = $derived<RouterExample[]>(
$allBaseServersEnabled ? mcpExamples : routerExamples
);
let routerFollowUps = $derived<RouterFollowUp[]>(
activeRouterExamplePrompt
? (activeExamples.find((ex) => ex.prompt === activeRouterExamplePrompt)?.followUps ?? [])
: []
);
let routerUserMessages = $derived(messages.filter((msg) => msg.from === "user"));
let shouldShowRouterFollowUps = $derived(
!draft.length &&
activeRouterExamplePrompt &&
routerFollowUps.length > 0 &&
routerUserMessages.length === 1 &&
(currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) &&
!hideRouterExamples &&
!loading
);
$effect(() => {
if (
!(currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) ||
!messages.length
) {
activeRouterExamplePrompt = null;
return;
}
const firstUserMessage = messages.find((msg) => msg.from === "user");
if (!firstUserMessage) {
activeRouterExamplePrompt = null;
return;
}
const match = activeExamples.find((ex) => ex.prompt.trim() === firstUserMessage.content.trim());
activeRouterExamplePrompt = match ? match.prompt : null;
});
$effect(() => {
if ($pendingChatInput) {
draft = $pendingChatInput;
pendingChatInput.set(undefined);
}
});
function triggerPrompt(prompt: string) {
if (requireAuthUser() || loading) return;
draft = prompt;
handleSubmit();
}
async function startExample(example: RouterExample) {
if (requireAuthUser()) return;
activeRouterExamplePrompt = example.prompt;
if (browser && example.attachments?.length) {
const loadedFiles: File[] = [];
for (const attachment of example.attachments) {
try {
const response = await fetch(`${base}/${attachment.src}`);
if (!response.ok) continue;
const blob = await response.blob();
const name = attachment.src.split("/").pop() ?? "attachment";
loadedFiles.push(
new File([blob], name, { type: blob.type || "application/octet-stream" })
);
} catch (err) {
console.error("Error loading attachment:", err);
}
}
files = loadedFiles;
}
triggerPrompt(example.prompt);
}
function startFollowUp(followUp: RouterFollowUp) {
triggerPrompt(followUp.prompt);
}
async function handleRecordingConfirm(audioBlob: Blob) {
isRecording = false;
isTranscribing = true;
try {
const response = await fetch(`${base}/api/transcribe`, {
method: "POST",
headers: { "Content-Type": audioBlob.type },
body: audioBlob,
});
if (!response.ok) {
throw new Error(await response.text());
}
const { text } = await response.json();
const trimmedText = text?.trim();
if (trimmedText) {
// Append transcribed text to draft
draft = draft.trim() ? `${draft.trim()} ${trimmedText}` : trimmedText;
}
} catch (err) {
console.error("Transcription error:", err);
$error = "Transcription failed. Please try again.";
} finally {
isTranscribing = false;
}
}
async function handleRecordingSend(audioBlob: Blob) {
isRecording = false;
isTranscribing = true;
try {
const response = await fetch(`${base}/api/transcribe`, {
method: "POST",
headers: { "Content-Type": audioBlob.type },
body: audioBlob,
});
if (!response.ok) {
throw new Error(await response.text());
}
const { text } = await response.json();
const trimmedText = text?.trim();
if (trimmedText) {
// Set draft and send immediately
draft = draft.trim() ? `${draft.trim()} ${trimmedText}` : trimmedText;
handleSubmit();
}
} catch (err) {
console.error("Transcription error:", err);
$error = "Transcription failed. Please try again.";
} finally {
isTranscribing = false;
}
}
function handleRecordingError(message: string) {
console.error("Recording error:", message);
isRecording = false;
$error = message;
}
</script>
<svelte:window
ondragenter={onDragEnter}
ondragleave={onDragLeave}
ondragover={(e) => {
e.preventDefault();
}}
ondrop={(e) => {
e.preventDefault();
onDrag = false;
}}
/>
<!-- pointer-events-none: the chat column sits at z-[-1]; this wrapper's
hit-area would otherwise swallow every click meant for it. Children
re-enable pointer events themselves. -->
<div class="pointer-events-none relative flex min-h-0 min-w-0">
<div class="pointer-events-auto relative z-[-1] min-h-0 min-w-0 flex-1">
{#if shareModalOpen}
<ShareConversationModal open={shareModalOpen} onclose={() => shareModal.close()} />
{/if}
{#if canShare}
<!-- Lives in the chat column (not the layout) so it stays visible when
the artifact panel is open -->
<button
type="button"
class="hidden size-8 items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white/90 text-sm font-medium text-gray-700 shadow-xs hover:bg-white/60 hover:text-gray-500 md:absolute md:top-5 md:right-6 md:z-10 md:flex dark:border-gray-700 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700
{loading ? 'cursor-not-allowed opacity-40' : ''}"
onclick={() => shareModal.open()}
aria-label="Share conversation"
disabled={loading}
>
<IconShare />
</button>
{/if}
{#if featureAnnouncement && showFeatureAnnouncement}
<FeatureAnnouncementToast announcement={featureAnnouncement} />
{/if}
<div
class="scrollbar-custom h-full overflow-y-auto"
use:snapScrollToBottom={scrollDependency}
bind:this={chatContainer}
>
<!-- @container: descendants (e.g. the per-message router-metadata row) adapt
to the actual column width, which shrinks when the artifact panel is open -->
<div
class="@container mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl xl:pt-10"
>
{#if preprompt && preprompt != currentModel.preprompt}
<SystemPromptModal preprompt={preprompt ?? ""} />
{/if}
{#if messages.length > 0}
<div bind:this={messagesEl} class="flex h-max flex-col gap-8">
{#each messages as message, idx (message.id)}
<ChatMessage
{loading}
{message}
alternatives={messagesAlternatives.find((a) => a.includes(message.id)) ?? []}
isAuthor={!shared}
readOnly={isReadOnly}
isLast={idx === messages.length - 1}
bind:editMsdgId
onretry={(payload) => onretry?.(payload)}
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
/>
{/each}
{#if showPendingPlaceholder}
<ChatMessage
loading={true}
message={{
id: "pending-placeholder",
content: "",
from: "assistant",
children: [],
}}
isAuthor={!shared}
readOnly={isReadOnly}
/>
{/if}
{#if isReadOnly}
<ModelSwitch {models} {currentModel} />
{/if}
</div>
<!-- Dynamic bottom spacer: large when streaming new message, shrinks as response grows -->
<div bind:this={spacerEl} class="flex-shrink-0" style="height: {spacerHeight}px;"></div>
{:else if pending}
<ChatMessage
loading={true}
message={{
id: "0-0-0-0-0",
content: "",
from: "assistant",
children: [],
}}
isAuthor={!shared}
readOnly={isReadOnly}
/>
{:else}
<ChatIntroduction
{currentModel}
onmessage={(content) => {
onmessage?.(content);
}}
/>
{/if}
</div>
<ScrollToPreviousBtn class="fixed right-4 bottom-48 lg:right-10" scrollNode={chatContainer} />
<ScrollToBottomBtn class="fixed right-4 bottom-36 lg:right-10" scrollNode={chatContainer} />
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full
max-w-3xl flex-col items-center justify-center bg-linear-to-t from-white
via-white to-white/0 px-3.5 pt-2 *:pointer-events-auto
max-sm:py-0 sm:px-5
md:pb-4 xl:max-w-4xl dark:border-gray-800 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900/0"
>
{#if !draft.length && !messages.length && !sources.length && !loading && (currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) && activeExamples.length && !hideRouterExamples && !lastIsError && $mcpServersLoaded}
<div
class="mb-3 no-scrollbar flex w-full justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 select-none dark:text-gray-500"
>
{#each activeExamples as ex}
<button
class="flex items-center gap-1 rounded-lg bg-gray-100/90 px-2 py-0.5 text-center text-sm backdrop-blur-sm hover:text-gray-500 dark:bg-gray-700/50 dark:hover:text-gray-400"
onclick={() => startExample(ex)}
>
{ex.title}
{#if ex.artifact}
<LucideSparkles class="size-3 flex-none text-blue-600 dark:text-blue-400" />
{/if}
</button>
{/each}
</div>
{/if}
{#if shouldShowRouterFollowUps && !lastIsError}
<div
class="mb-3 no-scrollbar flex w-full justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 select-none dark:text-gray-500"
>
<!-- <span class=" text-gray-500 dark:text-gray-400">Follow ups</span> -->
{#each routerFollowUps as followUp}
<button
class="flex items-center gap-1 rounded-lg bg-gray-100/90 px-2 py-0.5 text-center text-sm backdrop-blur-sm hover:text-gray-500 dark:bg-gray-700/50 dark:hover:text-gray-400"
onclick={() => startFollowUp(followUp)}
>
<CarbonDirectionRight class="scale-y-[-1] text-xs" />
{followUp.title}</button
>
{/each}
</div>
{/if}
{#if sources?.length && !loading}
<div
in:fly|local={sources.length === 1 ? { y: -20, easing: cubicInOut } : undefined}
class="flex flex-row flex-wrap justify-center gap-2.5 rounded-xl pb-3"
>
{#each sources as source, index}
{#await source then src}
<UploadedFile
file={src}
onclose={() => {
files = files.filter((_, i) => i !== index);
}}
/>
{/await}
{/each}
</div>
{/if}
<div class="w-full">
<div class="flex w-full *:mb-3">
{#if !loading && lastIsError}
<RetryBtn
classNames="ml-auto"
onClick={() => {
if (lastMessage && lastMessage.ancestors) {
onretry?.({
id: lastMessage.id,
});
}
}}
/>
{/if}
</div>
<form
tabindex="-1"
aria-label={isFileUploadEnabled ? "file dropzone" : undefined}
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class={{
"relative flex w-full max-w-4xl flex-1 items-center rounded-xl border bg-gray-100 dark:border-gray-700 dark:bg-gray-800": true,
"opacity-30": isReadOnly,
"max-sm:mb-4": focused && isVirtualKeyboard(),
}}
>
{#if isRecording || isTranscribing}
<VoiceRecorder
{isTranscribing}
{isTouchDevice}
oncancel={() => {
isRecording = false;
}}
onconfirm={handleRecordingConfirm}
onsend={handleRecordingSend}
onerror={handleRecordingError}
/>
{:else if onDrag && isFileUploadEnabled}
<FileDropzone bind:files bind:onDrag mimeTypes={activeMimeTypes} />
{:else}
<div
class="flex w-full flex-1 rounded-xl border-none bg-transparent"
class:paste-glow={pastedLongContent}
>
{#if lastIsError}
<ChatInput value="Sorry, something went wrong. Please try again." disabled={true} />
{:else}
<ChatInput
placeholder={isReadOnly ? "This conversation is read-only." : "Ask anything"}
{loading}
bind:value={draft}
bind:files
mimeTypes={activeMimeTypes}
onsubmit={handleSubmit}
{onPaste}
disabled={isReadOnly || lastIsError}
{modelIsMultimodal}
{modelSupportsTools}
bind:focused
/>
{/if}
{#if loading}
<StopGeneratingBtn
onClick={() => {
hapticError();
onstop?.();
}}
showBorder={true}
classNames="absolute bottom-2 right-2 size-8 sm:size-7 self-end rounded-full border bg-white text-black shadow-sm transition-none dark:border-transparent dark:bg-gray-600 dark:text-white"
/>
{:else}
{#if transcriptionEnabled}
<button
type="button"
class="absolute right-10 bottom-2 mr-1.5 btn size-8 self-end rounded-full border bg-white/50 text-gray-500 transition-none hover:bg-gray-50 hover:text-gray-700 sm:right-9 sm:size-7 dark:border-transparent dark:bg-gray-600/50 dark:text-gray-300 dark:hover:bg-gray-500 dark:hover:text-white"
disabled={isReadOnly}
onclick={() => {
isRecording = true;
}}
aria-label="Start voice recording"
>
<IconMic class="size-4" />
</button>
{/if}
<button
class="absolute right-2 bottom-2 btn size-8 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner sm:size-7 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!draft ||
isReadOnly
? ''
: 'bg-black! text-white! dark:bg-white! dark:text-black!'}"
disabled={!draft || isReadOnly}
type="submit"
aria-label="Send message"
name="submit"
>
<IconArrowUp />
</button>
{/if}
</div>
{/if}
</form>
<div
class={{
"mt-1.5 flex h-5 items-center self-stretch px-0.5 text-xs whitespace-nowrap text-gray-400/90 max-md:mb-2 max-sm:gap-2": true,
"max-sm:hidden": focused && isVirtualKeyboard(),
}}
>
{#if models.find((m) => m.id === currentModel.id)}
{#if loading && streamingToolCallName}
<span class="inline-flex items-center gap-1 text-xs whitespace-nowrap">
<LucideHammer class="size-3" />
Calling tool
<span class="loading-dots font-medium">
{availableTools.find((t) => t.name === streamingToolCallName)?.displayName ??
streamingToolCallName}
</span>
</span>
{:else if !currentModel.isRouter || !loading}
<a
href="{base}/settings/{currentModel.id}"
onclick={(e) => {
if (requireAuthUser()) {
e.preventDefault();
}
}}
class="inline-flex min-w-0 items-center gap-1 hover:underline"
>
{#if currentModel.isRouter}
<IconOmni />
<span class="truncate">{currentModel.displayName}</span>
{:else}
<span class="shrink-0">Model:</span>
{#if currentModel.logoUrl}
<img
src={currentModel.logoUrl}
alt=""
class="size-3 flex-none rounded-sm border bg-white dark:border-gray-700"
/>
{/if}
<span class="truncate">{currentModel.displayName}</span>
{#if hasProviderOverride}
{@const hubOrg =
PROVIDERS_HUB_ORGS[providerOverride as keyof typeof PROVIDERS_HUB_ORGS]}
<span
class="inline-flex shrink-0 items-center rounded-sm p-0.5 {providerOverride ===
'fastest'
? 'bg-green-100 text-green-600 dark:bg-green-800/20 dark:text-green-500'
: providerOverride === 'cheapest'
? 'bg-blue-100 text-blue-600 dark:bg-blue-800/20 dark:text-blue-500'
: ''}"
title="Provider: {providerOverride}"
>
{#if providerOverride === "fastest"}
<IconFast classNames="text-sm" />
{:else if providerOverride === "cheapest"}
<IconCheap classNames="text-sm" />
{:else if hubOrg}
<img
src="https://huggingface.co/api/avatars/{hubOrg}"
alt={providerOverride}
class="size-3 flex-none rounded-xs"
/>
{/if}
</span>
{/if}
{/if}
<CarbonCaretDown class="-ml-0.5 shrink-0 text-xxs" />
</a>
{:else if showRouterDetails && streamingRouterMetadata?.route}
<div
class="mr-2 flex items-center gap-1.5 text-xs text-[.70rem] leading-none whitespace-nowrap text-gray-400 dark:text-gray-400"
>
<IconOmni classNames="text-xs animate-pulse" />
<span class="router-badge-text router-shimmer">
{streamingRouterMetadata.route}
</span>
<span class="text-gray-500">with</span>
<span class="router-badge-text">
{streamingRouterModelName}
</span>
</div>
{:else}
<div
class="loading-dots relative inline-flex items-center text-gray-400 dark:text-gray-400"
aria-label="Routing…"
>
<IconOmni classNames="text-xs animate-pulse mr-1" /> Routing
</div>
{/if}
{:else}
<span class="inline-flex items-center line-through dark:border-gray-700">
{currentModel.id}
</span>
{/if}
{#if !messages.length && !loading}
<span class="max-sm:hidden">Generated content may be inaccurate or false.</span>
{/if}
{#if $settings.reasoningOverrides?.[currentModel.id] ?? currentModel.supportsReasoning}
<div class="ml-auto">
<ThinkingEffortChip modelId={currentModel.id} />
</div>
{/if}
</div>
</div>
</div>
</div>
<ArtifactPanel registry={artifactRegistry} {loading} />
</div>
<style>
.paste-glow {
animation: glow 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
will-change: box-shadow;
}
@keyframes glow {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.8);
}
50% {
box-shadow: 0 0 20px 4px rgba(59, 130, 246, 0.6);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
.router-badge-text {
display: inline-block;
position: relative;
color: inherit;
}
.router-shimmer {
display: inline-block;
background-image: linear-gradient(
90deg,
rgba(156, 163, 175, 1) 0%,
rgba(156, 163, 175, 0.6) 10%,
rgba(156, 163, 175, 0.6) 50%,
rgba(156, 163, 175, 0.6) 90%,
rgba(156, 163, 175, 1) 100%
);
background-size: 220% 100%;
animation: router-shimmer 2.8s linear infinite;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
:global(.dark) .router-shimmer {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.15) 100%
);
}
@keyframes router-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.loading-dots::after {
content: "";
animation: dots-content 0.9s steps(1, end) infinite;
}
@keyframes dots-content {
0% {
content: "";
}
33% {
content: ".";
}
66% {
content: "..";
}
88% {
content: "...";
}
}
</style>