1275 lines
		
	
	
		
			41 KiB
		
	
	
	
		
			Svelte
		
	
	
	
			
		
		
	
	
			1275 lines
		
	
	
		
			41 KiB
		
	
	
	
		
			Svelte
		
	
	
	
| <script lang="ts">
 | |
| 	import { toast } from 'svelte-sonner';
 | |
| 	import { v4 as uuidv4 } from 'uuid';
 | |
| 	import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
 | |
| 
 | |
| 	import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
 | |
| 	const dispatch = createEventDispatcher();
 | |
| 
 | |
| 	import {
 | |
| 		type Model,
 | |
| 		mobile,
 | |
| 		settings,
 | |
| 		showSidebar,
 | |
| 		models,
 | |
| 		config,
 | |
| 		showCallOverlay,
 | |
| 		tools,
 | |
| 		user as _user,
 | |
| 		showControls
 | |
| 	} from '$lib/stores';
 | |
| 
 | |
| 	import { blobToFile, compressImage, createMessagesList, findWordIndices } from '$lib/utils';
 | |
| 	import { transcribeAudio } from '$lib/apis/audio';
 | |
| 	import { uploadFile } from '$lib/apis/files';
 | |
| 	import { getTools } from '$lib/apis/tools';
 | |
| 
 | |
| 	import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
 | |
| 
 | |
| 	import Tooltip from '../common/Tooltip.svelte';
 | |
| 	import InputMenu from './MessageInput/InputMenu.svelte';
 | |
| 	import Headphone from '../icons/Headphone.svelte';
 | |
| 	import VoiceRecording from './MessageInput/VoiceRecording.svelte';
 | |
| 	import FileItem from '../common/FileItem.svelte';
 | |
| 	import FilesOverlay from './MessageInput/FilesOverlay.svelte';
 | |
| 	import Commands from './MessageInput/Commands.svelte';
 | |
| 	import XMark from '../icons/XMark.svelte';
 | |
| 	import RichTextInput from '../common/RichTextInput.svelte';
 | |
| 	import { generateAutoCompletion } from '$lib/apis';
 | |
| 	import { error, text } from '@sveltejs/kit';
 | |
| 	import Image from '../common/Image.svelte';
 | |
| 	import { deleteFileById } from '$lib/apis/files';
 | |
| 
 | |
| 	const i18n = getContext('i18n');
 | |
| 
 | |
| 	export let transparentBackground = false;
 | |
| 
 | |
| 	export let onChange: Function = () => {};
 | |
| 	export let createMessagePair: Function;
 | |
| 	export let stopResponse: Function;
 | |
| 
 | |
| 	export let autoScroll = false;
 | |
| 
 | |
| 	export let atSelectedModel: Model | undefined = undefined;
 | |
| 	export let selectedModels: [''];
 | |
| 
 | |
| 	let selectedModelIds = [];
 | |
| 	$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
 | |
| 
 | |
| 	export let history;
 | |
| 
 | |
| 	export let prompt = '';
 | |
| 	export let files = [];
 | |
| 
 | |
| 	export let selectedToolIds = [];
 | |
| 
 | |
| 	export let imageGenerationEnabled = false;
 | |
| 	export let webSearchEnabled = false;
 | |
| 
 | |
| 	$: onChange({
 | |
| 		prompt,
 | |
| 		files,
 | |
| 		selectedToolIds,
 | |
| 		imageGenerationEnabled,
 | |
| 		webSearchEnabled
 | |
| 	});
 | |
| 
 | |
| 	let loaded = false;
 | |
| 	let recording = false;
 | |
| 
 | |
| 	let chatInputContainerElement;
 | |
| 	let chatInputElement;
 | |
| 
 | |
| 	let filesInputElement;
 | |
| 	let commandsElement;
 | |
| 
 | |
| 	let inputFiles;
 | |
| 	let dragged = false;
 | |
| 
 | |
| 	let user = null;
 | |
| 	export let placeholder = '';
 | |
| 
 | |
| 	let visionCapableModels = [];
 | |
