forked from open-webui/open-webui
		
	feat: @model group convo
This commit is contained in:
		
							parent
							
								
									358f79f533
								
							
						
					
					
						commit
						70029d9bed
					
				
					 7 changed files with 261 additions and 15 deletions
				
			
		| 
						 | 
				
			
			@ -10,6 +10,7 @@
 | 
			
		|||
	import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
 | 
			
		||||
	import { SUPPORTED_FILE_TYPE } from '$lib/constants';
 | 
			
		||||
	import Documents from './MessageInput/Documents.svelte';
 | 
			
		||||
	import Models from './MessageInput/Models.svelte';
 | 
			
		||||
 | 
			
		||||
	export let submitPrompt: Function;
 | 
			
		||||
	export let stopResponse: Function;
 | 
			
		||||
| 
						 | 
				
			
			@ -18,12 +19,17 @@
 | 
			
		|||
	export let autoScroll = true;
 | 
			
		||||
 | 
			
		||||
	let filesInputElement;
 | 
			
		||||
 | 
			
		||||
	let promptsElement;
 | 
			
		||||
	let documentsElement;
 | 
			
		||||
	let modelsElement;
 | 
			
		||||
 | 
			
		||||
	let inputFiles;
 | 
			
		||||
	let dragged = false;
 | 
			
		||||
 | 
			
		||||
	let user = null;
 | 
			
		||||
	let chatInputPlaceholder = '';
 | 
			
		||||
 | 
			
		||||
	export let files = [];
 | 
			
		||||
 | 
			
		||||
	export let fileUploadEnabled = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +41,15 @@
 | 
			
		|||
 | 
			
		||||
	let speechRecognition;
 | 
			
		||||
 | 
			
		||||
	$: if (prompt) {
 | 
			
		||||
		const chatInput = document.getElementById('chat-textarea');
 | 
			
		||||
 | 
			
		||||
		if (chatInput) {
 | 
			
		||||
			chatInput.style.height = '';
 | 
			
		||||
			chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const speechRecognitionHandler = () => {
 | 
			
		||||
		// Check if SpeechRecognition is supported
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +94,7 @@
 | 
			
		|||
					console.log('recognition ended');
 | 
			
		||||
					speechRecognitionListening = false;
 | 
			
		||||
					if (prompt !== '' && $settings?.speechAutoSend === true) {
 | 
			
		||||
						submitPrompt(prompt);
 | 
			
		||||
						submitPrompt(prompt, user);
 | 
			
		||||
					}
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -242,6 +257,14 @@
 | 
			
		|||
							];
 | 
			
		||||
						}}
 | 
			
		||||
					/>
 | 
			
		||||
				{:else if prompt.charAt(0) === '@'}
 | 
			
		||||
					<Models
 | 
			
		||||
						bind:this={modelsElement}
 | 
			
		||||
						bind:prompt
 | 
			
		||||
						bind:user
 | 
			
		||||
						bind:chatInputPlaceholder
 | 
			
		||||
						{messages}
 | 
			
		||||
					/>
 | 
			
		||||
				{:else if messages.length == 0 && suggestionPrompts.length !== 0}
 | 
			
		||||
					<Suggestions {suggestionPrompts} {submitPrompt} />
 | 
			
		||||
				{/if}
 | 
			
		||||
