Merge branch 'dev' into buroa/hybrid-search

This commit is contained in:
Steven Kreitzer 2024-04-22 18:35:32 -05:00 committed by GitHub
commit db801aee79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 701 additions and 204 deletions

View file

@ -0,0 +1,70 @@
type TextStreamUpdate = {
done: boolean;
value: string;
};
// createOpenAITextStream takes a ReadableStreamDefaultReader from an SSE response,
// and returns an async generator that emits delta updates with large deltas chunked into random sized chunks
export async function createOpenAITextStream(
messageStream: ReadableStreamDefaultReader,
splitLargeDeltas: boolean
): Promise<AsyncGenerator<TextStreamUpdate>> {
let iterator = openAIStreamToIterator(messageStream);
if (splitLargeDeltas) {
iterator = streamLargeDeltasAsRandomChunks(iterator);
}
return iterator;
}
async function* openAIStreamToIterator(
reader: ReadableStreamDefaultReader
): AsyncGenerator<TextStreamUpdate> {
while (true) {
const { value, done } = await reader.read();
if (done) {
yield { done: true, value: '' };
break;
}
const lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
yield { done: true, value: '' };
} else {
const data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
yield { done: false, value: data.choices[0].delta.content ?? '' };
}
}
}
}
}
// streamLargeDeltasAsRandomChunks will chunk large deltas (length > 5) into random sized chunks between 1-3 characters
// This is to simulate a more fluid streaming, even though some providers may send large chunks of text at once
async function* streamLargeDeltasAsRandomChunks(
iterator: AsyncGenerator<TextStreamUpdate>
): AsyncGenerator<TextStreamUpdate> {
for await (const textStreamUpdate of iterator) {
if (textStreamUpdate.done) {
yield textStreamUpdate;
return;
}
let content = textStreamUpdate.value;
if (content.length < 5) {
yield { done: false, value: content };
continue;
}
while (content != '') {
const chunkSize = Math.min(Math.floor(Math.random() * 3) + 1, content.length);
const chunk = content.slice(0, chunkSize);
yield { done: false, value: chunk };
await sleep(5);
content = content.slice(chunkSize);
}
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { downloadDatabase } from '$lib/apis/utils';
import { onMount, getContext } from 'svelte';
import { config } from '$lib/stores';
const i18n = getContext('i18n');
@ -24,32 +25,34 @@
<div class=" flex w-full justify-between">
<!-- <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div> -->
<button
class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
type="button"
on:click={() => {
// exportAllUserChats();
{#if $config?.admin_export_enabled ?? true}
<button
class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
type="button"
on:click={() => {
// exportAllUserChats();
downloadDatabase(localStorage.token);
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM8.75 7.75a.75.75 0 0 0-1.5 0v2.69L6.03 9.22a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06l-1.22 1.22V7.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Download Database')}</div>
</button>
downloadDatabase(localStorage.token);
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM8.75 7.75a.75.75 0 0 0-1.5 0v2.69L6.03 9.22a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06l-1.22 1.22V7.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Download Database')}</div>
</button>
{/if}
</div>
</div>
</div>

View file

@ -316,24 +316,22 @@
console.log(e);
if (e.dataTransfer?.files) {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
};
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
inputFiles.forEach((file) => {
console.log(file, file.name.split('.').at(-1));
if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
};
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
@ -470,23 +468,22 @@
hidden
multiple
on:change={async () => {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
inputFiles = null;
filesInputElement.value = '';
};
if (inputFiles && inputFiles.length > 0) {
const _inputFiles = Array.from(inputFiles);
_inputFiles.forEach((file) => {
if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
inputFiles = null;
filesInputElement.value = '';
};
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||

View file

@ -301,7 +301,7 @@
</button>
{/if}
{#if $user?.role === 'admin'}
{#if $user?.role === 'admin' && ($config?.admin_export_enabled ?? true)}
<hr class=" dark:border-gray-700" />
<button

View file

@ -17,11 +17,17 @@
let titleAutoGenerateModelExternal = '';
let fullScreenMode = false;
let titleGenerationPrompt = '';
let splitLargeChunks = false;
// Interface
let promptSuggestions = [];
let showUsername = false;
const toggleSplitLargeChunks = async () => {
splitLargeChunks = !splitLargeChunks;
saveSettings({ splitLargeChunks: splitLargeChunks });
};
const toggleFullScreenMode = async () => {
fullScreenMode = !fullScreenMode;
saveSettings({ fullScreenMode: fullScreenMode });
@ -197,6 +203,28 @@
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Fluidly stream large external response chunks')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleSplitLargeChunks();
}}
type="button"
>
{#if splitLargeChunks === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
</div>
<hr class=" dark:border-gray-700" />

View file

@ -35,7 +35,7 @@
let liteLLMRPM = '';
let liteLLMMaxTokens = '';
let deleteLiteLLMModelId = '';
let deleteLiteLLMModelName = '';
$: liteLLMModelName = liteLLMModel;
@ -472,7 +472,7 @@
};
const deleteLiteLLMModelHandler = async () => {
const res = await deleteLiteLLMModel(localStorage.token, deleteLiteLLMModelId).catch(
const res = await deleteLiteLLMModel(localStorage.token, deleteLiteLLMModelName).catch(
(error) => {
toast.error(error);
return null;
@ -485,7 +485,7 @@
}
}
deleteLiteLLMModelId = '';
deleteLiteLLMModelName = '';
liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token);
models.set(await getModels());
};
@ -1099,14 +1099,14 @@
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={deleteLiteLLMModelId}
bind:value={deleteLiteLLMModelName}
placeholder={$i18n.t('Select a model')}
>
{#if !deleteLiteLLMModelId}
{#if !deleteLiteLLMModelName}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each liteLLMModelInfo as model}
<option value={model.model_info.id} class="bg-gray-100 dark:bg-gray-700"
<option value={model.model_name} class="bg-gray-100 dark:bg-gray-700"
>{model.model_name}</option
>
{/each}

View file

@ -3,7 +3,7 @@
import { toast } from 'svelte-sonner';
import { models, settings, user } from '$lib/stores';
import { getModels } from '$lib/utils';
import { getModels as _getModels } from '$lib/utils';
import Modal from '../common/Modal.svelte';
import Account from './Settings/Account.svelte';
@ -23,10 +23,14 @@
const saveSettings = async (updated) => {
console.log(updated);
await settings.set({ ...$settings, ...updated });
await models.set(await getModels(localStorage.token));
await models.set(await getModels());
localStorage.setItem('settings', JSON.stringify($settings));
};
const getModels = async () => {
return await _getModels(localStorage.token);
};
let selectedTab = 'general';
</script>

View file

@ -134,11 +134,36 @@
<button
class=" self-center flex items-center gap-1 px-3.5 py-2 rounded-xl text-sm font-medium bg-emerald-600 hover:bg-emerald-500 text-white"
type="button"
on:pointerdown={() => {
shareLocalChat();
}}
on:click={async () => {
copyToClipboard(shareUrl);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Oh, Safari, you're so special, let's give you some extra love and attention
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
show = false;
}}

View file

@ -67,7 +67,7 @@
<div class="flex flex-col md:flex-row w-full px-5 py-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if chats.length > 0}
<div class="text-left text-sm w-full mb-4">
<div class="text-left text-sm w-full mb-4 max-h-[22rem] overflow-y-scroll">
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead
@ -75,7 +75,7 @@
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Created At')} </th>
<th scope="col" class="px-3 py-2 hidden md:flex"> {$i18n.t('Created At')} </th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
@ -93,8 +93,10 @@
</a>
</td>
<td class=" px-3 py-1">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]">
<div class="my-auto">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div>
</td>
<td class="px-3 py-1 text-right">

View file

@ -152,6 +152,7 @@
"File Mode": "",
"File not found.": "",
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
"Fluidly stream large external response chunks": "",
"Focus chat input": "",
"Format your variables using square brackets like this:": "",
"From (Base Model)": "",

View file

@ -1,10 +1,10 @@
import { APP_NAME } from '$lib/constants';
import { writable } from 'svelte/store';
import { type Writable, writable } from 'svelte/store';
// Backend
export const WEBUI_NAME = writable(APP_NAME);
export const config = writable(undefined);
export const user = writable(undefined);
export const config: Writable<Config | undefined> = writable(undefined);
export const user: Writable<SessionUser | undefined> = writable(undefined);
// Frontend
export const MODEL_DOWNLOAD_POOL = writable({});
@ -14,10 +14,10 @@ export const chatId = writable('');
export const chats = writable([]);
export const tags = writable([]);
export const models = writable([]);
export const models: Writable<Model[]> = writable([]);
export const modelfiles = writable([]);
export const prompts = writable([]);
export const prompts: Writable<Prompt[]> = writable([]);
export const documents = writable([
{
collection_name: 'collection_name',
@ -33,6 +33,109 @@ export const documents = writable([
}
]);
export const settings = writable({});
export const settings: Writable<Settings> = writable({});
export const showSettings = writable(false);
export const showChangelog = writable(false);
type Model = OpenAIModel | OllamaModel;
type OpenAIModel = {
id: string;
name: string;
external: boolean;
source?: string;
};
type OllamaModel = {
id: string;
name: string;
// Ollama specific fields
details: OllamaModelDetails;
size: number;
description: string;
model: string;
modified_at: string;
digest: string;
};
type OllamaModelDetails = {
parent_model: string;
format: string;
family: string;
families: string[] | null;
parameter_size: string;
quantization_level: string;
};
type Settings = {
models?: string[];
conversationMode?: boolean;
speechAutoSend?: boolean;
responseAutoPlayback?: boolean;
audio?: AudioSettings;
showUsername?: boolean;
saveChatHistory?: boolean;
notificationEnabled?: boolean;
title?: TitleSettings;
system?: string;
requestFormat?: string;
keepAlive?: string;
seed?: number;
temperature?: string;
repeat_penalty?: string;
top_k?: string;
top_p?: string;
num_ctx?: string;
options?: ModelOptions;
};
type ModelOptions = {
stop?: boolean;
};
type AudioSettings = {
STTEngine?: string;
TTSEngine?: string;
speaker?: string;
};
type TitleSettings = {
auto?: boolean;
model?: string;
modelExternal?: string;
prompt?: string;
};
type Prompt = {
command: string;
user_id: string;
title: string;
content: string;
timestamp: number;
};
type Config = {
status?: boolean;
name?: string;
version?: string;
default_locale?: string;
images?: boolean;
default_models?: string[];
default_prompt_suggestions?: PromptSuggestion[];
trusted_header_auth?: boolean;
};
type PromptSuggestion = {
content: string;
title: [string, string];
};
type SessionUser = {
id: string;
email: string;
name: string;
role: string;
profile_image_url: string;
};

View file

@ -35,7 +35,6 @@ export const sanitizeResponseContent = (content: string) => {
.replace(/<\|[a-z]+\|$/, '')
.replace(/<$/, '')
.replaceAll(/<\|[a-z]+\|>/g, ' ')
.replaceAll(/<br\s?\/?>/gi, '\n')
.replaceAll('<', '&lt;')
.trim();
};

View file

@ -39,6 +39,7 @@
import { RAGTemplate } from '$lib/utils/rag';
import { LITELLM_API_BASE_URL, OLLAMA_API_BASE_URL, OPENAI_API_BASE_URL } from '$lib/constants';
import { WEBUI_BASE_URL } from '$lib/constants';
import { createOpenAITextStream } from '$lib/apis/streaming';
const i18n = getContext('i18n');
@ -599,38 +600,22 @@
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
const textStream = await createOpenAITextStream(reader, $settings.splitLargeChunks);
console.log(textStream);
for await (const update of textStream) {
const { value, done } = update;
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
}
}
}
}
} catch (error) {
console.log(error);
if (responseMessage.content == '' && value == '\n') {
continue;
} else {
responseMessage.content += value;
messages = messages;
}
if ($settings.notificationEnabled && !document.hasFocus()) {

View file

@ -42,6 +42,7 @@
OLLAMA_API_BASE_URL,
WEBUI_BASE_URL
} from '$lib/constants';
import { createOpenAITextStream } from '$lib/apis/streaming';
const i18n = getContext('i18n');
@ -611,38 +612,22 @@
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
const textStream = await createOpenAITextStream(reader, $settings.splitLargeChunks);
console.log(textStream);
for await (const update of textStream) {
const { value, done } = update;
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
}
}
}
}
} catch (error) {
console.log(error);
if (responseMessage.content == '' && value == '\n') {
continue;
} else {
responseMessage.content += value;
messages = messages;
}
if ($settings.notificationEnabled && !document.hasFocus()) {