| 	$: visionCapableModels = [...(atSelectedModel ? [atSelectedModel] : selectedModels)].filter(
 | |
| 		(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
 | |
| 	);
 | |
| 
 | |
| 	const scrollToBottom = () => {
 | |
| 		const element = document.getElementById('messages-container');
 | |
| 		element.scrollTo({
 | |
| 			top: element.scrollHeight,
 | |
| 			behavior: 'smooth'
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	const screenCaptureHandler = async () => {
 | |
| 		try {
 | |
| 			// Request screen media
 | |
| 			const mediaStream = await navigator.mediaDevices.getDisplayMedia({
 | |
| 				video: { cursor: 'never' },
 | |
| 				audio: false
 | |
| 			});
 | |
| 			// Once the user selects a screen, temporarily create a video element
 | |
| 			const video = document.createElement('video');
 | |
| 			video.srcObject = mediaStream;
 | |
| 			// Ensure the video loads without affecting user experience or tab switching
 | |
| 			await video.play();
 | |
| 			// Set up the canvas to match the video dimensions
 | |
| 			const canvas = document.createElement('canvas');
 | |
| 			canvas.width = video.videoWidth;
 | |
| 			canvas.height = video.videoHeight;
 | |
| 			// Grab a single frame from the video stream using the canvas
 | |
| 			const context = canvas.getContext('2d');
 | |
| 			context.drawImage(video, 0, 0, canvas.width, canvas.height);
 | |
| 			// Stop all video tracks (stop screen sharing) after capturing the image
 | |
| 			mediaStream.getTracks().forEach((track) => track.stop());
 | |
| 
 | |
| 			// bring back focus to this current tab, so that the user can see the screen capture
 | |
| 			window.focus();
 | |
| 
 | |
| 			// Convert the canvas to a Base64 image URL
 | |
| 			const imageUrl = canvas.toDataURL('image/png');
 | |
| 			// Add the captured image to the files array to render it
 | |
| 			files = [...files, { type: 'image', url: imageUrl }];
 | |
| 			// Clean memory: Clear video srcObject
 | |
| 			video.srcObject = null;
 | |
| 		} catch (error) {
 | |
| 			// Handle any errors (e.g., user cancels screen sharing)
 | |
| 			console.error('Error capturing screen:', error);
 | |
| 		}
 | |
| 	};
 | |
| 
 | |
| 	const uploadFileHandler = async (file, fullContext: boolean = false) => {
 | |
| 		if ($_user?.role !== 'admin' && !($_user?.permissions?.chat?.file_upload ?? true)) {
 | |
| 			toast.error($i18n.t('You do not have permission to upload files.'));
 | |
| 			return null;
 | |
| 		}
 | |
| 
 | |
| 		const tempItemId = uuidv4();
 | |
| 		const fileItem = {
 | |
| 			type: 'file',
 | |
| 			file: '',
 | |
| 			id: null,
 | |
| 			url: '',
 | |
| 			name: file.name,
 | |
| 			collection_name: '',
 | |
| 			status: 'uploading',
 | |
| 			size: file.size,
 | |
| 			error: '',
 | |
| 			itemId: tempItemId,
 | |
| 			...(fullContext ? { context: 'full' } : {})
 | |
| 		};
 | |
| 
 | |
| 		if (fileItem.size == 0) {
 | |
| 			toast.error($i18n.t('You cannot upload an empty file.'));
 | |
| 			return null;
 | |
| 		}
 | |
| 
 | |
| 		files = [...files, fileItem];
 | |
| 		// Check if the file is an audio file and transcribe/convert it to text file
 | |
| 		if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
 | |
| 			const res = await transcribeAudio(localStorage.token, file).catch((error) => {
 | |
| 				toast.error(`${error}`);
 | |
| 				return null;
 | |
| 			});
 | |
| 
 | |
| 			if (res) {
 | |
| 				console.log(res);
 | |
| 				const blob = new Blob([res.text], { type: 'text/plain' });
 | |
| 				file = blobToFile(blob, `${file.name}.txt`);
 | |
| 
 | |
| 				fileItem.name = file.name;
 | |
| 				fileItem.size = file.size;
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		try {
 | |
| 			// During the file upload, file content is automatically extracted.
 | |
| 			const uploadedFile = await uploadFile(localStorage.token, file);
 | |
| 
 | |
| 			if (uploadedFile) {
 | |
| 				console.log('File upload completed:', {
 | |
| 					id: uploadedFile.id,
 | |
| 					name: fileItem.name,
 | |
| 					collection: uploadedFile?.meta?.collection_name
 | |
| 				});
 | |
| 
 | |
| 				if (uploadedFile.error) {
 | |
| 					console.warn('File upload warning:', uploadedFile.error);
 | |
| 					toast.warning(uploadedFile.error);
 | |
| 				}
 | |
| 
 | |
| 				fileItem.status = 'uploaded';
 | |
| 				fileItem.file = uploadedFile;
 | |
| 				fileItem.id = uploadedFile.id;
 | |
| 				fileItem.collection_name =
 | |
| 					uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
 | |
| 				fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
 | |
| 
 | |
| 				files = files;
 | |
| 			} else {
 | |
| 				files = files.filter((item) => item?.itemId !== tempItemId);
 | |
| 			}
 | |
| 		} catch (e) {
 | |
| 			toast.error(`${e}`);
 | |
| 			files = files.filter((item) => item?.itemId !== tempItemId);
 | |
| 		}
 | |
| 	};
 | |
| 
 | |
| 	const inputFilesHandler = async (inputFiles) => {
 | |
| 		console.log('Input files handler called with:', inputFiles);
 | |
| 		inputFiles.forEach((file) => {
 | |
| 			console.log('Processing file:', {
 | |
| 				name: file.name,
 | |
| 				type: file.type,
 | |
| 				size: file.size,
 | |
| 				extension: file.name.split('.').at(-1)
 | |
| 			});
 | |
| 
 | |
| 			if (
 | |
| 				($config?.file?.max_size ?? null) !== null &&
 | |
| 				file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
 | |
| 			) {
 | |
| 				console.log('File exceeds max size limit:', {
 | |
| 					fileSize: file.size,
 | |
| 					maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
 | |
| 				});
 | |
| 				toast.error(
 | |
| 					$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
 | |
| 						maxSize: $config?.file?.max_size
 | |
| 					})
 | |
| 				);
 | |
| 				return;
 | |
| 			}
 | |
| 
 | |
| 			if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
 | |
| 				if (visionCapableModels.length === 0) {
 | |
| 					toast.error($i18n.t('Selected model(s) do not support image inputs'));
 | |
| 					return;
 | |
| 				}
 | |
| 				let reader = new FileReader();
 | |
| 				reader.onload = async (event) => {
 | |
| 					let imageUrl = event.target.result;
 | |
| 
 | |
| 					if ($settings?.imageCompression ?? false) {
 | |
| 						const width = $settings?.imageCompressionSize?.width ?? null;
 | |
| 						const height = $settings?.imageCompressionSize?.height ?? null;
 | |
| 
 | |
| 						if (width || height) {
 | |
| 							imageUrl = await compressImage(imageUrl, width, height);
 | |
| 						}
 | |
| 					}
 | |
| 
 | |
| 					files = [
 | |
| 						...files,
 | |
| 						{
 | |
| 							type: 'image',
 | |
| 							url: `${imageUrl}`
 | |
| 						}
 | |
| 					];
 | |
| 				};
 | |
| 				reader.readAsDataURL(file);
 | |
| 			} else {
 | |
| 				uploadFileHandler(file);
 | |
| 			}
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	const handleKeyDown = (event: KeyboardEvent) => {
 | |
| 		if (event.key === 'Escape') {
 | |
| 			console.log('Escape');
 | |
| 			dragged = false;
 | |
| 		}
 | |
| 	};
 | |
| 
 | |
| 	const onDragOver = (e) => {
 | |
| 		e.preventDefault();
 | |
| 
 | |
| 		// Check if a file is being dragged.
 | |
| 		if (e.dataTransfer?.types?.includes('Files')) {
 | |
| 			dragged = true;
 | |
| 		} else {
 | |
| 			dragged = false;
 | |
| 		}
 | |
| 	};
 | |
| 
 | |
| 	const onDragLeave = () => {
 | |
| 		dragged = false;
 | |
| 	};
 | |
| 
 | |
| 	const onDrop = async (e) => {
 | |
| 		e.preventDefault();
 | |
| 		console.log(e);
 | |
| 
 | |
| 		if (e.dataTransfer?.files) {
 | |
| 			const inputFiles = Array.from(e.dataTransfer?.files);
 | |
| 			if (inputFiles && inputFiles.length > 0) {
 | |
| 				console.log(inputFiles);
 | |
| 				inputFilesHandler(inputFiles);
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		dragged = false;
 | |
| 	};
 | |
| 
 | |
| 	onMount(async () => {
 | |
| 		loaded = true;
 | |
| 
 | |
| 		window.setTimeout(() => {
 | |
| 			const chatInput = document.getElementById('chat-input');
 | |
| 			chatInput?.focus();
 | |
| 		}, 0);
 | |
| 
 | |
| 		window.addEventListener('keydown', handleKeyDown);
 | |
| 
 | |
| 		await tick();
 | |
| 
 | |
| 		const dropzoneElement = document.getElementById('chat-container');
 | |
| 
 | |
| 		dropzoneElement?.addEventListener('dragover', onDragOver);
 | |
| 		dropzoneElement?.addEventListener('drop', onDrop);
 | |
| 		dropzoneElement?.addEventListener('dragleave', onDragLeave);
 | |
| 	});
 | |
| 
 | |
| 	onDestroy(() => {
 | |
| 		console.log('destroy');
 | |
| 		window.removeEventListener('keydown', handleKeyDown);
 | |
| 
 | |
| 		const dropzoneElement = document.getElementById('chat-container');
 | |
| 
 | |
| 		if (dropzoneElement) {
 | |
| 			dropzoneElement?.removeEventListener('dragover', onDragOver);
 | |
| 			dropzoneElement?.removeEventListener('drop', onDrop);
 | |
| 			dropzoneElement?.removeEventListener('dragleave', onDragLeave);
 | |
| 		}
 | |
| 	});
 | |
| </script>
 | |
| 
 | |
| <FilesOverlay show={dragged} />
 | |
| 
 | |
| {#if loaded}
 | |
| 	<div class="w-full font-primary">
 | |
| 		<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
 | |
| 			<div
 | |
| 				class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
 | |
| 					? 'max-w-full'
 | |
| 					: 'max-w-6xl'} w-full"
 | |
| 			>
 | |
| 				<div class="relative">
 | |
| 					{#if autoScroll === false && history?.currentId}
 | |
| 						<div
 | |
| 							class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
 | |
| 						>
 | |
| 							<button
 | |
| 								class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
 | |
| 								on:click={() => {
 | |
| 									autoScroll = true;
 | |
| 									scrollToBottom();
 | |
| 								}}
 | |
| 							>
 | |
| 								<svg
 | |
| 									xmlns="http://www.w3.org/2000/svg"
 | |
| 									viewBox="0 0 20 20"
 | |
| 									fill="currentColor"
 | |
| 									class="w-5 h-5"
 | |
| 								>
 | |
| 									<path
 | |
| 										fill-rule="evenodd"
 | |
| 										d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
 | |
| 										clip-rule="evenodd"
 | |
| 									/>
 | |
| 								</svg>
 | |
| 							</button>
 | |
| 						</div>
 | |
| 					{/if}
 | |
| 				</div>
 | |
| 
 | |
| 				<div class="w-full relative">
 | |
| 					{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || imageGenerationEnabled}
 | |
| 						<div
 | |
| 							class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white dark:from-gray-900 z-10"
 | |
| 						>
 | |
| 							{#if selectedToolIds.length > 0}
 | |
| 								<div class="flex items-center justify-between w-full">
 | |
| 									<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
 | |
| 										<div class="pl-1">
 | |
| 											<span class="relative flex size-2">
 | |
| 												<span
 | |
| 													class="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"
 | |
| 												/>
 | |
| 												<span class="relative inline-flex rounded-full size-2 bg-yellow-500" />
 | |
| 											</span>
 | |
| 										</div>
 | |
| 										<div class="  text-ellipsis line-clamp-1 flex">
 | |
| 											{#each selectedToolIds.map((id) => {
 | |
| 												return $tools ? $tools.find((t) => t.id === id) : { id: id, name: id };
 | |
| 											}) as tool, toolIdx (toolIdx)}
 | |
| 												<Tooltip
 | |
| 													content={tool?.meta?.description ?? ''}
 | |
| 													className=" {toolIdx !== 0 ? 'pl-0.5' : ''} flex-shrink-0"
 | |
| 													placement="top"
 | |
| 												>
 | |
| 													{tool.name}
 | |
| 												</Tooltip>
 | |
| 
 | |
| 												{#if toolIdx !== selectedToolIds.length - 1}
 | |
| 													<span>, </span>
 | |
| 												{/if}
 | |
| 											{/each}
 | |
| 										</div>
 | |
| 									</div>
 | |
| 								</div>
 | |
| 							{/if}
 | |
| 
 | |
| 							{#if imageGenerationEnabled}
 | |
| 								<div class="flex items-center justify-between w-full">
 | |
| 									<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
 | |
| 										<div class="pl-1">
 | |
| 											<span class="relative flex size-2">
 | |
| 												<span
 | |
| 													class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
 | |
| 												/>
 | |
| 												<span class="relative inline-flex rounded-full size-2 bg-green-500" />
 | |
| 											</span>
 | |
| 										</div>
 | |
| 										<div class=" ">{$i18n.t('Image generation')}</div>
 | |
| 									</div>
 | |
| 								</div>
 | |
| 							{/if}
 | |
| 
 | |
| 							{#if webSearchEnabled}
 | |
| 								<div class="flex items-center justify-between w-full">
 | |
| 									<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
 | |
| 										<div class="pl-1">
 | |
| 											<span class="relative flex size-2">
 | |
| 												<span
 | |
| 													class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
 | |
| 												/>
 | |
| 												<span class="relative inline-flex rounded-full size-2 bg-green-500" />
 | |
| 											</span>
 | |
| 										</div>
 | |
| 										<div class=" ">{$i18n.t('Search the web')}</div>
 | |
| 									</div>
 | |
| 								</div>
 | |
| 							{/if}
 | |
| 
 | |
| 							{#if atSelectedModel !== undefined}
 | |
| 								<div class="flex items-center justify-between w-full">
 | |
| 									<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
 | |
| 										<img
 | |
| 											crossorigin="anonymous"
 | |
| 											alt="model profile"
 | |
| 											class="size-3.5 max-w-[28px] object-cover rounded-full"
 | |
| 											src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
 | |
| 												?.profile_image_url ??
 | |
| 												($i18n.language === 'dg-DG'
 | |
| 													? `/doge.png`
 | |
| 													: `${WEBUI_BASE_URL}/static/favicon.png`)}
 | |
| 										/>
 | |
| 										<div class="translate-y-[0.5px]">
 | |
| 											Talking to <span class=" font-medium">{atSelectedModel.name}</span>
 | |
| 										</div>
 | |
| 									</div>
 | |
| 									<div>
 | |
| 										<button
 | |
| 											class="flex items-center dark:text-gray-500"
 | |
| 											on:click={() => {
 | |
| 												atSelectedModel = undefined;
 | |
| 											}}
 | |
| 										>
 | |
| 											<XMark />
 | |
| 										</button>
 | |
| 									</div>
 | |
| 								</div>
 | |
| 							{/if}
 | |
| 						</div>
 | |
| 					{/if}
 | |
| 
 | |
| 					<Commands
 | |
| 						bind:this={commandsElement}
 | |
| 						bind:prompt
 | |
| 						bind:files
 | |
| 						on:upload={(e) => {
 | |
| 							dispatch('upload', e.detail);
 | |
| 						}}
 | |
| 						on:select={(e) => {
 | |
| 							const data = e.detail;
 | |
| 
 | |
| 							if (data?.type === 'model') {
 | |
| 								atSelectedModel = data.data;
 | |
| 							}
 | |
| 
 | |
| 							const chatInputElement = document.getElementById('chat-input');
 | |
| 							chatInputElement?.focus();
 | |
| 						}}
 | |
| 					/>
 | |
| 				</div>
 | |
| 			</div>
 | |
| 		</div>
 | |
| 
 | |
| 		<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
 | |
| 			<div
 | |
| 				class="{($settings?.widescreenMode ?? null)
 | |
| 					? 'max-w-full'
 | |
| 					: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
 | |
| 			>
 | |
| 				<div class="">
 | |
| 					<input
 | |
| 						bind:this={filesInputElement}
 | |
| 						bind:files={inputFiles}
 | |
| 						type="file"
 | |
| 						hidden
 | |
| 						multiple
 | |
| 						on:change={async () => {
 | |
| 							if (inputFiles && inputFiles.length > 0) {
 | |
| 								const _inputFiles = Array.from(inputFiles);
 | |
| 								inputFilesHandler(_inputFiles);
 | |
| 							} else {
 | |
| 								toast.error($i18n.t(`File not found.`));
 | |
| 							}
 | |
| 
 | |
| 							filesInputElement.value = '';
 | |
| 						}}
 | |
| 					/>
 | |
| 
 | |
| 					{#if recording}
 | |
| 						<VoiceRecording
 | |
| 							bind:recording
 | |
| 							on:cancel={async () => {
 | |
| 								recording = false;
 | |
| 
 | |
| 								await tick();
 | |
| 								document.getElementById('chat-input')?.focus();
 | |
| 							}}
 | |
| 							on:confirm={async (e) => {
 | |
| 								const { text, filename } = e.detail;
 | |
| 								prompt = `${prompt}${text} `;
 | |
| 
 | |
| 								recording = false;
 | |
| 
 | |
| 								await tick();
 | |
| 								document.getElementById('chat-input')?.focus();
 | |
| 
 | |
| 								if ($settings?.speechAutoSend ?? false) {
 | |
| 									dispatch('submit', prompt);
 | |
| 								}
 | |
| 							}}
 | |
| 						/>
 | |
| 					{:else}
 | |
| 						<form
 | |
| 							class="w-full flex gap-1.5"
 | |
| 							on:submit|preventDefault={() => {
 | |
| 								// check if selectedModels support image input
 | |
| 								dispatch('submit', prompt);
 | |
| 							}}
 | |
| 						>
 | |
| 							<div
 | |
| 								class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
 | |
| 								dir={$settings?.chatDirection ?? 'LTR'}
 | |
| 							>
 | |
| 								{#if files.length > 0}
 | |
| 									<div class="mx-1 mt-2.5 mb-1 flex items-center flex-wrap gap-2">
 | |
| 										{#each files as file, fileIdx}
 | |
| 											{#if file.type === 'image'}
 | |
| 												<div class=" relative group">
 | |
| 													<div class="relative flex items-center">
 | |
| 														<Image
 | |
| 															src={file.url}
 | |
| 															alt="input"
 | |
| 															imageClassName=" size-14 rounded-xl object-cover"
 | |
| 														/>
 | |
| 														{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
 | |
| 															<Tooltip
 | |
| 																className=" absolute top-1 left-1"
 | |
| 																content={$i18n.t('{{ models }}', {
 | |
| 																	models: [
 | |
| 																		...(atSelectedModel ? [atSelectedModel] : selectedModels)
 | |
| 																	]
 | |
| 																		.filter((id) => !visionCapableModels.includes(id))
 | |
| 																		.join(', ')
 | |
| 																})}
 | |
| 															>
 | |
| 																<svg
 | |
| 																	xmlns="http://www.w3.org/2000/svg"
 | |
| 																	viewBox="0 0 24 24"
 | |
| 																	fill="currentColor"
 | |
| 																	class="size-4 fill-yellow-300"
 | |
| 																>
 | |
| 																	<path
 | |
| 																		fill-rule="evenodd"
 | |
| 																		d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
 | |
| 																		clip-rule="evenodd"
 | |
| 																	/>
 | |
| 																</svg>
 | |
| 															</Tooltip>
 | |
| 														{/if}
 | |
| 													</div>
 | |
| 													<div class=" absolute -top-1 -right-1">
 | |
| 														<button
 | |
| 															class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
 | |
| 															type="button"
 | |
| 															on:click={() => {
 | |
| 																files.splice(fileIdx, 1);
 | |
| 																files = files;
 | |
| 															}}
 | |
| 														>
 | |
| 															<svg
 | |
| 																xmlns="http://www.w3.org/2000/svg"
 | |
| 																viewBox="0 0 20 20"
 | |
| 																fill="currentColor"
 | |
| 																class="w-4 h-4"
 | |
| 															>
 | |
| 																<path
 | |
| 																	d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
 | |
| 																/>
 | |
| 															</svg>
 | |
| 														</button>
 | |
| 													</div>
 | |
| 												</div>
 | |
| 											{:else}
 | |
| 												<FileItem
 | |
| 													item={file}
 | |
| 													name={file.name}
 | |
| 													type={file.type}
 | |
| 													size={file?.size}
 | |
| 													loading={file.status === 'uploading'}
 | |
| 													dismissible={true}
 | |
| 													edit={true}
 | |
| 													on:dismiss={async () => {
 | |
| 														if (file.type !== 'collection' && !file?.collection) {
 | |
| 															if (file.id) {
 | |
| 																// This will handle both file deletion and Chroma cleanup
 | |
| 																await deleteFileById(localStorage.token, file.id);
 | |
| 															}
 | |
| 														}
 | |
| 
 | |
| 														// Remove from UI state
 | |
| 														files.splice(fileIdx, 1);
 | |
| 														files = files;
 | |
| 													}}
 | |
| 													on:click={() => {
 | |
| 														console.log(file);
 | |
| 													}}
 | |
| 												/>
 | |
| 											{/if}
 | |
| 										{/each}
 | |
| 									</div>
 | |
| 								{/if}
 | |
| 
 | |
| 								<div class=" flex">
 | |
| 									<div class="ml-1 self-end mb-1.5 flex space-x-1">
 | |
| 										<InputMenu
 | |
| 											bind:imageGenerationEnabled
 | |
| 											bind:webSearchEnabled
 | |
| 											bind:selectedToolIds
 | |
| 											{screenCaptureHandler}
 | |
| 											uploadFilesHandler={() => {
 | |
| 												filesInputElement.click();
 | |
| 											}}
 | |
| 											uploadGoogleDriveHandler={async () => {
 | |
| 												try {
 | |
| 													const fileData = await createPicker();
 | |
| 													if (fileData) {
 | |
| 														const file = new File([fileData.blob], fileData.name, {
 | |
| 															type: fileData.blob.type
 | |
| 														});
 | |
| 														await uploadFileHandler(file);
 | |
| 													} else {
 | |
| 														console.log('No file was selected from Google Drive');
 | |
| 													}
 | |
| 												} catch (error) {
 | |
| 													console.error('Google Drive Error:', error);
 | |
| 													toast.error(
 | |
| 														$i18n.t('Error accessing Google Drive: {{error}}', {
 | |
| 															error: error.message
 | |
| 														})
 | |
| 													);
 | |
| 												}
 | |
| 											}}
 | |
| 											onClose={async () => {
 | |
| 												await tick();
 | |
| 
 | |
| 												const chatInput = document.getElementById('chat-input');
 | |
| 												chatInput?.focus();
 | |
| 											}}
 | |
| 										>
 | |
| 											<button
 | |
| 												class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
 | |
| 												type="button"
 | |
| 												aria-label="More"
 | |
| 											>
 | |
| 												<svg
 | |
| 													xmlns="http://www.w3.org/2000/svg"
 | |
| 													viewBox="0 0 20 20"
 | |
| 													fill="currentColor"
 | |
| 													class="size-5"
 | |
| 												>
 | |
| 													<path
 | |
| 														d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
 | |
| 													/>
 | |
| 												</svg>
 | |
| 											</button>
 | |
| 										</InputMenu>
 | |
| 									</div>
 | |
| 
 | |
| 									{#if $settings?.richTextInput ?? true}
 | |
| 										<div
 | |
| 											class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
 | |
| 										>
 | |
| 											<RichTextInput
 | |
| 												bind:this={chatInputElement}
 | |
| 												bind:value={prompt}
 | |
| 												id="chat-input"
 | |
| 												messageInput={true}
 | |
| 												shiftEnter={!$mobile ||
 | |
| 													!(
 | |
| 														'ontouchstart' in window ||
 | |
| 														navigator.maxTouchPoints > 0 ||
 | |
| 														navigator.msMaxTouchPoints > 0
 | |
| 													)}
 | |
| 												placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
 | |
| 												largeTextAsFile={$settings?.largeTextAsFile ?? false}
 | |
| 												autocomplete={true}
 | |
| 												generateAutoCompletion={async (text) => {
 | |
| 													if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
 | |
| 														toast.error($i18n.t('Please select a model first.'));
 | |
| 													}
 | |
| 
 | |
| 													const res = await generateAutoCompletion(
 | |
| 														localStorage.token,
 | |
| 														selectedModelIds.at(0),
 | |
| 														text,
 | |
| 														history?.currentId
 | |
| 															? createMessagesList(history, history.currentId)
 | |
| 															: null
 | |
| 													).catch((error) => {
 | |
| 														console.log(error);
 | |
| 
 | |
| 														return null;
 | |
| 													});
 | |
| 
 | |
| 													console.log(res);
 | |
| 													return res;
 | |
| 												}}
 | |
| 												on:keydown={async (e) => {
 | |
| 													e = e.detail.event;
 | |
| 
 | |
| 													const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
 | |
| 													const commandsContainerElement =
 | |
| 														document.getElementById('commands-container');
 | |
| 
 | |
| 													if (e.key === 'Escape') {
 | |
| 														stopResponse();
 | |
| 													}
 | |
| 
 | |
| 													// Command/Ctrl + Shift + Enter to submit a message pair
 | |
| 													if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
 | |
| 														e.preventDefault();
 | |
| 														createMessagePair(prompt);
 | |
| 													}
 | |
| 
 | |
| 													// Check if Ctrl + R is pressed
 | |
| 													if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
 | |
| 														e.preventDefault();
 | |
| 														console.log('regenerate');
 | |
| 
 | |
| 														const regenerateButton = [
 | |
| 															...document.getElementsByClassName('regenerate-response-button')
 | |
| 														]?.at(-1);
 | |
| 
 | |
| 														regenerateButton?.click();
 | |
| 													}
 | |
| 
 | |
| 													if (prompt === '' && e.key == 'ArrowUp') {
 | |
| 														e.preventDefault();
 | |
| 
 | |
| 														const userMessageElement = [
 | |
| 															...document.getElementsByClassName('user-message')
 | |
| 														]?.at(-1);
 | |
| 
 | |
| 														if (userMessageElement) {
 | |
| 															userMessageElement.scrollIntoView({ block: 'center' });
 | |
| 															const editButton = [
 | |
| 																...document.getElementsByClassName('edit-user-message-button')
 | |
| 															]?.at(-1);
 | |
| 
 | |
| 															editButton?.click();
 | |
| 														}
 | |
| 													}
 | |
| 
 | |
| 													if (commandsContainerElement) {
 | |
| 														if (commandsContainerElement && e.key === 'ArrowUp') {
 | |
| 															e.preventDefault();
 | |
| 															commandsElement.selectUp();
 | |
| 
 | |
| 															const commandOptionButton = [
 | |
| 																...document.getElementsByClassName('selected-command-option-button')
 | |
| 															]?.at(-1);
 | |
| 															commandOptionButton.scrollIntoView({ block: 'center' });
 | |
| 														}
 | |
| 
 | |
| 														if (commandsContainerElement && e.key === 'ArrowDown') {
 | |
| 															e.preventDefault();
 | |
| 															commandsElement.selectDown();
 | |
| 
 | |
| 															const commandOptionButton = [
 | |
| 																...document.getElementsByClassName('selected-command-option-button')
 | |
| 															]?.at(-1);
 | |
| 															commandOptionButton.scrollIntoView({ block: 'center' });
 | |
| 														}
 | |
| 
 | |
| 														if (commandsContainerElement && e.key === 'Tab') {
 | |
| 															e.preventDefault();
 | |
| 
 | |
| 															const commandOptionButton = [
 | |
| 																...document.getElementsByClassName('selected-command-option-button')
 | |
| 															]?.at(-1);
 | |
| 
 | |
| 															commandOptionButton?.click();
 | |
| 														}
 | |
| 
 | |
| 														if (commandsContainerElement && e.key === 'Enter') {
 | |
| 															e.preventDefault();
 | |
| 
 | |
| 															const commandOptionButton = [
 | |
| 																...document.getElementsByClassName('selected-command-option-button')
 | |
| 															]?.at(-1);
 | |
| 
 | |
| 															if (commandOptionButton) {
 | |
| 																commandOptionButton?.click();
 | |
| 															} else {
 | |
| 																document.getElementById('send-message-button')?.click();
 | |
| 															}
 | |
| 														}
 | |
| 													} else {
 | |
| 														if (
 | |
| 															!$mobile ||
 | |
| 															!(
 | |
| 																'ontouchstart' in window ||
 | |
| 																navigator.maxTouchPoints > 0 ||
 | |
| 																navigator.msMaxTouchPoints > 0
 | |
| 															)
 | |
| 														) {
 | |
| 															// Prevent Enter key from creating a new line
 | |
| 															// Uses keyCode '13' for Enter key for chinese/japanese keyboards
 | |
| 															if (e.keyCode === 13 && !e.shiftKey) {
 | |
| 																e.preventDefault();
 | |
| 															}
 | |
| 
 | |
| 															// Submit the prompt when Enter key is pressed
 | |
| 															if (prompt !== '' && e.keyCode === 13 && !e.shiftKey) {
 | |
| 																dispatch('submit', prompt);
 | |
| 															}
 | |
| 														}
 | |
| 													}
 | |
| 
 | |
| 													if (e.key === 'Escape') {
 | |
| 														console.log('Escape');
 | |
| 														atSelectedModel = undefined;
 | |
| 														selectedToolIds = [];
 | |
| 														webSearchEnabled = false;
 | |
| 														imageGenerationEnabled = false;
 | |
| 													}
 | |
| 												}}
 | |
| 												on:paste={async (e) => {
 | |
| 													e = e.detail.event;
 | |
| 													console.log(e);
 | |
| 
 | |
| 													const clipboardData = e.clipboardData || window.clipboardData;
 | |
| 
 | |
| 													if (clipboardData && clipboardData.items) {
 | |
| 														for (const item of clipboardData.items) {
 | |
| 															if (item.type.indexOf('image') !== -1) {
 | |
| 																const blob = item.getAsFile();
 | |
| 																const reader = new FileReader();
 | |
| 
 | |
| 																reader.onload = function (e) {
 | |
| 																	files = [
 | |
| 																		...files,
 | |
| 																		{
 | |
| 																			type: 'image',
 | |
| 																			url: `${e.target.result}`
 | |
| 																		}
 | |
| 																	];
 | |
| 																};
 | |
| 
 | |
| 																reader.readAsDataURL(blob);
 | |
| 															} else if (item.type === 'text/plain') {
 | |
| 																if ($settings?.largeTextAsFile ?? false) {
 | |
| 																	const text = clipboardData.getData('text/plain');
 | |
| 
 | |
| 																	if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
 | |
| 																		e.preventDefault();
 | |
| 																		const blob = new Blob([text], { type: 'text/plain' });
 | |
| 																		const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, {
 | |
| 																			type: 'text/plain'
 | |
| 																		});
 | |
| 
 | |
| 																		await uploadFileHandler(file, true);
 | |
| 																	}
 | |
| 																}
 | |
| 															}
 | |
| 														}
 | |
| 													}
 | |
| 												}}
 | |
| 											/>
 | |
| 										</div>
 | |
| 									{:else}
 | |
| 										<textarea
 | |
| 											id="chat-input"
 | |
| 											bind:this={chatInputElement}
 | |
| 											class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
 | |
| 											placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
 | |
| 											bind:value={prompt}
 | |
| 											on:keypress={(e) => {
 | |
| 												if (
 | |
| 													!$mobile ||
 | |
| 													!(
 | |
| 														'ontouchstart' in window ||
 | |
| 														navigator.maxTouchPoints > 0 ||
 | |
| 														navigator.msMaxTouchPoints > 0
 | |
| 													)
 | |
| 												) {
 | |
| 													// Prevent Enter key from creating a new line
 | |
| 													if (e.key === 'Enter' && !e.shiftKey) {
 | |
| 														e.preventDefault();
 | |
| 													}
 | |
| 
 | |
| 													// Submit the prompt when Enter key is pressed
 | |
| 													if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
 | |
| 														dispatch('submit', prompt);
 | |
| 													}
 | |
| 												}
 | |
| 											}}
 | |
| 											on:keydown={async (e) => {
 | |
| 												const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
 | |
| 												const commandsContainerElement =
 | |
| 													document.getElementById('commands-container');
 | |
| 
 | |
| 												if (e.key === 'Escape') {
 | |
| 													stopResponse();
 | |
| 												}
 | |
| 												// Command/Ctrl + Shift + Enter to submit a message pair
 | |
| 												if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
 | |
| 													e.preventDefault();
 | |
| 													createMessagePair(prompt);
 | |
| 												}
 | |
| 
 | |
| 												// Check if Ctrl + R is pressed
 | |
| 												if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
 | |
| 													e.preventDefault();
 | |
| 													console.log('regenerate');
 | |
| 
 | |
| 													const regenerateButton = [
 | |
| 														...document.getElementsByClassName('regenerate-response-button')
 | |
| 													]?.at(-1);
 | |
| 
 | |
| 													regenerateButton?.click();
 | |
| 												}
 | |
| 
 | |
| 												if (prompt === '' && e.key == 'ArrowUp') {
 | |
| 													e.preventDefault();
 | |
| 
 | |
| 													const userMessageElement = [
 | |
| 														...document.getElementsByClassName('user-message')
 | |
| 													]?.at(-1);
 | |
| 
 | |
| 													const editButton = [
 | |
| 														...document.getElementsByClassName('edit-user-message-button')
 | |
| 													]?.at(-1);
 | |
| 
 | |
| 													console.log(userMessageElement);
 | |
| 
 | |
| 													userMessageElement.scrollIntoView({ block: 'center' });
 | |
| 													editButton?.click();
 | |
| 												}
 | |
| 
 | |
| 												if (commandsContainerElement && e.key === 'ArrowUp') {
 | |
| 													e.preventDefault();
 | |
| 													commandsElement.selectUp();
 | |
| 
 | |
| 													const commandOptionButton = [
 | |
| 														...document.getElementsByClassName('selected-command-option-button')
 | |
| 													]?.at(-1);
 | |
| 													commandOptionButton.scrollIntoView({ block: 'center' });
 | |
| 												}
 | |
| 
 | |
| 												if (commandsContainerElement && e.key === 'ArrowDown') {
 | |
| 													e.preventDefault();
 | |
| 													commandsElement.selectDown();
 | |
| 
 | |
| 													const commandOptionButton = [
 | |
| 														...document.getElementsByClassName('selected-command-option-button')
 | |
| 													]?.at(-1);
 | |
| 													commandOptionButton.scrollIntoView({ block: 'center' });
 | |
| 												}
 | |
| 
 | |
| 												if (commandsContainerElement && e.key === 'Enter') {
 | |
| 													e.preventDefault();
 | |
| 
 | |
| 													const commandOptionButton = [
 | |
| 														...document.getElementsByClassName('selected-command-option-button')
 | |
| 													]?.at(-1);
 | |
| 
 | |
| 													if (e.shiftKey) {
 | |
| 														prompt = `${prompt}\n`;
 | |
| 													} else if (commandOptionButton) {
 | |
| 														commandOptionButton?.click();
 | |
| 													} else {
 | |
| 														document.getElementById('send-message-button')?.click();
 | |
| 													}
 | |
| 												}
 | |
| 
 | |
| 												if (commandsContainerElement && e.key === 'Tab') {
 | |
| 													e.preventDefault();
 | |
| 
 | |
| 													const commandOptionButton = [
 | |
| 														...document.getElementsByClassName('selected-command-option-button')
 | |
| 													]?.at(-1);
 | |
| 
 | |
| 													commandOptionButton?.click();
 | |
| 												} else if (e.key === 'Tab') {
 | |
| 													const words = findWordIndices(prompt);
 | |
| 
 | |
| 													if (words.length > 0) {
 | |
| 														const word = words.at(0);
 | |
| 														const fullPrompt = prompt;
 | |
| 
 | |
| 														prompt = prompt.substring(0, word?.endIndex + 1);
 | |
| 														await tick();
 | |
| 
 | |
| 														e.target.scrollTop = e.target.scrollHeight;
 | |
| 														prompt = fullPrompt;
 | |
| 														await tick();
 | |
| 
 | |
| 														e.preventDefault();
 | |
| 														e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
 | |
| 													}
 | |
| 
 | |
| 													e.target.style.height = '';
 | |
| 													e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
 | |
| 												}
 | |
| 
 | |
| 												if (e.key === 'Escape') {
 | |
| 													console.log('Escape');
 | |
| 													atSelectedModel = undefined;
 | |
| 													selectedToolIds = [];
 | |
| 													webSearchEnabled = false;
 | |
| 													imageGenerationEnabled = false;
 | |
| 												}
 | |
| 											}}
 | |
| 											rows="1"
 | |
| 											on:input={async (e) => {
 | |
| 												e.target.style.height = '';
 | |
| 												e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
 | |
| 											}}
 | |
| 											on:focus={async (e) => {
 | |
| 												e.target.style.height = '';
 | |
| 												e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
 | |
| 											}}
 | |
| 											on:paste={async (e) => {
 | |
| 												const clipboardData = e.clipboardData || window.clipboardData;
 | |
| 
 | |
| 												if (clipboardData && clipboardData.items) {
 | |
| 													for (const item of clipboardData.items) {
 | |
| 														if (item.type.indexOf('image') !== -1) {
 | |
| 															const blob = item.getAsFile();
 | |
| 															const reader = new FileReader();
 | |
| 
 | |
| 															reader.onload = function (e) {
 | |
| 																files = [
 | |
| 																	...files,
 | |
| 																	{
 | |
| 																		type: 'image',
 | |
| 																		url: `${e.target.result}`
 | |
| 																	}
 | |
| 																];
 | |
| 															};
 | |
| 
 | |
| 															reader.readAsDataURL(blob);
 | |
| 														} else if (item.type === 'text/plain') {
 | |
| 															if ($settings?.largeTextAsFile ?? false) {
 | |
| 																const text = clipboardData.getData('text/plain');
 | |
| 
 | |
| 																if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
 | |
| 																	e.preventDefault();
 | |
| 																	const blob = new Blob([text], { type: 'text/plain' });
 | |
| 																	const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, {
 | |
| 																		type: 'text/plain'
 | |
| 																	});
 | |
| 
 | |
| 																	await uploadFileHandler(file, true);
 | |
| 																}
 | |
| 															}
 | |
| 														}
 | |
| 													}
 | |
| 												}
 | |
| 											}}
 | |
| 										/>
 | |
| 									{/if}
 | |
| 
 | |
| 									<div class="self-end mb-1.5 flex space-x-1 mr-1">
 | |
| 										{#if !history?.currentId || history.messages[history.currentId]?.done == true}
 | |
| 											<Tooltip content={$i18n.t('Record voice')}>
 | |
| 												<button
 | |
| 													id="voice-input-button"
 | |
| 													class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
 | |
| 													type="button"
 | |
| 													on:click={async () => {
 | |
| 														try {
 | |
| 															let stream = await navigator.mediaDevices
 | |
| 																.getUserMedia({ audio: true })
 | |
| 																.catch(function (err) {
 | |
| 																	toast.error(
 | |
| 																		$i18n.t(
 | |
| 																			`Permission denied when accessing microphone: {{error}}`,
 | |
| 																			{
 | |
| 																				error: err
 | |
| 																			}
 | |
| 																		)
 | |
| 																	);
 | |
| 																	return null;
 | |
| 																});
 | |
| 
 | |
| 															if (stream) {
 | |
| 																recording = true;
 | |
| 																const tracks = stream.getTracks();
 | |
| 																tracks.forEach((track) => track.stop());
 | |
| 															}
 | |
| 															stream = null;
 | |
| 														} catch {
 | |
| 															toast.error($i18n.t('Permission denied when accessing microphone'));
 | |
| 														}
 | |
| 													}}
 | |
| 													aria-label="Voice Input"
 | |
| 												>
 | |
| 													<svg
 | |
| 														xmlns="http://www.w3.org/2000/svg"
 | |
| 														viewBox="0 0 20 20"
 | |
| 														fill="currentColor"
 | |
| 														class="w-5 h-5 translate-y-[0.5px]"
 | |
| 													>
 | |
| 														<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
 | |
| 														<path
 | |
| 															d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
 | |
| 														/>
 | |
| 													</svg>
 | |
| 												</button>
 | |
| 											</Tooltip>
 | |
| 										{/if}
 | |
| 
 | |
| 										{#if !history.currentId || history.messages[history.currentId]?.done == true}
 | |
| 											{#if prompt === ''}
 | |
| 												<div class=" flex items-center">
 | |
| 													<Tooltip content={$i18n.t('Call')}>
 | |
| 														<button
 | |
| 															class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-2 self-center"
 | |
| 															type="button"
 | |
| 															on:click={async () => {
 | |
| 																if (selectedModels.length > 1) {
 | |
| 																	toast.error($i18n.t('Select only one model to call'));
 | |
| 
 | |
| 																	return;
 | |
| 																}
 | |
| 
 | |
| 																if ($config.audio.stt.engine === 'web') {
 | |
| 																	toast.error(
 | |
| 																		$i18n.t(
 | |
| 																			'Call feature is not supported when using Web STT engine'
 | |
| 																		)
 | |
| 																	);
 | |
| 
 | |
| 																	return;
 | |
| 																}
 | |
| 																// check if user has access to getUserMedia
 | |
| 																try {
 | |
| 																	let stream = await navigator.mediaDevices.getUserMedia({
 | |
| 																		audio: true
 | |
| 																	});
 | |
| 																	// If the user grants the permission, proceed to show the call overlay
 | |
| 
 | |
| 																	if (stream) {
 | |
| 																		const tracks = stream.getTracks();
 | |
| 																		tracks.forEach((track) => track.stop());
 | |
| 																	}
 | |
| 
 | |
| 																	stream = null;
 | |
| 
 | |
| 																	showCallOverlay.set(true);
 | |
| 																	showControls.set(true);
 | |
| 																} catch (err) {
 | |
| 																	// If the user denies the permission or an error occurs, show an error message
 | |
| 																	toast.error(
 | |
| 																		$i18n.t('Permission denied when accessing media devices')
 | |
| 																	);
 | |
| 																}
 | |
| 															}}
 | |
| 															aria-label="Call"
 | |
| 														>
 | |
| 															<Headphone className="size-5" />
 | |
| 														</button>
 | |
| 													</Tooltip>
 | |
| 												</div>
 | |
| 											{:else}
 | |
| 												<div class=" flex items-center">
 | |
| 													<Tooltip content={$i18n.t('Send message')}>
 | |
| 														<button
 | |
| 															id="send-message-button"
 | |
| 															class="{prompt !== ''
 | |
| 																? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
 | |
| 																: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
 | |
| 															type="submit"
 | |
| 															disabled={prompt === ''}
 | |
| 														>
 | |
| 															<svg
 | |
| 																xmlns="http://www.w3.org/2000/svg"
 | |
| 																viewBox="0 0 16 16"
 | |
| 																fill="currentColor"
 | |
| 																class="size-6"
 | |
| 															>
 | |
| 																<path
 | |
| 																	fill-rule="evenodd"
 | |
| 																	d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
 | |
| 																	clip-rule="evenodd"
 | |
| 																/>
 | |
| 															</svg>
 | |
| 														</button>
 | |
| 													</Tooltip>
 | |
| 												</div>
 | |
| 											{/if}
 | |
| 										{:else}
 | |
| 											<div class=" flex items-center">
 | |
| 												<Tooltip content={$i18n.t('Stop')}>
 | |
| 													<button
 | |
| 														class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
 | |
| 														on:click={() => {
 | |
| 															stopResponse();
 | |
| 														}}
 | |
| 													>
 | |
| 														<svg
 | |
| 															xmlns="http://www.w3.org/2000/svg"
 | |
| 															viewBox="0 0 24 24"
 | |
| 															fill="currentColor"
 | |
| 															class="size-6"
 | |
| 														>
 | |
| 															<path
 | |
| 																fill-rule="evenodd"
 | |
| 																d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
 | |
| 																clip-rule="evenodd"
 | |
| 															/>
 | |
| 														</svg>
 | |
| 													</button>
 | |
| 												</Tooltip>
 | |
| 											</div>
 | |
| 										{/if}
 | |
| 									</div>
 | |
| 								</div>
 | |
| 							</div>
 | |
| 						</form>
 | |
| 					{/if}
 | |
| 				</div>
 | |
| 			</div>
 | |
| 		</div>
 | |
| 	</div>
 | |
| {/if}
 |