| 
						 | 
				
			
			@ -289,7 +312,7 @@
 | 
			
		|||
				<form
 | 
			
		||||
					class=" flex flex-col relative w-full rounded-xl border dark:border-gray-600 bg-white dark:bg-gray-800 dark:text-gray-100"
 | 
			
		||||
					on:submit|preventDefault={() => {
 | 
			
		||||
						submitPrompt(prompt);
 | 
			
		||||
						submitPrompt(prompt, user);
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					{#if files.length > 0}
 | 
			
		||||
| 
						 | 
				
			
			@ -431,14 +454,18 @@
 | 
			
		|||
							class=" dark:bg-gray-800 dark:text-gray-100 outline-none w-full py-3 px-2 {fileUploadEnabled
 | 
			
		||||
								? ''
 | 
			
		||||
								: ' pl-4'} rounded-xl resize-none h-[48px]"
 | 
			
		||||
							placeholder={speechRecognitionListening ? 'Listening...' : 'Send a message'}
 | 
			
		||||
							placeholder={chatInputPlaceholder !== ''
 | 
			
		||||
								? chatInputPlaceholder
 | 
			
		||||
								: speechRecognitionListening
 | 
			
		||||
								? 'Listening...'
 | 
			
		||||
								: 'Send a message'}
 | 
			
		||||
							bind:value={prompt}
 | 
			
		||||
							on:keypress={(e) => {
 | 
			
		||||
								if (e.keyCode == 13 && !e.shiftKey) {
 | 
			
		||||
									e.preventDefault();
 | 
			
		||||
								}
 | 
			
		||||
								if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) {
 | 
			
		||||
									submitPrompt(prompt);
 | 
			
		||||
									submitPrompt(prompt, user);
 | 
			
		||||
								}
 | 
			
		||||
							}}
 | 
			
		||||
							on:keydown={async (e) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -473,10 +500,10 @@
 | 
			
		|||
									editButton?.click();
 | 
			
		||||
								}
 | 
			
		||||
 | 
			
		||||
								if (['/', '#'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
 | 
			
		||||
								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
 | 
			
		||||
									e.preventDefault();
 | 
			
		||||
 | 
			
		||||
									(promptsElement || documentsElement).selectUp();
 | 
			
		||||
									(promptsElement || documentsElement || modelsElement).selectUp();
 | 
			
		||||
 | 
			
		||||
									const commandOptionButton = [
 | 
			
		||||
										...document.getElementsByClassName('selected-command-option-button')
 | 
			
		||||
| 
						 | 
				
			
			@ -484,10 +511,10 @@
 | 
			
		|||
									commandOptionButton.scrollIntoView({ block: 'center' });
 | 
			
		||||
								}
 | 
			
		||||
 | 
			
		||||
								if (['/', '#'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
 | 
			
		||||
								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
 | 
			
		||||
									e.preventDefault();
 | 
			
		||||
 | 
			
		||||
									(promptsElement || documentsElement).selectDown();
 | 
			
		||||
									(promptsElement || documentsElement || modelsElement).selectDown();
 | 
			
		||||
 | 
			
		||||
									const commandOptionButton = [
 | 
			
		||||
										...document.getElementsByClassName('selected-command-option-button')
 | 
			
		||||
| 
						 | 
				
			
			@ -495,7 +522,7 @@
 | 
			
		|||
									commandOptionButton.scrollIntoView({ block: 'center' });
 | 
			
		||||
								}
 | 
			
		||||
 | 
			
		||||
								if (['/', '#'].includes(prompt.charAt(0)) && e.key === 'Enter') {
 | 
			
		||||
								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
 | 
			
		||||
									e.preventDefault();
 | 
			
		||||
 | 
			
		||||
									const commandOptionButton = [
 | 
			
		||||
| 
						 | 
				
			
			@ -505,7 +532,7 @@
 | 
			
		|||
									commandOptionButton?.click();
 | 
			
		||||
								}
 | 
			
		||||
 | 
			
		||||
								if (['/', '#'].includes(prompt.charAt(0)) && e.key === 'Tab') {
 | 
			
		||||
								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
 | 
			
		||||
									e.preventDefault();
 | 
			
		||||
 | 
			
		||||
									const commandOptionButton = [
 | 
			
		||||
| 
						 | 
				
			
			@ -536,6 +563,7 @@
 | 
			
		|||
							on:input={(e) => {
 | 
			
		||||
								e.target.style.height = '';
 | 
			
		||||
								e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
 | 
			
		||||
								user = null;
 | 
			
		||||
							}}
 | 
			
		||||
							on:paste={(e) => {
 | 
			
		||||
								const clipboardData = e.clipboardData || window.clipboardData;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										153
									
								
								src/lib/components/chat/MessageInput/Models.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								src/lib/components/chat/MessageInput/Models.svelte
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,153 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
	import { generatePrompt } from '$lib/apis/ollama';
 | 
			
		||||
	import { models } from '$lib/stores';
 | 
			
		||||
	import { splitStream } from '$lib/utils';
 | 
			
		||||
	import { tick } from 'svelte';
 | 
			
		||||
	import toast from 'svelte-french-toast';
 | 
			
		||||
 | 
			
		||||
	export let prompt = '';
 | 
			
		||||
	export let user = null;
 | 
			
		||||
 | 
			
		||||
	export let chatInputPlaceholder = '';
 | 
			
		||||
	export let messages = [];
 | 
			
		||||
 | 
			
		||||
	let selectedIdx = 0;
 | 
			
		||||
	let filteredModels = [];
 | 
			
		||||
 | 
			
		||||
	$: filteredModels = $models
 | 
			
		||||
		.filter((p) => p.name !== 'hr' && p.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? ''))
 | 
			
		||||
		.sort((a, b) => a.name.localeCompare(b.name));
 | 
			
		||||
 | 
			
		||||
	$: if (prompt) {
 | 
			
		||||
		selectedIdx = 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	export const selectUp = () => {
 | 
			
		||||
		selectedIdx = Math.max(0, selectedIdx - 1);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	export const selectDown = () => {
 | 
			
		||||
		selectedIdx = Math.min(selectedIdx + 1, filteredModels.length - 1);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const confirmSelect = async (model) => {
 | 
			
		||||
		// dispatch('select', model);
 | 
			
		||||
		prompt = '';
 | 
			
		||||
		user = JSON.parse(JSON.stringify(model.name));
 | 
			
		||||
		await tick();
 | 
			
		||||
 | 
			
		||||
		chatInputPlaceholder = `'${model.name}' is thinking...`;
 | 
			
		||||
 | 
			
		||||
		const chatInputElement = document.getElementById('chat-textarea');
 | 
			
		||||
 | 
			
		||||
		await tick();
 | 
			
		||||
		chatInputElement?.focus();
 | 
			
		||||
		await tick();
 | 
			
		||||
 | 
			
		||||
		const convoText = messages.reduce((a, message, i, arr) => {
 | 
			
		||||
			return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
 | 
			
		||||
		}, '');
 | 
			
		||||
 | 
			
		||||
		const res = await generatePrompt(localStorage.token, model.name, convoText);
 | 
			
		||||
 | 
			
		||||
		if (res && res.ok) {
 | 
			
		||||
			const reader = res.body
 | 
			
		||||
				.pipeThrough(new TextDecoderStream())
 | 
			
		||||
				.pipeThrough(splitStream('\n'))
 | 
			
		||||
				.getReader();
 | 
			
		||||
 | 
			
		||||
			while (true) {
 | 
			
		||||
				const { value, done } = await reader.read();
 | 
			
		||||
				if (done) {
 | 
			
		||||
					break;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				try {
 | 
			
		||||
					let lines = value.split('\n');
 | 
			
		||||
 | 
			
		||||
					for (const line of lines) {
 | 
			
		||||
						if (line !== '') {
 | 
			
		||||
							console.log(line);
 | 
			
		||||
							let data = JSON.parse(line);
 | 
			
		||||
 | 
			
		||||
							if ('detail' in data) {
 | 
			
		||||
								throw data;
 | 
			
		||||
							}
 | 
			
		||||
 | 
			
		||||
							if (data.done == false) {
 | 
			
		||||
								if (prompt == '' && data.response == '\n') {
 | 
			
		||||
									continue;
 | 
			
		||||
								} else {
 | 
			
		||||
									prompt += data.response;
 | 
			
		||||
									console.log(data.response);
 | 
			
		||||
									chatInputElement.scrollTop = chatInputElement.scrollHeight;
 | 
			
		||||
									await tick();
 | 
			
		||||
								}
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					console.log(error);
 | 
			
		||||
					if ('detail' in error) {
 | 
			
		||||
						toast.error(error.detail);
 | 
			
		||||
					}
 | 
			
		||||
					break;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			if (res !== null) {
 | 
			
		||||
				const error = await res.json();
 | 
			
		||||
				console.log(error);
 | 
			
		||||
				if ('detail' in error) {
 | 
			
		||||
					toast.error(error.detail);
 | 
			
		||||
				} else {
 | 
			
		||||
					toast.error(error.error);
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				toast.error(`Uh-oh! There was an issue connecting to Ollama.`);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		chatInputPlaceholder = '';
 | 
			
		||||
 | 
			
		||||
		console.log(user);
 | 
			
		||||
	};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if filteredModels.length > 0}
 | 
			
		||||
	<div class="md:px-2 mb-3 text-left w-full">
 | 
			
		||||
		<div class="flex w-full rounded-lg border border-gray-100 dark:border-gray-700">
 | 
			
		||||
			<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-lg text-center">
 | 
			
		||||
				<div class=" text-lg font-semibold mt-2">@</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="max-h-60 flex flex-col w-full rounded-r-lg">
 | 
			
		||||
				<div class=" overflow-y-auto bg-white p-2 rounded-tr-lg space-y-0.5">
 | 
			
		||||
					{#each filteredModels as model, modelIdx}
 | 
			
		||||
						<button
 | 
			
		||||
							class=" px-3 py-1.5 rounded-lg w-full text-left {modelIdx === selectedIdx
 | 
			
		||||
								? ' bg-gray-100 selected-command-option-button'
 | 
			
		||||
								: ''}"
 | 
			
		||||
							type="button"
 | 
			
		||||
							on:click={() => {
 | 
			
		||||
								confirmSelect(model);
 | 
			
		||||
							}}
 | 
			
		||||
							on:mousemove={() => {
 | 
			
		||||
								selectedIdx = modelIdx;
 | 
			
		||||
							}}
 | 
			
		||||
							on:focus={() => {}}
 | 
			
		||||
						>
 | 
			
		||||
							<div class=" font-medium text-black line-clamp-1">
 | 
			
		||||
								{model.name}
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<!-- <div class=" text-xs text-gray-600 line-clamp-1">
 | 
			
		||||
								{doc.title}
 | 
			
		||||
							</div> -->
 | 
			
		||||
						</button>
 | 
			
		||||
					{/each}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
{/if}
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +9,7 @@
 | 
			
		|||
			<button
 | 
			
		||||
				class=" flex-1 flex justify-between w-full h-full px-4 py-2.5 bg-white hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg transition group"
 | 
			
		||||
				on:click={() => {
 | 
			
		||||
					submitPrompt(prompt.content);
 | 
			
		||||
					submitPrompt(prompt.content, '');
 | 
			
		||||
				}}
 | 
			
		||||
			>
 | 
			
		||||
				<div class="flex flex-col text-left self-center">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,8 @@
 | 
			
		|||
	import { tick } from 'svelte';
 | 
			
		||||
	import Name from './Name.svelte';
 | 
			
		||||
	import ProfileImage from './ProfileImage.svelte';
 | 
			
		||||
	import { modelfiles } from '$lib/stores';
 | 
			
		||||
	import { stringify } from 'postcss';
 | 
			
		||||
 | 
			
		||||
	export let user;
 | 
			
		||||
	export let message;
 | 
			
		||||
| 
						 | 
				
			
			@ -42,11 +44,25 @@
 | 
			
		|||
</script>
 | 
			
		||||
 | 
			
		||||
<div class=" flex w-full">
 | 
			
		||||
	<ProfileImage src={user?.profile_image_url ?? '/user.png'} />
 | 
			
		||||
	<ProfileImage
 | 
			
		||||
		src={message.user
 | 
			
		||||
			? $modelfiles.find((modelfile) => modelfile.tagName === message.user)?.imageUrl ?? '/user.png'
 | 
			
		||||
			: user?.profile_image_url ?? '/user.png'}
 | 
			
		||||
	/>
 | 
			
		||||
 | 
			
		||||
	<div class="w-full overflow-hidden">
 | 
			
		||||
		<div class="user-message">
 | 
			
		||||
			<Name>You</Name>
 | 
			
		||||
			<Name>
 | 
			
		||||
				{#if message.user}
 | 
			
		||||
					{#if $modelfiles.map((modelfile) => modelfile.tagName).includes(message.user)}
 | 
			
		||||
						{$modelfiles.find((modelfile) => modelfile.tagName === message.user)?.title}
 | 
			
		||||
					{:else}
 | 
			
		||||
						You <span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
 | 
			
		||||
					{/if}
 | 
			
		||||
				{:else}
 | 
			
		||||
					You
 | 
			
		||||
				{/if}
 | 
			
		||||
			</Name>
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
		<div
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue