open-webui/src/lib/components/layout/Navbar/Menu.svelte
2025-05-10 18:59:04 +04:00

398 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { toast } from 'svelte-sonner';
import { DropdownMenu } from 'bits-ui';
import { getContext } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas-pro';
import { downloadChatAsPDF } from '$lib/apis/utils';
import { copyToClipboard, createMessagesList } from '$lib/utils';
import {
showOverview,
showControls,
showArtifacts,
mobile,
temporaryChatEnabled,
theme,
user,
settings
} from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Map from '$lib/components/icons/Map.svelte';
import Clipboard from '$lib/components/icons/Clipboard.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import Cube from '$lib/components/icons/Cube.svelte';
import { getChatById } from '$lib/apis/chats';
const i18n = getContext('i18n');
export let shareEnabled: boolean = false;
export let shareHandler: Function;
export let downloadHandler: Function;
// export let tagHandler: Function;
export let chat;
export let onClose: Function = () => {};
const getChatAsText = async () => {
const history = chat.chat.history;
const messages = createMessagesList(history, history.currentId);
const chatText = messages.reduce((a, message, i, arr) => {
return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
}, '');
return chatText.trim();
};
const downloadTxt = async () => {
const chatText = await getChatAsText();
let blob = new Blob([chatText], {
type: 'text/plain'
});
saveAs(blob, `chat-${chat.chat.title}.txt`);
};
const downloadPdf = async () => {
if ($settings?.stylizedPdfExport ?? true) {
const containerElement = document.getElementById('messages-container');
if (containerElement) {
try {
const isDarkMode = document.documentElement.classList.contains('dark');
const virtualWidth = 800; // Fixed width in px
const pagePixelHeight = 1200; // Each slice height (adjust to avoid canvas bugs; generally 24k is safe)
// Clone & style once
const clonedElement = containerElement.cloneNode(true);
clonedElement.classList.add('text-black');
clonedElement.classList.add('dark:text-white');
clonedElement.style.width = `${virtualWidth}px`;
clonedElement.style.position = 'absolute';
clonedElement.style.left = '-9999px'; // Offscreen
clonedElement.style.height = 'auto';
document.body.appendChild(clonedElement);
// Get total height after attached to DOM
const totalHeight = clonedElement.scrollHeight;
let offsetY = 0;
let page = 0;
// Prepare PDF
const pdf = new jsPDF('p', 'mm', 'a4');
const imgWidth = 210; // A4 mm
const pageHeight = 297; // A4 mm
while (offsetY < totalHeight) {
// For each slice, adjust scrollTop to show desired part
clonedElement.scrollTop = offsetY;
// Optionally: mask/hide overflowing content via CSS if needed
clonedElement.style.maxHeight = `${pagePixelHeight}px`;
// Only render the visible part
const canvas = await html2canvas(clonedElement, {
backgroundColor: isDarkMode ? '#000' : '#fff',
useCORS: true,
scale: 2,
width: virtualWidth,
height: Math.min(pagePixelHeight, totalHeight - offsetY),
// Optionally: y offset for correct region?
windowWidth: virtualWidth
//windowHeight: pagePixelHeight,
});
const imgData = canvas.toDataURL('image/png');
// Maintain aspect ratio
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const position = 0; // Always first line, since we've clipped vertically
if (page > 0) pdf.addPage();
// Set page background for dark mode
if (isDarkMode) {
pdf.setFillColor(0, 0, 0);
pdf.rect(0, 0, imgWidth, pageHeight, 'F'); // black bg
}
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
offsetY += pagePixelHeight;
page++;
}
document.body.removeChild(clonedElement);
pdf.save(`chat-${chat.chat.title}.pdf`);
} catch (error) {
console.error('Error generating PDF', error);
}
}
} else {
console.log('Downloading PDF');
const chatText = await getChatAsText();
const doc = new jsPDF();
// Margins
const left = 15;
const top = 20;
const right = 15;
const bottom = 20;
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const usableWidth = pageWidth - left - right;
const usableHeight = pageHeight - top - bottom;
// Font size and line height
const fontSize = 8;
doc.setFontSize(fontSize);
const lineHeight = fontSize * 1; // adjust if needed
// Split the markdown into lines (handles \n)
const paragraphs = chatText.split('\n');
let y = top;
for (let paragraph of paragraphs) {
// Wrap each paragraph to fit the width
const lines = doc.splitTextToSize(paragraph, usableWidth);
for (let line of lines) {
// If the line would overflow the bottom, add a new page
if (y + lineHeight > pageHeight - bottom) {
doc.addPage();
y = top;
}
doc.text(line, left, y);
y += lineHeight * 0.5;
}
// Add empty line at paragraph breaks
y += lineHeight * 0.1;
}
doc.save(`chat-${chat.chat.title}.pdf`);
}
};
const downloadJSONExport = async () => {
if (chat.id) {
let chatObj = null;
if (chat.id === 'local' || $temporaryChatEnabled) {
chatObj = chat;
} else {
chatObj = await getChatById(localStorage.token, chat.id);
}
let blob = new Blob([JSON.stringify([chatObj])], {
type: 'application/json'
});
saveAs(blob, `chat-export-${Date.now()}.json`);
}
};
</script>
<Dropdown
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="end"
transition={flyAndScale}
>
<!-- <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer dark:hover:bg-gray-800 rounded-md"
on:click={async () => {
await showSettings.set(!$showSettings);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Settings')}</div>
</DropdownMenu.Item> -->
{#if $mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-controls-button"
on:click={async () => {
await showControls.set(true);
await showOverview.set(false);
await showArtifacts.set(false);
}}
>
<AdjustmentsHorizontal className=" size-4" strokeWidth="0.5" />
<div class="flex items-center">{$i18n.t('Controls')}</div>
</DropdownMenu.Item>
{/if}
{#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
shareHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-overview-button"
on:click={async () => {
await showControls.set(true);
await showOverview.set(true);
await showArtifacts.set(false);
}}
>
<Map className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Overview')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-overview-button"
on:click={async () => {
await showControls.set(true);
await showArtifacts.set(true);
await showOverview.set(false);
}}
>
<Cube className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Artifacts')}</div>
</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
transition={flyAndScale}
sideOffset={8}
>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadJSONExport();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadPdf();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-copy-button"
on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => {
console.error(e);
});
if (res) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy')}</div>
</DropdownMenu.Item>
{#if !$temporaryChatEnabled}
<hr class="border-gray-100 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags chatId={chat.id} />
</div>
{/if}
</DropdownMenu.Content>
</div>
</Dropdown>