Unverified Commit 437d7ff6 authored by Timothy Jaeryang Baek's avatar Timothy Jaeryang Baek Committed by GitHub
Browse files

Merge pull request #897 from open-webui/main

dev
parents 02f364bf 81eceb48
<script lang="ts">
import ImagePreview from './ImagePreview.svelte';
export let src = '';
export let alt = '';
let showImagePreview = false;
</script>
<ImagePreview bind:show={showImagePreview} {src} {alt} />
<button
on:click={() => {
console.log('image preview');
showImagePreview = true;
}}
>
<img {src} {alt} class=" max-h-96 rounded-lg" draggable="false" />
</button>
<script lang="ts">
export let show = false;
export let src = '';
export let alt = '';
</script>
{#if show}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed top-0 right-0 left-0 bottom-0 bg-black text-white w-full min-h-screen h-screen flex justify-center z-50 overflow-hidden overscroll-contain"
>
<div class=" absolute left-0 w-full flex justify-between">
<div>
<button
class=" p-5"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-6 h-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div>
<button
class=" p-5"
on:click={() => {
const a = document.createElement('a');
a.href = src;
a.download = 'Image.png';
a.click();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-6 h-6"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
</div>
</div>
<img {src} {alt} class=" mx-auto h-full object-scale-down" />
</div>
{/if}
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade, blur } from 'svelte/transition'; import { fade } from 'svelte/transition';
export let show = true; export let show = true;
export let size = 'md'; export let size = 'md';
...@@ -8,10 +8,12 @@ ...@@ -8,10 +8,12 @@
let mounted = false; let mounted = false;
const sizeToWidth = (size) => { const sizeToWidth = (size) => {
if (size === 'sm') { if (size === 'xs') {
return 'w-[16rem]';
} else if (size === 'sm') {
return 'w-[30rem]'; return 'w-[30rem]';
} else { } else {
return 'w-[40rem]'; return 'w-[44rem]';
} }
}; };
...@@ -32,16 +34,17 @@ ...@@ -32,16 +34,17 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="fixed top-0 right-0 left-0 bottom-0 bg-stone-900/50 w-full min-h-screen h-screen flex justify-center z-50 overflow-hidden overscroll-contain" class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center z-50 overflow-hidden overscroll-contain"
in:fade={{ duration: 10 }}
on:click={() => { on:click={() => {
show = false; show = false;
}} }}
> >
<div <div
class="m-auto rounded-xl max-w-full {sizeToWidth( class=" modal-content m-auto rounded-xl max-w-full {sizeToWidth(
size size
)} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl" )} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl"
transition:fade={{ delay: 100, duration: 200 }} in:fade={{ duration: 10 }}
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
...@@ -50,3 +53,20 @@ ...@@ -50,3 +53,20 @@
</div> </div>
</div> </div>
{/if} {/if}
<style>
.modal-content {
animation: scaleUp 0.1s ease-out forwards;
}
@keyframes scaleUp {
from {
transform: scale(0.985);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
<script lang="ts">
import TagInput from './Tags/TagInput.svelte';
import TagList from './Tags/TagList.svelte';
export let tags = [];
export let deleteTag: Function;
export let addTag: Function;
</script>
<div class="flex flex-row space-x-0.5 line-clamp-1">
<TagList
{tags}
on:delete={(e) => {
deleteTag(e.detail);
}}
/>
<TagInput
on:add={(e) => {
addTag(e.detail);
}}
/>
</div>
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let showTagInput = false;
let tagName = '';
</script>
<div class="flex space-x-1 pl-1.5">
{#if showTagInput}
<div class="flex items-center">
<input
bind:value={tagName}
class=" cursor-pointer self-center text-xs h-fit bg-transparent outline-none line-clamp-1 w-[4rem]"
placeholder="Add a tag"
/>
<button
type="button"
on:click={() => {
dispatch('add', tagName);
tagName = '';
showTagInput = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- TODO: Tag Suggestions -->
{/if}
<button
class=" cursor-pointer self-center p-0.5 space-x-1 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed"
type="button"
on:click={() => {
showTagInput = !showTagInput;
}}
>
<div class=" m-auto self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3 {showTagInput ? 'rotate-45' : ''} transition-all transform"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</div>
</button>
</div>
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let tags = [];
</script>
{#each tags as tag}
<div
class="px-2 py-0.5 space-x-1 flex h-fit items-center rounded-full transition border dark:border-gray-600 dark:text-white"
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1">
{tag.name}
</div>
<button
class=" m-auto self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
/>
</svg>
</button>
</div>
{/each}
<script lang="ts">
import toast from 'svelte-french-toast';
import dayjs from 'dayjs';
import { onMount } from 'svelte';
import { getDocs, tagDocByName, updateDocByName } from '$lib/apis/documents';
import Modal from '../common/Modal.svelte';
import { documents } from '$lib/stores';
import TagInput from '../common/Tags/TagInput.svelte';
import Tags from '../common/Tags.svelte';
import { addTagById } from '$lib/apis/chats';
export let show = false;
export let selectedDoc;
let tags = [];
let doc = {
name: '',
title: '',
content: null
};
const submitHandler = async () => {
const res = await updateDocByName(localStorage.token, selectedDoc.name, {
title: doc.title,
name: doc.name
}).catch((error) => {
toast.error(error);
});
if (res) {
show = false;
documents.set(await getDocs(localStorage.token));
}
};
const addTagHandler = async (tagName) => {
if (!tags.find((tag) => tag.name === tagName) && tagName !== '') {
tags = [...tags, { name: tagName }];
await tagDocByName(localStorage.token, doc.name, {
name: doc.name,
tags: tags
});
documents.set(await getDocs(localStorage.token));
} else {
console.log('tag already exists');
}
};
const deleteTagHandler = async (tagName) => {
tags = tags.filter((tag) => tag.name !== tagName);
await tagDocByName(localStorage.token, doc.name, {
name: doc.name,
tags: tags
});
documents.set(await getDocs(localStorage.token));
};
onMount(() => {
if (selectedDoc) {
doc = JSON.parse(JSON.stringify(selectedDoc));
tags = doc?.content?.tags ?? [];
}
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center">Edit Doc</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<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>
<hr class=" dark:border-gray-800" />
<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">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">Name Tag</div>
<div class="flex flex-1">
<div
class="bg-gray-200 dark:bg-gray-600 font-bold px-3 py-1 border border-r-0 dark:border-gray-600 rounded-l-lg flex items-center"
>
#
</div>
<input
class="w-full rounded-r-lg py-2.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="text"
bind:value={doc.name}
autocomplete="off"
required
/>
</div>
<!-- <div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="text"
bind:value={doc.name}
autocomplete="off"
required
/>
</div> -->
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">Title</div>
<div class="flex-1">
<input
class="w-full rounded-lg py-2.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
type="text"
bind:value={doc.title}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1.5 text-xs text-gray-500">Tags</div>
<Tags {tags} addTag={addTagHandler} deleteTag={deleteTagHandler} />
</div>
</div>
<div class="flex justify-end pt-5 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit"
>
Save
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
.tabs::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.tabs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
input[type='number'] {
-moz-appearance: textfield; /* Firefox */
}
</style>
<script lang="ts">
import { getDocs } from '$lib/apis/documents';
import {
getChunkParams,
getRAGTemplate,
scanDocs,
updateChunkParams,
updateRAGTemplate
} from '$lib/apis/rag';
import { documents } from '$lib/stores';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast';
export let saveHandler: Function;
let loading = false;
let chunkSize = 0;
let chunkOverlap = 0;
let template = '';
const scanHandler = async () => {
loading = true;
const res = await scanDocs(localStorage.token);
loading = false;
if (res) {
await documents.set(await getDocs(localStorage.token));
toast.success('Scan complete!');
}
};
const submitHandler = async () => {
const res = await updateChunkParams(localStorage.token, chunkSize, chunkOverlap);
await updateRAGTemplate(localStorage.token, template);
};
onMount(async () => {
const res = await getChunkParams(localStorage.token);
if (res) {
chunkSize = res.chunk_size;
chunkOverlap = res.chunk_overlap;
}
template = await getRAGTemplate(localStorage.token);
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
submitHandler();
saveHandler();
}}
>
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<div>
<div class=" mb-2 text-sm font-medium">General Settings</div>
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">Scan for documents from '/data/docs'</div>
<button
class=" self-center text-xs p-1 px-3 bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 rounded flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
on:click={() => {
scanHandler();
console.log('check');
}}
type="button"
disabled={loading}
>
<div class="self-center font-medium">Scan</div>
<!-- <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M13.836 2.477a.75.75 0 0 1 .75.75v3.182a.75.75 0 0 1-.75.75h-3.182a.75.75 0 0 1 0-1.5h1.37l-.84-.841a4.5 4.5 0 0 0-7.08.932.75.75 0 0 1-1.3-.75 6 6 0 0 1 9.44-1.242l.842.84V3.227a.75.75 0 0 1 .75-.75Zm-.911 7.5A.75.75 0 0 1 13.199 11a6 6 0 0 1-9.44 1.241l-.84-.84v1.371a.75.75 0 0 1-1.5 0V9.591a.75.75 0 0 1 .75-.75H5.35a.75.75 0 0 1 0 1.5H3.98l.841.841a4.5 4.5 0 0 0 7.08-.932.75.75 0 0 1 1.025-.273Z"
clip-rule="evenodd"
/>
</svg> -->
{#if loading}
<div class="ml-3 self-center">
<svg
class=" w-3 h-3"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</div>
<hr class=" dark:border-gray-700" />
<div class=" ">
<div class=" text-sm font-medium">Chunk Params</div>
<div class=" flex">
<div class=" flex w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit">Chunk Size</div>
<div class="self-center p-3">
<input
class=" w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="number"
placeholder="Enter Chunk Size"
bind:value={chunkSize}
autocomplete="off"
min="0"
/>
</div>
</div>
<div class="flex w-full">
<div class=" self-center text-xs font-medium min-w-fit">Chunk Overlap</div>
<div class="self-center p-3">
<input
class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="number"
placeholder="Enter Chunk Overlap"
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div>
</div>
<div>
<div class=" mb-2.5 text-sm font-medium">RAG Template</div>
<textarea
bind:value={template}
class="w-full rounded p-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
rows="4"
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit"
>
Save
</button>
</div>
</form>
<script>
import Modal from '../common/Modal.svelte';
import General from './Settings/General.svelte';
export let show = false;
let selectedTab = 'general';
</script>
<Modal bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center">Document Settings</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<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>
<hr class=" dark:border-gray-800" />
<div class="flex flex-col md:flex-row w-full p-4 md:space-x-4">
<div
class="tabs flex flex-row overflow-x-auto space-x-1 md:space-x-0 md:space-y-1 md:flex-col flex-1 md:flex-none md:w-40 dark:text-gray-200 text-xs text-left mb-3 md:mb-0"
>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'general'
? 'bg-gray-200 dark:bg-gray-700'
: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
on:click={() => {
selectedTab = 'general';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">General</div>
</button>
</div>
<div class="flex-1 md:min-h-[380px]">
{#if selectedTab === 'general'}
<General
saveHandler={() => {
show = false;
}}
/>
<!-- <General
saveHandler={() => {
show = false;
}}
/> -->
<!-- {:else if selectedTab === 'users'}
<Users
saveHandler={() => {
show = false;
}}
/> -->
{/if}
</div>
</div>
</div>
</Modal>
...@@ -4,18 +4,30 @@ ...@@ -4,18 +4,30 @@
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
import { getChatById } from '$lib/apis/chats'; import { getChatById } from '$lib/apis/chats';
import { chatId, modelfiles } from '$lib/stores'; import { WEBUI_NAME, chatId, modelfiles, settings } from '$lib/stores';
import ShareChatModal from '../chat/ShareChatModal.svelte';
import TagInput from '../common/Tags/TagInput.svelte';
import Tags from '../common/Tags.svelte';
export let initNewChat: Function; export let initNewChat: Function;
export let title: string = 'Ollama Web UI'; export let title: string = $WEBUI_NAME;
export let shareEnabled: boolean = false; export let shareEnabled: boolean = false;
export let tags = [];
export let addTag: Function;
export let deleteTag: Function;
let showShareChatModal = false;
let tagName = '';
let showTagInput = false;
const shareChat = async () => { const shareChat = async () => {
const chat = (await getChatById(localStorage.token, $chatId)).chat; const chat = (await getChatById(localStorage.token, $chatId)).chat;
console.log('share', chat); console.log('share', chat);
toast.success('Redirecting you to OllamaHub'); toast.success('Redirecting you to OpenWebUI Community');
const url = 'https://ollamahub.com'; const url = 'https://openwebui.com';
// const url = 'http://localhost:5173'; // const url = 'http://localhost:5173';
const tab = await window.open(`${url}/chats/upload`, '_blank'); const tab = await window.open(`${url}/chats/upload`, '_blank');
...@@ -53,16 +65,21 @@ ...@@ -53,16 +65,21 @@
}; };
</script> </script>
<ShareChatModal bind:show={showShareChatModal} {downloadChat} {shareChat} />
<nav <nav
id="nav" id="nav"
class=" fixed py-2.5 top-0 flex flex-row justify-center bg-white/95 dark:bg-gray-800/90 dark:text-gray-200 backdrop-blur-xl w-screen z-30" class=" sticky py-2.5 top-0 flex flex-row justify-center bg-white/95 dark:bg-gray-900/90 dark:text-gray-200 backdrop-blur-xl z-30"
> >
<div class=" flex max-w-3xl w-full mx-auto px-3"> <div
<div class="flex w-full max-w-full"> class=" flex {$settings?.fullScreenMode ?? null
<div class="pr-2 self-center"> ? 'max-w-full'
: 'max-w-3xl'} w-full mx-auto px-3"
>
<div class="flex items-center w-full max-w-full">
<div class="pr-2 self-start">
<button <button
id="new-chat-button" id="new-chat-button"
class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition" class=" cursor-pointer p-1.5 flex dark:hover:bg-gray-700 rounded-lg transition"
on:click={initNewChat} on:click={initNewChat}
> >
<div class=" m-auto self-center"> <div class=" m-auto self-center">
...@@ -82,59 +99,41 @@ ...@@ -82,59 +99,41 @@
</div> </div>
</button> </button>
</div> </div>
<div class=" flex-1 self-center font-medium text-ellipsis whitespace-nowrap overflow-hidden"> <div class=" flex-1 self-center font-medium line-clamp-1">
{title != '' ? title : 'Ollama Web UI'} <div>
{title != '' ? title : $WEBUI_NAME}
</div>
</div> </div>
{#if shareEnabled} <div class="pl-2 self-center flex items-center space-x-2">
<div class="pl-2 flex space-x-1.5"> {#if shareEnabled}
<button <Tags {tags} {deleteTag} {addTag} />
class=" cursor-pointer p-2 flex dark:hover:bg-gray-700 rounded-lg transition border dark:border-gray-600"
on:click={async () => {
downloadChat();
}}
>
<div class=" m-auto self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
</div>
</button>
<button <button
class=" cursor-pointer p-2 flex dark:hover:bg-gray-700 rounded-lg transition border dark:border-gray-600" class=" cursor-pointer p-1.5 flex dark:hover:bg-gray-700 rounded-lg transition border dark:border-gray-600"
on:click={async () => { on:click={async () => {
shareChat(); showShareChatModal = !showShareChatModal;
// console.log(showShareChatModal);
}} }}
> >
<div class=" m-auto self-center"> <div class=" m-auto self-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
<path <path
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z" 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"
<path clip-rule="evenodd"
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/> />
</svg> </svg>
</div> </div>
</button> </button>
</div> {/if}
{/if} </div>
</div> </div>
</div> </div>
</nav> </nav>
...@@ -6,14 +6,23 @@ ...@@ -6,14 +6,23 @@
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { user, chats, settings, showSettings, chatId } from '$lib/stores'; import { user, chats, settings, showSettings, chatId, tags } from '$lib/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { deleteChatById, getChatList, updateChatById } from '$lib/apis/chats'; import {
deleteChatById,
getChatList,
getChatById,
getChatListByTagName,
updateChatById
} from '$lib/apis/chats';
import toast from 'svelte-french-toast';
import { slide } from 'svelte/transition';
import { WEBUI_BASE_URL } from '$lib/constants';
let show = false; let show = false;
let navElement; let navElement;
let title: string = 'Ollama Web UI'; let title: string = 'UI';
let search = ''; let search = '';
let chatDeleteId = null; let chatDeleteId = null;
...@@ -26,10 +35,24 @@ ...@@ -26,10 +35,24 @@
if (window.innerWidth > 1280) { if (window.innerWidth > 1280) {
show = true; show = true;
} }
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
}); });
// Helper function to fetch and add chat content to each chat
const enrichChatsWithContent = async (chatList) => {
const enrichedChats = await Promise.all(
chatList.map(async (chat) => {
const chatDetails = await getChatById(localStorage.token, chat.id).catch((error) => null); // Handle error or non-existent chat gracefully
if (chatDetails) {
chat.chat = chatDetails.chat; // Assuming chatDetails.chat contains the chat content
}
return chat;
})
);
await chats.set(enrichedChats);
};
const loadChat = async (id) => { const loadChat = async (id) => {
goto(`/c/${id}`); goto(`/c/${id}`);
}; };
...@@ -44,10 +67,17 @@ ...@@ -44,10 +67,17 @@
}; };
const deleteChat = async (id) => { const deleteChat = async (id) => {
goto('/'); const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(error);
chatDeleteId = null;
await deleteChatById(localStorage.token, id); return null;
await chats.set(await getChatList(localStorage.token)); });
if (res) {
goto('/');
await chats.set(await getChatList(localStorage.token));
}
}; };
const saveSettings = async (updated) => { const saveSettings = async (updated) => {
...@@ -59,16 +89,20 @@ ...@@ -59,16 +89,20 @@
<div <div
bind:this={navElement} bind:this={navElement}
class="h-screen {show class="h-screen max-h-[100dvh] min-h-screen {show
? '' ? 'lg:relative w-[260px]'
: '-translate-x-[260px]'} w-[260px] fixed top-0 left-0 z-40 transition bg-black text-gray-200 shadow-2xl text-sm : '-translate-x-[260px] w-[0px]'} bg-black text-gray-200 shadow-2xl text-sm transition z-40 fixed top-0 left-0
" "
> >
<div class="py-2.5 my-auto flex flex-col justify-between h-screen"> <div
class="py-2.5 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] {show
? ''
: 'invisible'}"
>
<div class="px-2.5 flex justify-center space-x-2"> <div class="px-2.5 flex justify-center space-x-2">
<button <button
id="sidebar-new-chat-button" id="sidebar-new-chat-button"
class="flex-grow flex justify-between rounded-md px-3 py-1.5 mt-2 hover:bg-gray-900 transition" class="flex-grow flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 transition"
on:click={async () => { on:click={async () => {
goto('/'); goto('/');
...@@ -80,8 +114,12 @@ ...@@ -80,8 +114,12 @@
}} }}
> >
<div class="flex self-center"> <div class="flex self-center">
<div class="self-center mr-3.5"> <div class="self-center mr-1.5">
<img src="/ollama.png" class=" w-5 invert-[100%] rounded-full" /> <img
src="{WEBUI_BASE_URL}/static/favicon.png"
class=" w-7 -translate-x-1.5 rounded-full"
alt="logo"
/>
</div> </div>
<div class=" self-center font-medium text-sm">New Chat</div> <div class=" self-center font-medium text-sm">New Chat</div>
...@@ -106,12 +144,10 @@ ...@@ -106,12 +144,10 @@
</div> </div>
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<div class="px-2.5 flex justify-center mt-1"> <div class="px-2.5 flex justify-center mt-0.5">
<button <a
class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition" class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
on:click={async () => { href="/modelfiles"
goto('/modelfiles');
}}
> >
<div class="self-center"> <div class="self-center">
<svg <svg
...@@ -125,7 +161,7 @@ ...@@ -125,7 +161,7 @@
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 002.25-2.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v2.25A2.25 2.25 0 006 10.5zm0 9.75h2.25A2.25 2.25 0 0010.5 18v-2.25a2.25 2.25 0 00-2.25-2.25H6a2.25 2.25 0 00-2.25 2.25V18A2.25 2.25 0 006 20.25zm9.75-9.75H18a2.25 2.25 0 002.25-2.25V6A2.25 2.25 0 0018 3.75h-2.25A2.25 2.25 0 0013.5 6v2.25a2.25 2.25 0 002.25 2.25z" d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/> />
</svg> </svg>
</div> </div>
...@@ -133,27 +169,27 @@ ...@@ -133,27 +169,27 @@
<div class="flex self-center"> <div class="flex self-center">
<div class=" self-center font-medium text-sm">Modelfiles</div> <div class=" self-center font-medium text-sm">Modelfiles</div>
</div> </div>
</button> </a>
</div> </div>
<div class="px-2.5 flex justify-center mb-1"> <div class="px-2.5 flex justify-center">
<button <a
class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition" class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
on:click={async () => { href="/prompts"
goto('/prompts');
}}
> >
<div class="self-center"> <div class="self-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" fill="none"
fill="currentColor" viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
<path <path
fill-rule="evenodd" stroke-linecap="round"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z" stroke-linejoin="round"
clip-rule="evenodd" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/> />
</svg> </svg>
</div> </div>
...@@ -161,7 +197,35 @@ ...@@ -161,7 +197,35 @@
<div class="flex self-center"> <div class="flex self-center">
<div class=" self-center font-medium text-sm">Prompts</div> <div class=" self-center font-medium text-sm">Prompts</div>
</div> </div>
</button> </a>
</div>
<div class="px-2.5 flex justify-center mb-1">
<a
class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
href="/documents"
>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
/>
</svg>
</div>
<div class="flex self-center">
<div class=" self-center font-medium text-sm">Documents</div>
</div>
</a>
</div> </div>
{/if} {/if}
...@@ -228,6 +292,9 @@ ...@@ -228,6 +292,9 @@
class="w-full rounded-r py-1.5 pl-2.5 pr-4 text-sm text-gray-300 bg-gray-950 outline-none" class="w-full rounded-r py-1.5 pl-2.5 pr-4 text-sm text-gray-300 bg-gray-950 outline-none"
placeholder="Search" placeholder="Search"
bind:value={search} bind:value={search}
on:focus={() => {
enrichChatsWithContent($chats);
}}
/> />
<!-- <div class="self-center pr-3 py-2 bg-gray-900"> <!-- <div class="self-center pr-3 py-2 bg-gray-900">
...@@ -249,59 +316,62 @@ ...@@ -249,59 +316,62 @@
</div> </div>
</div> </div>
{#if $tags.length > 0}
<div class="px-2.5 mt-0.5 mb-2 flex gap-1 flex-wrap">
<button
class="px-2.5 text-xs font-medium bg-gray-900 hover:bg-gray-800 transition rounded-full"
on:click={async () => {
await chats.set(await getChatList(localStorage.token));
}}
>
all
</button>
{#each $tags as tag}
<button
class="px-2.5 text-xs font-medium bg-gray-900 hover:bg-gray-800 transition rounded-full"
on:click={async () => {
await chats.set(await getChatListByTagName(localStorage.token, tag.name));
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
<div class="pl-2.5 my-2 flex-1 flex flex-col space-y-1 overflow-y-auto"> <div class="pl-2.5 my-2 flex-1 flex flex-col space-y-1 overflow-y-auto">
{#each $chats.filter((chat) => { {#each $chats.filter((chat) => {
if (search === '') { if (search === '') {
return true; return true;
} else { } else {
let title = chat.title.toLowerCase(); let title = chat.title.toLowerCase();
const query = search.toLowerCase();
if (title.includes(search)) {
return true; let contentMatches = false;
} else { // Access the messages within chat.chat.messages
return false; if (chat.chat && chat.chat.messages && Array.isArray(chat.chat.messages)) {
contentMatches = chat.chat.messages.some((message) => {
// Check if message.content exists and includes the search query
return message.content && message.content.toLowerCase().includes(query);
});
} }
return title.includes(query) || contentMatches;
} }
}) as chat, i} }) as chat, i}
<div class=" w-full pr-2 relative"> <div class=" w-full pr-2 relative">
<button <a
class=" w-full flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 {chat.id === class=" w-full flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 {chat.id ===
$chatId $chatId
? 'bg-gray-900' ? 'bg-gray-900'
: ''} transition whitespace-nowrap text-ellipsis" : ''} transition whitespace-nowrap text-ellipsis"
on:click={() => { href="/c/{chat.id}"
// goto(`/c/${chat.id}`);
if (chat.id !== chatTitleEditId) {
chatTitleEditId = null;
chatTitle = '';
}
if (chat.id !== $chatId) {
loadChat(chat.id);
}
}}
> >
<div class=" flex self-center flex-1"> <div class=" flex self-center flex-1 w-full">
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
</div>
<div <div
class=" text-left self-center overflow-hidden {chat.id === $chatId class=" text-left self-center overflow-hidden {chat.id === $chatId
? 'w-[120px]' ? 'w-[160px]'
: 'w-[180px]'} " : 'w-full'} "
> >
{#if chatTitleEditId === chat.id} {#if chatTitleEditId === chat.id}
<input bind:value={chatTitle} class=" bg-transparent w-full" /> <input bind:value={chatTitle} class=" bg-transparent w-full" />
...@@ -310,7 +380,7 @@ ...@@ -310,7 +380,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</button> </a>
{#if chat.id === $chatId} {#if chat.id === $chatId}
<div class=" absolute right-[22px] top-[10px]"> <div class=" absolute right-[22px] top-[10px]">
...@@ -481,7 +551,8 @@ ...@@ -481,7 +551,8 @@
{#if showDropdown} {#if showDropdown}
<div <div
id="dropdownDots" id="dropdownDots"
class="absolute z-10 bottom-[70px] 4.5rem rounded-lg shadow w-[240px] bg-gray-900" class="absolute z-40 bottom-[70px] 4.5rem rounded-lg shadow w-[240px] bg-gray-900"
in:slide={{ duration: 150 }}
> >
<div class="py-2 w-full"> <div class="py-2 w-full">
{#if $user.role === 'admin'} {#if $user.role === 'admin'}
......
import { dev } from '$app/environment'; import { dev } from '$app/environment';
// import { version } from '../../package.json';
export const APP_NAME = 'Open WebUI';
export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``; export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``;
export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`; export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`;
export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama/api`; export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama/api`;
export const OPENAI_API_BASE_URL = `${WEBUI_BASE_URL}/openai/api`; export const OPENAI_API_BASE_URL = `${WEBUI_BASE_URL}/openai/api`;
export const AUDIO_API_BASE_URL = `${WEBUI_BASE_URL}/audio/api/v1`;
export const IMAGES_API_BASE_URL = `${WEBUI_BASE_URL}/images/api/v1`;
export const RAG_API_BASE_URL = `${WEBUI_BASE_URL}/rag/api/v1`; export const RAG_API_BASE_URL = `${WEBUI_BASE_URL}/rag/api/v1`;
export const WEB_UI_VERSION = 'v1.0.0-alpha-static'; export const WEBUI_VERSION = APP_VERSION;
export const REQUIRED_OLLAMA_VERSION = '0.1.16'; export const REQUIRED_OLLAMA_VERSION = '0.1.16';
export const SUPPORTED_FILE_TYPE = [
'application/epub+zip',
'application/pdf',
'text/plain',
'text/csv',
'text/xml',
'text/x-python',
'text/css',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream',
'application/x-javascript',
'text/markdown',
'audio/mpeg',
'audio/wav'
];
export const SUPPORTED_FILE_EXTENSIONS = [
'md',
'rst',
'go',
'py',
'java',
'sh',
'bat',
'ps1',
'cmd',
'js',
'ts',
'css',
'cpp',
'hpp',
'h',
'c',
'cs',
'sql',
'log',
'ini',
'pl',
'pm',
'r',
'dart',
'dockerfile',
'env',
'php',
'hs',
'hsc',
'lua',
'nginxconf',
'conf',
'm',
'mm',
'plsql',
'perl',
'rb',
'rs',
'db2',
'scala',
'bash',
'swift',
'vue',
'svelte',
'doc',
'docx',
'pdf',
'csv',
'txt',
'xls',
'xlsx'
];
// Source: https://kit.svelte.dev/docs/modules#$env-static-public // Source: https://kit.svelte.dev/docs/modules#$env-static-public
// This feature, akin to $env/static/private, exclusively incorporates environment variables // This feature, akin to $env/static/private, exclusively incorporates environment variables
// that are prefixed with config.kit.env.publicPrefix (usually set to PUBLIC_). // that are prefixed with config.kit.env.publicPrefix (usually set to PUBLIC_).
......
import { APP_NAME } from '$lib/constants';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
// Backend // Backend
export const WEBUI_NAME = writable(APP_NAME);
export const config = writable(undefined); export const config = writable(undefined);
export const user = writable(undefined); export const user = writable(undefined);
...@@ -10,9 +12,26 @@ export const theme = writable('dark'); ...@@ -10,9 +12,26 @@ export const theme = writable('dark');
export const chatId = writable(''); export const chatId = writable('');
export const chats = writable([]); export const chats = writable([]);
export const tags = writable([]);
export const models = writable([]); export const models = writable([]);
export const modelfiles = writable([]); export const modelfiles = writable([]);
export const prompts = writable([]); export const prompts = writable([]);
export const documents = writable([
{
collection_name: 'collection_name',
filename: 'filename',
name: 'name',
title: 'title'
},
{
collection_name: 'collection_name1',
filename: 'filename1',
name: 'name1',
title: 'title1'
}
]);
export const settings = writable({}); export const settings = writable({});
export const showSettings = writable(false); export const showSettings = writable(false);
export const showChangelog = writable(false);
...@@ -128,6 +128,37 @@ export const findWordIndices = (text) => { ...@@ -128,6 +128,37 @@ export const findWordIndices = (text) => {
return matches; return matches;
}; };
export const removeFirstHashWord = (inputString) => {
// Split the string into an array of words
const words = inputString.split(' ');
// Find the index of the first word that starts with #
const index = words.findIndex((word) => word.startsWith('#'));
// Remove the first word with #
if (index !== -1) {
words.splice(index, 1);
}
// Join the remaining words back into a string
const resultString = words.join(' ');
return resultString;
};
export const transformFileName = (fileName) => {
// Convert to lowercase
const lowerCaseFileName = fileName.toLowerCase();
// Remove special characters using regular expression
const sanitizedFileName = lowerCaseFileName.replace(/[^\w\s]/g, '');
// Replace spaces with dashes
const finalFileName = sanitizedFileName.replace(/\s+/g, '-');
return finalFileName;
};
export const calculateSHA256 = async (file) => { export const calculateSHA256 = async (file) => {
// Create a FileReader to read the file asynchronously // Create a FileReader to read the file asynchronously
const reader = new FileReader(); const reader = new FileReader();
...@@ -161,3 +192,158 @@ export const calculateSHA256 = async (file) => { ...@@ -161,3 +192,158 @@ export const calculateSHA256 = async (file) => {
throw error; throw error;
} }
}; };
export const getImportOrigin = (_chats) => {
// Check what external service chat imports are from
if ('mapping' in _chats[0]) {
return 'openai';
}
return 'webui';
};
const convertOpenAIMessages = (convo) => {
// Parse OpenAI chat messages and create chat dictionary for creating new chats
const mapping = convo['mapping'];
const messages = [];
let currentId = '';
let lastId = null;
for (let message_id in mapping) {
const message = mapping[message_id];
currentId = message_id;
try {
if (
messages.length == 0 &&
(message['message'] == null ||
(message['message']['content']['parts']?.[0] == '' &&
message['message']['content']['text'] == null))
) {
// Skip chat messages with no content
continue;
} else {
const new_chat = {
id: message_id,
parentId: lastId,
childrenIds: message['children'] || [],
role: message['message']?.['author']?.['role'] !== 'user' ? 'assistant' : 'user',
content:
message['message']?.['content']?.['parts']?.[0] ||
message['message']?.['content']?.['text'] ||
'',
model: 'gpt-3.5-turbo',
done: true,
context: null
};
messages.push(new_chat);
lastId = currentId;
}
} catch (error) {
console.log('Error with', message, '\nError:', error);
}
}
let history = {};
messages.forEach((obj) => (history[obj.id] = obj));
const chat = {
history: {
currentId: currentId,
messages: history // Need to convert this to not a list and instead a json object
},
models: ['gpt-3.5-turbo'],
messages: messages,
options: {},
timestamp: convo['create_time'],
title: convo['title'] ?? 'New Chat'
};
return chat;
};
const validateChat = (chat) => {
// Because ChatGPT sometimes has features we can't use like DALL-E or migh have corrupted messages, need to validate
const messages = chat.messages;
// Check if messages array is empty
if (messages.length === 0) {
return false;
}
// Last message's children should be an empty array
const lastMessage = messages[messages.length - 1];
if (lastMessage.childrenIds.length !== 0) {
return false;
}
// First message's parent should be null
const firstMessage = messages[0];
if (firstMessage.parentId !== null) {
return false;
}
// Every message's content should be a string
for (let message of messages) {
if (typeof message.content !== 'string') {
return false;
}
}
return true;
};
export const convertOpenAIChats = (_chats) => {
// Create a list of dictionaries with each conversation from import
const chats = [];
let failed = 0;
for (let convo of _chats) {
const chat = convertOpenAIMessages(convo);
if (validateChat(chat)) {
chats.push({
id: convo['id'],
user_id: '',
title: convo['title'],
chat: chat,
timestamp: convo['timestamp']
});
} else {
failed++;
}
}
console.log(failed, 'Conversations could not be imported');
return chats;
};
export const isValidHttpUrl = (string) => {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
};
export const removeEmojis = (str) => {
// Regular expression to match emojis
const emojiRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g;
// Replace emojis with an empty string
return str.replace(emojiRegex, '');
};
export const extractSentences = (text) => {
// Split the paragraph into sentences based on common punctuation marks
const sentences = text.split(/(?<=[.!?])/);
return sentences
.map((sentence) => removeEmojis(sentence.trim()))
.filter((sentence) => sentence !== '');
};
export const blobToFile = (blob, fileName) => {
// Create a new File object from the Blob
const file = new File([blob], fileName, { type: blob.type });
return file;
};
export const RAGTemplate = (context: string, query: string) => { import { getRAGTemplate } from '$lib/apis/rag';
let template = `Use the following context as your learned knowledge, inside <context></context> XML tags.
<context> export const RAGTemplate = async (token: string, context: string, query: string) => {
[context] let template = await getRAGTemplate(token).catch(() => {
</context> return `Use the following context as your learned knowledge, inside <context></context> XML tags.
<context>
When answer to user: [context]
- If you don't know, just say that you don't know. </context>
- If you don't know when you are not sure, ask for clarification.
Avoid mentioning that you obtained the information from the context. When answer to user:
And answer according to the language of the user's question. - If you don't know, just say that you don't know.
- If you don't know when you are not sure, ask for clarification.
Given the context information, answer the query. Avoid mentioning that you obtained the information from the context.
Query: [query]`; And answer according to the language of the user's question.
Given the context information, answer the query.
Query: [query]`;
});
template = template.replace(/\[context\]/g, context); template = template.replace(/\[context\]/g, context);
template = template.replace(/\[query\]/g, query); template = template.replace(/\[query\]/g, query);
......
<script lang="ts"> <script lang="ts">
import toast from 'svelte-french-toast'; import toast from 'svelte-french-toast';
import { openDB, deleteDB } from 'idb'; import { openDB, deleteDB } from 'idb';
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { getOllamaModels, getOllamaVersion } from '$lib/apis/ollama'; import { getOllamaModels, getOllamaVersion } from '$lib/apis/ollama';
import { getModelfiles } from '$lib/apis/modelfiles'; import { getModelfiles } from '$lib/apis/modelfiles';
import { getPrompts } from '$lib/apis/prompts'; import { getPrompts } from '$lib/apis/prompts';
import { getOpenAIModels } from '$lib/apis/openai'; import { getOpenAIModels } from '$lib/apis/openai';
import { getDocs } from '$lib/apis/documents';
import { user, showSettings, settings, models, modelfiles, prompts } from '$lib/stores'; import { getAllChatTags } from '$lib/apis/chats';
import {
user,
showSettings,
settings,
models,
modelfiles,
prompts,
documents,
tags,
showChangelog,
config
} from '$lib/stores';
import { REQUIRED_OLLAMA_VERSION, WEBUI_API_BASE_URL } from '$lib/constants'; import { REQUIRED_OLLAMA_VERSION, WEBUI_API_BASE_URL } from '$lib/constants';
import { checkVersion } from '$lib/utils';
import SettingsModal from '$lib/components/chat/SettingsModal.svelte'; import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte'; import Sidebar from '$lib/components/layout/Sidebar.svelte';
import { checkVersion } from '$lib/utils';
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
let ollamaVersion = ''; let ollamaVersion = '';
let loaded = false; let loaded = false;
...@@ -93,11 +106,11 @@ ...@@ -93,11 +106,11 @@
console.log(); console.log();
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}')); await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
await modelfiles.set(await getModelfiles(localStorage.token));
await modelfiles.set(await getModelfiles(localStorage.token));
await prompts.set(await getPrompts(localStorage.token)); await prompts.set(await getPrompts(localStorage.token));
await documents.set(await getDocs(localStorage.token));
console.log($modelfiles); await tags.set(await getAllChatTags(localStorage.token));
modelfiles.subscribe(async () => { modelfiles.subscribe(async () => {
// should fetch models // should fetch models
...@@ -171,6 +184,10 @@ ...@@ -171,6 +184,10 @@
} }
}); });
if ($user.role === 'admin') {
showChangelog.set(localStorage.version !== $config.version);
}
await tick(); await tick();
} }
...@@ -257,7 +274,7 @@ ...@@ -257,7 +274,7 @@
Trouble accessing Ollama? Trouble accessing Ollama?
<a <a
class=" text-black dark:text-white font-semibold underline" class=" text-black dark:text-white font-semibold underline"
href="https://github.com/ollama-webui/ollama-webui#troubleshooting" href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank" target="_blank"
> >
Click here for help. Click here for help.
...@@ -340,10 +357,11 @@ ...@@ -340,10 +357,11 @@
{/if} {/if}
<div <div
class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 min-h-screen overflow-auto flex flex-row" class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-900 min-h-screen overflow-auto flex flex-row"
> >
<Sidebar /> <Sidebar />
<SettingsModal bind:show={$showSettings} /> <SettingsModal bind:show={$showSettings} />
<ChangelogModal bind:show={$showChangelog} />
<slot /> <slot />
</div> </div>
</div> </div>
......
...@@ -6,12 +6,29 @@ ...@@ -6,12 +6,29 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { models, modelfiles, user, settings, chats, chatId, config } from '$lib/stores'; import {
models,
modelfiles,
user,
settings,
chats,
chatId,
config,
tags as _tags
} from '$lib/stores';
import { copyToClipboard, splitStream } from '$lib/utils'; import { copyToClipboard, splitStream } from '$lib/utils';
import { generateChatCompletion, generateTitle } from '$lib/apis/ollama'; import { generateChatCompletion, cancelChatCompletion, generateTitle } from '$lib/apis/ollama';
import { createNewChat, getChatList, updateChatById } from '$lib/apis/chats'; import {
import { queryVectorDB } from '$lib/apis/rag'; addTagById,
createNewChat,
deleteTagById,
getAllChatTags,
getChatList,
getTagsById,
updateChatById
} from '$lib/apis/chats';
import { queryCollection, queryDoc } from '$lib/apis/rag';
import { generateOpenAIChatCompletion } from '$lib/apis/openai'; import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import MessageInput from '$lib/components/chat/MessageInput.svelte'; import MessageInput from '$lib/components/chat/MessageInput.svelte';
...@@ -19,11 +36,14 @@ ...@@ -19,11 +36,14 @@
import ModelSelector from '$lib/components/chat/ModelSelector.svelte'; import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte'; import Navbar from '$lib/components/layout/Navbar.svelte';
import { RAGTemplate } from '$lib/utils/rag'; import { RAGTemplate } from '$lib/utils/rag';
import { WEBUI_BASE_URL } from '$lib/constants';
let stopResponseFlag = false; let stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
let currentRequestId = null;
let selectedModels = ['']; let selectedModels = [''];
let selectedModelfile = null; let selectedModelfile = null;
...@@ -45,6 +65,7 @@ ...@@ -45,6 +65,7 @@
}, {}); }, {});
let chat = null; let chat = null;
let tags = [];
let title = ''; let title = '';
let prompt = ''; let prompt = '';
...@@ -78,6 +99,11 @@ ...@@ -78,6 +99,11 @@
////////////////////////// //////////////////////////
const initNewChat = async () => { const initNewChat = async () => {
if (currentRequestId !== null) {
await cancelChatCompletion(localStorage.token, currentRequestId);
currentRequestId = null;
}
window.history.replaceState(history.state, '', `/`); window.history.replaceState(history.state, '', `/`);
console.log('initNewChat'); console.log('initNewChat');
...@@ -112,11 +138,16 @@ ...@@ -112,11 +138,16 @@
}); });
}; };
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
element.scrollTop = element.scrollHeight;
};
////////////////////////// //////////////////////////
// Ollama functions // Ollama functions
////////////////////////// //////////////////////////
const submitPrompt = async (userPrompt) => { const submitPrompt = async (userPrompt, _user = null) => {
console.log('submitPrompt', $chatId); console.log('submitPrompt', $chatId);
if (selectedModels.includes('')) { if (selectedModels.includes('')) {
...@@ -143,8 +174,10 @@ ...@@ -143,8 +174,10 @@
parentId: messages.length !== 0 ? messages.at(-1).id : null, parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [], childrenIds: [],
role: 'user', role: 'user',
user: _user ?? undefined,
content: userPrompt, content: userPrompt,
files: files.length > 0 ? files : undefined files: files.length > 0 ? files : undefined,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
}; };
// Add message to history and Set currentId to messageId // Add message to history and Set currentId to messageId
...@@ -172,6 +205,7 @@ ...@@ -172,6 +205,7 @@
}, },
messages: messages, messages: messages,
history: history, history: history,
tags: [],
timestamp: Date.now() timestamp: Date.now()
}); });
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
...@@ -196,7 +230,9 @@ ...@@ -196,7 +230,9 @@
const docs = messages const docs = messages
.filter((message) => message?.files ?? null) .filter((message) => message?.files ?? null)
.map((message) => message.files.filter((item) => item.type === 'doc')) .map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1); .flat(1);
console.log(docs); console.log(docs);
...@@ -206,12 +242,21 @@ ...@@ -206,12 +242,21 @@
let relevantContexts = await Promise.all( let relevantContexts = await Promise.all(
docs.map(async (doc) => { docs.map(async (doc) => {
return await queryVectorDB(localStorage.token, doc.collection_name, query, 4).catch( if (doc.type === 'collection') {
(error) => { return await queryCollection(localStorage.token, doc.collection_names, query, 4).catch(
console.log(error); (error) => {
return null; console.log(error);
} return null;
); }
);
} else {
return await queryDoc(localStorage.token, doc.collection_name, query, 4).catch(
(error) => {
console.log(error);
return null;
}
);
}
}) })
); );
relevantContexts = relevantContexts.filter((context) => context); relevantContexts = relevantContexts.filter((context) => context);
...@@ -222,7 +267,11 @@ ...@@ -222,7 +267,11 @@
console.log(contextString); console.log(contextString);
history.messages[parentId].raContent = RAGTemplate(contextString, query); history.messages[parentId].raContent = await RAGTemplate(
localStorage.token,
contextString,
query
);
history.messages[parentId].contexts = relevantContexts; history.messages[parentId].contexts = relevantContexts;
await tick(); await tick();
processing = ''; processing = '';
...@@ -233,10 +282,34 @@ ...@@ -233,10 +282,34 @@
console.log(model); console.log(model);
const modelTag = $models.filter((m) => m.name === model).at(0); const modelTag = $models.filter((m) => m.name === model).at(0);
// Create response message
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
if (modelTag?.external) { if (modelTag?.external) {
await sendPromptOpenAI(model, prompt, parentId, _chatId); await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (modelTag) { } else if (modelTag) {
await sendPromptOllama(model, prompt, parentId, _chatId); await sendPromptOllama(model, prompt, responseMessageId, _chatId);
} else { } else {
toast.error(`Model ${model} not found`); toast.error(`Model ${model} not found`);
} }
...@@ -246,64 +319,64 @@ ...@@ -246,64 +319,64 @@
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
}; };
const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => { const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
// Create response message const responseMessage = history.messages[responseMessageId];
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
// Wait until history/message have been updated // Wait until history/message have been updated
await tick(); await tick();
// Scroll down // Scroll down
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
const messagesBody = [
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages.filter((message) => !message.deleted)
]
.filter((message) => message)
.map((message, idx, arr) => ({
role: message.role,
content: arr.length - 2 !== idx ? message.content : message?.raContent ?? message.content,
...(message.files && {
images: message.files
.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1))
})
}));
let lastImageIndex = -1;
// Find the index of the last object with images
messagesBody.forEach((item, index) => {
if (item.images) {
lastImageIndex = index;
}
});
const res = await generateChatCompletion(localStorage.token, { // Remove images from all but the last one
messagesBody.forEach((item, index) => {
if (index !== lastImageIndex) {
delete item.images;
}
});
const [res, controller] = await generateChatCompletion(localStorage.token, {
model: model, model: model,
messages: [ messages: messagesBody,
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages
]
.filter((message) => message)
.map((message) => ({
role: message.role,
content: message?.raContent ?? message.content,
...(message.files && {
images: message.files
.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1))
})
})),
options: { options: {
...($settings.options ?? {}) ...($settings.options ?? {})
}, },
format: $settings.requestFormat ?? undefined format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined
}); });
if (res && res.ok) { if (res && res.ok) {
console.log('controller', controller);
const reader = res.body const reader = res.body
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n')) .pipeThrough(splitStream('\n'))
...@@ -314,6 +387,14 @@ ...@@ -314,6 +387,14 @@
if (done || stopResponseFlag || _chatId !== $chatId) { if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true; responseMessage.done = true;
messages = messages; messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
await cancelChatCompletion(localStorage.token, currentRequestId);
}
currentRequestId = null;
break; break;
} }
...@@ -329,52 +410,62 @@ ...@@ -329,52 +410,62 @@
throw data; throw data;
} }
if (data.done == false) { if ('id' in data) {
if (responseMessage.content == '' && data.message.content == '\n') { console.log(data);
continue; currentRequestId = data.id;
} else {
if (data.done == false) {
if (responseMessage.content == '' && data.message.content == '\n') {
continue;
} else {
responseMessage.content += data.message.content;
messages = messages;
}
} else { } else {
responseMessage.content += data.message.content; responseMessage.done = true;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null;
responseMessage.info = {
total_duration: data.total_duration,
load_duration: data.load_duration,
sample_count: data.sample_count,
sample_duration: data.sample_duration,
prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages; messages = messages;
}
} else {
responseMessage.done = true;
if (responseMessage.content == '') { if ($settings.notificationEnabled && !document.hasFocus()) {
responseMessage.error = true; const notification = new Notification(
responseMessage.content = selectedModelfile
'Oops! No text generated from Ollama, Please try again.'; ? `${
} selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
responseMessage.context = data.context ?? null; }`
responseMessage.info = { : `${model}`,
total_duration: data.total_duration, {
load_duration: data.load_duration, body: responseMessage.content,
sample_count: data.sample_count, icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
sample_duration: data.sample_duration, }
prompt_eval_count: data.prompt_eval_count, );
prompt_eval_duration: data.prompt_eval_duration, }
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages;
if ($settings.notificationEnabled && !document.hasFocus()) { if ($settings.responseAutoCopy) {
const notification = new Notification( copyToClipboard(responseMessage.content);
selectedModelfile }
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `Ollama - ${model}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? '/favicon.png'
}
);
}
if ($settings.responseAutoCopy) { if ($settings.responseAutoPlayback) {
copyToClipboard(responseMessage.content); await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
} }
} }
} }
...@@ -388,7 +479,7 @@ ...@@ -388,7 +479,7 @@
} }
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
} }
...@@ -427,7 +518,7 @@ ...@@ -427,7 +518,7 @@
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
if (messages.length == 2 && messages.at(1).content !== '') { if (messages.length == 2 && messages.at(1).content !== '') {
...@@ -436,28 +527,9 @@ ...@@ -436,28 +527,9 @@
} }
}; };
const sendPromptOpenAI = async (model, userPrompt, parentId, _chatId) => { const sendPromptOpenAI = async (model, userPrompt, responseMessageId, _chatId) => {
let responseMessageId = uuidv4(); const responseMessage = history.messages[responseMessageId];
scrollToBottom();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight });
const res = await generateOpenAIChatCompletion(localStorage.token, { const res = await generateOpenAIChatCompletion(localStorage.token, {
model: model, model: model,
...@@ -469,17 +541,20 @@ ...@@ -469,17 +541,20 @@
content: $settings.system content: $settings.system
} }
: undefined, : undefined,
...messages ...messages.filter((message) => !message.deleted)
] ]
.filter((message) => message) .filter((message) => message)
.map((message) => ({ .map((message, idx, arr) => ({
role: message.role, role: message.role,
...(message.files ...(message.files?.filter((file) => file.type === 'image').length > 0 ?? false
? { ? {
content: [ content: [
{ {
type: 'text', type: 'text',
text: message?.raContent ?? message.content text:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
}, },
...message.files ...message.files
.filter((file) => file.type === 'image') .filter((file) => file.type === 'image')
...@@ -491,7 +566,10 @@ ...@@ -491,7 +566,10 @@
})) }))
] ]
} }
: { content: message?.raContent ?? message.content }) : {
content:
arr.length - 1 !== idx ? message.content : message?.raContent ?? message.content
})
})), })),
seed: $settings?.options?.seed ?? undefined, seed: $settings?.options?.seed ?? undefined,
stop: $settings?.options?.stop ?? undefined, stop: $settings?.options?.stop ?? undefined,
...@@ -545,7 +623,7 @@ ...@@ -545,7 +623,7 @@
if ($settings.notificationEnabled && !document.hasFocus()) { if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(`OpenAI ${model}`, { const notification = new Notification(`OpenAI ${model}`, {
body: responseMessage.content, body: responseMessage.content,
icon: '/favicon.png' icon: `${WEBUI_BASE_URL}/static/favicon.png`
}); });
} }
...@@ -553,8 +631,13 @@ ...@@ -553,8 +631,13 @@
copyToClipboard(responseMessage.content); copyToClipboard(responseMessage.content);
} }
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
} }
...@@ -598,7 +681,7 @@ ...@@ -598,7 +681,7 @@
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
if (messages.length == 2) { if (messages.length == 2) {
...@@ -625,10 +708,43 @@ ...@@ -625,10 +708,43 @@
} }
}; };
const continueGeneration = async () => {
console.log('continueGeneration');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (messages.length != 0 && messages.at(-1).done == true) {
const responseMessage = history.messages[history.currentId];
responseMessage.done = false;
await tick();
const modelTag = $models.filter((m) => m.name === responseMessage.model).at(0);
if (modelTag?.external) {
await sendPromptOpenAI(
responseMessage.model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else if (modelTag) {
await sendPromptOllama(
responseMessage.model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else {
toast.error(`Model ${model} not found`);
}
}
};
const generateChatTitle = async (_chatId, userPrompt) => { const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) { if ($settings.titleAutoGenerate ?? true) {
const title = await generateTitle( const title = await generateTitle(
localStorage.token, localStorage.token,
$settings?.titleGenerationPrompt ??
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title': {{prompt}}",
$settings?.titleAutoGenerateModel ?? selectedModels[0], $settings?.titleAutoGenerateModel ?? selectedModels[0],
userPrompt userPrompt
); );
...@@ -641,6 +757,34 @@ ...@@ -641,6 +757,34 @@
} }
}; };
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
const deleteTag = async (tagName) => {
const res = await deleteTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
const setChatTitle = async (_chatId, _title) => { const setChatTitle = async (_chatId, _title) => {
if (_chatId === $chatId) { if (_chatId === $chatId) {
title = _title; title = _title;
...@@ -653,59 +797,52 @@ ...@@ -653,59 +797,52 @@
}; };
</script> </script>
<svelte:window <div class="h-screen max-h-[100dvh] w-full flex flex-col">
on:scroll={(e) => { <Navbar {title} shareEnabled={messages.length > 0} {initNewChat} {tags} {addTag} {deleteTag} />
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40; <div class="flex flex-col flex-auto">
}} <div
/> class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container"
<Navbar {title} shareEnabled={messages.length > 0} {initNewChat} /> on:scroll={(e) => {
<div class="min-h-screen w-full flex justify-center"> autoScroll = e.target.scrollHeight - e.target.scrollTop <= e.target.clientHeight + 50;
<div class=" py-2.5 flex flex-col justify-between w-full"> }}
<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10"> >
<ModelSelector bind:selectedModels disabled={messages.length > 0} /> <div
class="{$settings?.fullScreenMode ?? null
? 'max-w-full'
: 'max-w-2xl md:px-0'} mx-auto w-full px-4"
>
<ModelSelector
bind:selectedModels
disabled={messages.length > 0 && !selectedModels.includes('')}
/>
</div>
<div class=" h-full w-full flex flex-col py-8">
<Messages
chatId={$chatId}
{selectedModels}
{selectedModelfiles}
{processing}
bind:history
bind:messages
bind:autoScroll
bottomPadding={files.length > 0}
{sendPrompt}
{continueGeneration}
{regenerateResponse}
/>
</div>
</div> </div>
<div class=" h-full mt-10 mb-32 w-full flex flex-col"> <MessageInput
<Messages bind:files
chatId={$chatId} bind:prompt
{selectedModels} bind:autoScroll
{selectedModelfiles} suggestionPrompts={selectedModelfile?.suggestionPrompts ?? $config.default_prompt_suggestions}
{processing} {messages}
bind:history {submitPrompt}
bind:messages {stopResponse}
bind:autoScroll />
bottomPadding={files.length > 0}
{sendPrompt}
{regenerateResponse}
/>
</div>
</div> </div>
<MessageInput
bind:files
bind:prompt
bind:autoScroll
suggestionPrompts={selectedModelfile?.suggestionPrompts ?? [
{
title: ['Help me study', 'vocabulary for a college entrance exam'],
content: `Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.`
},
{
title: ['Give me ideas', `for what to do with my kids' art`],
content: `What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.`
},
{
title: ['Tell me a fun fact', 'about the Roman Empire'],
content: 'Tell me a random fun fact about the Roman Empire'
},
{
title: ['Show me a code snippet', `of a website's sticky header`],
content: `Show me a code snippet of a website's sticky header in CSS and JavaScript.`
}
]}
{messages}
{submitPrompt}
{stopResponse}
/>
</div> </div>
...@@ -9,13 +9,14 @@ ...@@ -9,13 +9,14 @@
import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users'; import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths'; import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
import EditUserModal from '$lib/components/admin/EditUserModal.svelte'; import EditUserModal from '$lib/components/admin/EditUserModal.svelte';
import SettingsModal from '$lib/components/admin/SettingsModal.svelte';
let loaded = false; let loaded = false;
let users = []; let users = [];
let selectedUser = null; let selectedUser = null;
let signUpEnabled = true; let showSettingsModal = false;
let showEditUserModal = false; let showEditUserModal = false;
const updateRoleHandler = async (id, role) => { const updateRoleHandler = async (id, role) => {
...@@ -50,17 +51,11 @@ ...@@ -50,17 +51,11 @@
} }
}; };
const toggleSignUpEnabled = async () => {
signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token);
};
onMount(async () => { onMount(async () => {
if ($user?.role !== 'admin') { if ($user?.role !== 'admin') {
await goto('/'); await goto('/');
} else { } else {
users = await getUsers(localStorage.token); users = await getUsers(localStorage.token);
signUpEnabled = await getSignUpEnabledStatus(localStorage.token);
} }
loaded = true; loaded = true;
}); });
...@@ -77,39 +72,32 @@ ...@@ -77,39 +72,32 @@
/> />
{/key} {/key}
<SettingsModal bind:show={showSettingsModal} />
<div <div
class=" bg-white dark:bg-gray-800 dark:text-gray-100 min-h-screen w-full flex justify-center font-mona" class="min-h-screen max-h-[100dvh] w-full flex justify-center dark:text-white bg-white dark:bg-gray-900 font-mona"
> >
{#if loaded} {#if loaded}
<div class="w-full max-w-3xl px-10 md:px-16 min-h-screen flex flex-col"> <div class="overflow-y-auto w-full flex justify-center">
<div class="py-10 w-full"> <div class="w-full max-w-3xl px-6 md:px-16 flex flex-col">
<div class=" flex flex-col justify-center"> <div class="py-10 w-full">
<div class=" flex justify-between items-center"> <div class=" flex flex-col justify-center">
<div class=" text-2xl font-semibold">Users ({users.length})</div> <div class=" flex justify-between items-center">
<div> <div class="flex items-center text-2xl font-semibold">
<button All Users
class="flex items-center space-x-1 border border-gray-200 dark:border-gray-600 px-3 py-1 rounded-lg" <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
type="button" <span class="text-lg font-medium text-gray-500 dark:text-gray-300"
on:click={() => { >{users.length}</span
toggleSignUpEnabled(); >
}} </div>
> <div>
{#if signUpEnabled} <button
<svg class="flex items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition"
xmlns="http://www.w3.org/2000/svg" type="button"
viewBox="0 0 16 16" on:click={() => {
fill="currentColor" showSettingsModal = !showSettingsModal;
class="w-4 h-4" }}
> >
<path
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/>
</svg>
<div class=" text-xs">
New Sign Up <span class=" font-semibold">Enabled</span>
</div>
{:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
...@@ -118,116 +106,128 @@ ...@@ -118,116 +106,128 @@
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
<div class=" text-xs"> <div class=" text-xs">Admin Settings</div>
New Sign Up <span class=" font-semibold">Disabled</span> </button>
</div> </div>
{/if} </div>
</button> <div class=" text-gray-500 text-xs mt-1">
ⓘ Click on the user role button to change a user's role.
</div> </div>
</div>
<div class=" text-gray-500 text-xs font-medium mt-1">
Click on the user role cell in the table to change a user's role.
</div>
<hr class=" my-3 dark:border-gray-600" /> <hr class=" my-3 dark:border-gray-600" />
<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap"> <div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400"> <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead <thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400" class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
> >
<tr> <tr>
<th scope="col" class="px-6 py-3"> Name </th> <th scope="col" class="px-3 py-2"> Role </th>
<th scope="col" class="px-6 py-3"> Email </th> <th scope="col" class="px-3 py-2"> Name </th>
<th scope="col" class="px-6 py-3"> Role </th> <th scope="col" class="px-3 py-2"> Email </th>
<th scope="col" class="px-6 py-3"> Action </th> <th scope="col" class="px-3 py-2"> Action </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each users as user} {#each users as user}
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> <tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
<th <td class="px-3 py-2 min-w-[7rem] w-28">
scope="row" <button
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white w-fit" class=" flex items-center gap-2 text-xs px-3 py-0.5 rounded-lg {user.role ===
> 'admin' &&
<div class="flex flex-row"> 'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {user.role === 'user' &&
<img 'text-green-600 dark:text-green-200 bg-green-200/30'} {user.role ===
class=" rounded-full max-w-[30px] max-h-[30px] object-cover mr-4" 'pending' && 'text-gray-600 dark:text-gray-200 bg-gray-200/30'}"
src={user.profile_image_url} on:click={() => {
alt="user" if (user.role === 'user') {
/> updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
<div class=" font-semibold self-center">{user.name}</div> updateRoleHandler(user.id, 'user');
</div> } else {
</th> updateRoleHandler(user.id, 'pending');
<td class="px-6 py-4"> {user.email} </td> }
<td class="px-6 py-4"> }}
<button
class=" dark:text-white underline"
on:click={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}}>{user.role}</button
>
</td>
<td class="px-6 py-4 space-x-1 text-center flex justify-center">
<button
class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path <div
fill-rule="evenodd" class="w-1 h-1 rounded-full {user.role === 'admin' &&
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z" 'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
clip-rule="evenodd" 'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
'bg-gray-600 dark:bg-gray-300'}"
/> />
</svg> {user.role}</button
</button>
<button
class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
on:click={async () => {
deleteUserHandler(user.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
> >
<path </td>
stroke-linecap="round" <td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
stroke-linejoin="round" <div class="flex flex-row w-max">
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" <img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url}
alt="user"
/> />
</svg>
</button> <div class=" font-medium self-center">{user.name}</div>
</td> </div>
</tr> </td>
{/each} <td class=" px-3 py-2"> {user.email} </td>
</tbody>
</table> <td class="px-3 py-2">
<div class="flex justify-start w-full">
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteUserHandler(user.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -6,12 +6,30 @@ ...@@ -6,12 +6,30 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { models, modelfiles, user, settings, chats, chatId, config } from '$lib/stores'; import {
import { copyToClipboard, splitStream } from '$lib/utils'; models,
modelfiles,
import { generateChatCompletion, generateTitle } from '$lib/apis/ollama'; user,
import { createNewChat, getChatById, getChatList, updateChatById } from '$lib/apis/chats'; settings,
import { queryVectorDB } from '$lib/apis/rag'; chats,
chatId,
config,
tags as _tags
} from '$lib/stores';
import { copyToClipboard, splitStream, convertMessagesToHistory } from '$lib/utils';
import { generateChatCompletion, generateTitle, cancelChatCompletion } from '$lib/apis/ollama';
import {
addTagById,
createNewChat,
deleteTagById,
getAllChatTags,
getChatById,
getChatList,
getTagsById,
updateChatById
} from '$lib/apis/chats';
import { queryCollection, queryDoc } from '$lib/apis/rag';
import { generateOpenAIChatCompletion } from '$lib/apis/openai'; import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import MessageInput from '$lib/components/chat/MessageInput.svelte'; import MessageInput from '$lib/components/chat/MessageInput.svelte';
...@@ -19,6 +37,7 @@ ...@@ -19,6 +37,7 @@
import ModelSelector from '$lib/components/chat/ModelSelector.svelte'; import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte'; import Navbar from '$lib/components/layout/Navbar.svelte';
import { RAGTemplate } from '$lib/utils/rag'; import { RAGTemplate } from '$lib/utils/rag';
import { WEBUI_BASE_URL } from '$lib/constants';
let loaded = false; let loaded = false;
...@@ -26,6 +45,8 @@ ...@@ -26,6 +45,8 @@
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
let currentRequestId = null;
// let chatId = $page.params.id; // let chatId = $page.params.id;
let selectedModels = ['']; let selectedModels = [''];
let selectedModelfile = null; let selectedModelfile = null;
...@@ -47,6 +68,7 @@ ...@@ -47,6 +68,7 @@
}, {}); }, {});
let chat = null; let chat = null;
let tags = [];
let title = ''; let title = '';
let prompt = ''; let prompt = '';
...@@ -95,6 +117,7 @@ ...@@ -95,6 +117,7 @@
}); });
if (chat) { if (chat) {
tags = await getTags();
const chatContent = chat.chat; const chatContent = chat.chat;
if (chatContent) { if (chatContent) {
...@@ -103,7 +126,7 @@ ...@@ -103,7 +126,7 @@
selectedModels = selectedModels =
(chatContent?.models ?? undefined) !== undefined (chatContent?.models ?? undefined) !== undefined
? chatContent.models ? chatContent.models
: [chatContent.model ?? '']; : [chatContent.models ?? ''];
history = history =
(chatContent?.history ?? undefined) !== undefined (chatContent?.history ?? undefined) !== undefined
? chatContent.history ? chatContent.history
...@@ -131,11 +154,16 @@ ...@@ -131,11 +154,16 @@
} }
}; };
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
element.scrollTop = element.scrollHeight;
};
////////////////////////// //////////////////////////
// Ollama functions // Ollama functions
////////////////////////// //////////////////////////
const submitPrompt = async (userPrompt) => { const submitPrompt = async (userPrompt, _user = null) => {
console.log('submitPrompt', $chatId); console.log('submitPrompt', $chatId);
if (selectedModels.includes('')) { if (selectedModels.includes('')) {
...@@ -143,6 +171,14 @@ ...@@ -143,6 +171,14 @@
} else if (messages.length != 0 && messages.at(-1).done != true) { } else if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done // Response not done
console.log('wait'); console.log('wait');
} else if (
files.length > 0 &&
files.filter((file) => file.upload_status === false).length > 0
) {
// Upload not done
toast.error(
`Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.`
);
} else { } else {
// Reset chat message textarea height // Reset chat message textarea height
document.getElementById('chat-textarea').style.height = ''; document.getElementById('chat-textarea').style.height = '';
...@@ -154,8 +190,10 @@ ...@@ -154,8 +190,10 @@
parentId: messages.length !== 0 ? messages.at(-1).id : null, parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [], childrenIds: [],
role: 'user', role: 'user',
user: _user ?? undefined,
content: userPrompt, content: userPrompt,
files: files.length > 0 ? files : undefined files: files.length > 0 ? files : undefined,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
}; };
// Add message to history and Set currentId to messageId // Add message to history and Set currentId to messageId
...@@ -192,7 +230,6 @@ ...@@ -192,7 +230,6 @@
} }
await tick(); await tick();
} }
// Reset chat input textarea // Reset chat input textarea
prompt = ''; prompt = '';
files = []; files = [];
...@@ -207,7 +244,9 @@ ...@@ -207,7 +244,9 @@
const docs = messages const docs = messages
.filter((message) => message?.files ?? null) .filter((message) => message?.files ?? null)
.map((message) => message.files.filter((item) => item.type === 'doc')) .map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1); .flat(1);
console.log(docs); console.log(docs);
...@@ -217,12 +256,21 @@ ...@@ -217,12 +256,21 @@
let relevantContexts = await Promise.all( let relevantContexts = await Promise.all(
docs.map(async (doc) => { docs.map(async (doc) => {
return await queryVectorDB(localStorage.token, doc.collection_name, query, 4).catch( if (doc.type === 'collection') {
(error) => { return await queryCollection(localStorage.token, doc.collection_names, query, 4).catch(
console.log(error); (error) => {
return null; console.log(error);
} return null;
); }
);
} else {
return await queryDoc(localStorage.token, doc.collection_name, query, 4).catch(
(error) => {
console.log(error);
return null;
}
);
}
}) })
); );
relevantContexts = relevantContexts.filter((context) => context); relevantContexts = relevantContexts.filter((context) => context);
...@@ -233,7 +281,11 @@ ...@@ -233,7 +281,11 @@
console.log(contextString); console.log(contextString);
history.messages[parentId].raContent = RAGTemplate(contextString, query); history.messages[parentId].raContent = await RAGTemplate(
localStorage.token,
contextString,
query
);
history.messages[parentId].contexts = relevantContexts; history.messages[parentId].contexts = relevantContexts;
await tick(); await tick();
processing = ''; processing = '';
...@@ -244,10 +296,34 @@ ...@@ -244,10 +296,34 @@
console.log(model); console.log(model);
const modelTag = $models.filter((m) => m.name === model).at(0); const modelTag = $models.filter((m) => m.name === model).at(0);
// Create response message
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
if (modelTag?.external) { if (modelTag?.external) {
await sendPromptOpenAI(model, prompt, parentId, _chatId); await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (modelTag) { } else if (modelTag) {
await sendPromptOllama(model, prompt, parentId, _chatId); await sendPromptOllama(model, prompt, responseMessageId, _chatId);
} else { } else {
toast.error(`Model ${model} not found`); toast.error(`Model ${model} not found`);
} }
...@@ -257,64 +333,64 @@ ...@@ -257,64 +333,64 @@
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
}; };
const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => { const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
// Create response message const responseMessage = history.messages[responseMessageId];
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
// Wait until history/message have been updated // Wait until history/message have been updated
await tick(); await tick();
// Scroll down // Scroll down
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
const messagesBody = [
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages.filter((message) => !message.deleted)
]
.filter((message) => message)
.map((message, idx, arr) => ({
role: message.role,
content: arr.length - 2 !== idx ? message.content : message?.raContent ?? message.content,
...(message.files && {
images: message.files
.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1))
})
}));
let lastImageIndex = -1;
// Find the index of the last object with images
messagesBody.forEach((item, index) => {
if (item.images) {
lastImageIndex = index;
}
});
const res = await generateChatCompletion(localStorage.token, { // Remove images from all but the last one
messagesBody.forEach((item, index) => {
if (index !== lastImageIndex) {
delete item.images;
}
});
const [res, controller] = await generateChatCompletion(localStorage.token, {
model: model, model: model,
messages: [ messages: messagesBody,
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages
]
.filter((message) => message)
.map((message) => ({
role: message.role,
content: message?.raContent ?? message.content,
...(message.files && {
images: message.files
.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1))
})
})),
options: { options: {
...($settings.options ?? {}) ...($settings.options ?? {})
}, },
format: $settings.requestFormat ?? undefined format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined
}); });
if (res && res.ok) { if (res && res.ok) {
console.log('controller', controller);
const reader = res.body const reader = res.body
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n')) .pipeThrough(splitStream('\n'))
...@@ -325,6 +401,14 @@ ...@@ -325,6 +401,14 @@
if (done || stopResponseFlag || _chatId !== $chatId) { if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true; responseMessage.done = true;
messages = messages; messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
await cancelChatCompletion(localStorage.token, currentRequestId);
}
currentRequestId = null;
break; break;
} }
...@@ -340,52 +424,62 @@ ...@@ -340,52 +424,62 @@
throw data; throw data;
} }
if (data.done == false) { if ('id' in data) {
if (responseMessage.content == '' && data.message.content == '\n') { console.log(data);
continue; currentRequestId = data.id;
} else {
if (data.done == false) {
if (responseMessage.content == '' && data.message.content == '\n') {
continue;
} else {
responseMessage.content += data.message.content;
messages = messages;
}
} else { } else {
responseMessage.content += data.message.content; responseMessage.done = true;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null;
responseMessage.info = {
total_duration: data.total_duration,
load_duration: data.load_duration,
sample_count: data.sample_count,
sample_duration: data.sample_duration,
prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages; messages = messages;
}
} else {
responseMessage.done = true;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null; if ($settings.notificationEnabled && !document.hasFocus()) {
responseMessage.info = { const notification = new Notification(
total_duration: data.total_duration, selectedModelfile
load_duration: data.load_duration, ? `${
sample_count: data.sample_count, selectedModelfile.title.charAt(0).toUpperCase() +
sample_duration: data.sample_duration, selectedModelfile.title.slice(1)
prompt_eval_count: data.prompt_eval_count, }`
prompt_eval_duration: data.prompt_eval_duration, : `${model}`,
eval_count: data.eval_count, {
eval_duration: data.eval_duration body: responseMessage.content,
}; icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
messages = messages; }
);
}
if ($settings.notificationEnabled && !document.hasFocus()) { if ($settings.responseAutoCopy) {
const notification = new Notification( copyToClipboard(responseMessage.content);
selectedModelfile }
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `Ollama - ${model}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? '/favicon.png'
}
);
}
if ($settings.responseAutoCopy) { if ($settings.responseAutoPlayback) {
copyToClipboard(responseMessage.content); await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
} }
} }
} }
...@@ -399,7 +493,7 @@ ...@@ -399,7 +493,7 @@
} }
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
} }
...@@ -438,7 +532,7 @@ ...@@ -438,7 +532,7 @@
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
if (messages.length == 2 && messages.at(1).content !== '') { if (messages.length == 2 && messages.at(1).content !== '') {
...@@ -447,28 +541,10 @@ ...@@ -447,28 +541,10 @@
} }
}; };
const sendPromptOpenAI = async (model, userPrompt, parentId, _chatId) => { const sendPromptOpenAI = async (model, userPrompt, responseMessageId, _chatId) => {
let responseMessageId = uuidv4(); const responseMessage = history.messages[responseMessageId];
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
const res = await generateOpenAIChatCompletion(localStorage.token, { const res = await generateOpenAIChatCompletion(localStorage.token, {
model: model, model: model,
...@@ -480,17 +556,20 @@ ...@@ -480,17 +556,20 @@
content: $settings.system content: $settings.system
} }
: undefined, : undefined,
...messages ...messages.filter((message) => !message.deleted)
] ]
.filter((message) => message) .filter((message) => message)
.map((message) => ({ .map((message, idx, arr) => ({
role: message.role, role: message.role,
...(message.files ...(message.files?.filter((file) => file.type === 'image').length > 0 ?? false
? { ? {
content: [ content: [
{ {
type: 'text', type: 'text',
text: message?.raContent ?? message.content text:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
}, },
...message.files ...message.files
.filter((file) => file.type === 'image') .filter((file) => file.type === 'image')
...@@ -502,7 +581,10 @@ ...@@ -502,7 +581,10 @@
})) }))
] ]
} }
: { content: message?.raContent ?? message.content }) : {
content:
arr.length - 1 !== idx ? message.content : message?.raContent ?? message.content
})
})), })),
seed: $settings?.options?.seed ?? undefined, seed: $settings?.options?.seed ?? undefined,
stop: $settings?.options?.stop ?? undefined, stop: $settings?.options?.stop ?? undefined,
...@@ -556,7 +638,7 @@ ...@@ -556,7 +638,7 @@
if ($settings.notificationEnabled && !document.hasFocus()) { if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(`OpenAI ${model}`, { const notification = new Notification(`OpenAI ${model}`, {
body: responseMessage.content, body: responseMessage.content,
icon: '/favicon.png' icon: `${WEBUI_BASE_URL}/static/favicon.png`
}); });
} }
...@@ -564,8 +646,13 @@ ...@@ -564,8 +646,13 @@
copyToClipboard(responseMessage.content); copyToClipboard(responseMessage.content);
} }
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
} }
...@@ -609,7 +696,7 @@ ...@@ -609,7 +696,7 @@
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); scrollToBottom();
} }
if (messages.length == 2) { if (messages.length == 2) {
...@@ -623,6 +710,37 @@ ...@@ -623,6 +710,37 @@
console.log('stopResponse'); console.log('stopResponse');
}; };
const continueGeneration = async () => {
console.log('continueGeneration');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (messages.length != 0 && messages.at(-1).done == true) {
const responseMessage = history.messages[history.currentId];
responseMessage.done = false;
await tick();
const modelTag = $models.filter((m) => m.name === responseMessage.model).at(0);
if (modelTag?.external) {
await sendPromptOpenAI(
responseMessage.model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else if (modelTag) {
await sendPromptOllama(
responseMessage.model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else {
toast.error(`Model ${model} not found`);
}
}
};
const regenerateResponse = async () => { const regenerateResponse = async () => {
console.log('regenerateResponse'); console.log('regenerateResponse');
if (messages.length != 0 && messages.at(-1).done == true) { if (messages.length != 0 && messages.at(-1).done == true) {
...@@ -638,7 +756,13 @@ ...@@ -638,7 +756,13 @@
const generateChatTitle = async (_chatId, userPrompt) => { const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) { if ($settings.titleAutoGenerate ?? true) {
const title = await generateTitle(localStorage.token, selectedModels[0], userPrompt); const title = await generateTitle(
localStorage.token,
$settings?.titleGenerationPrompt ??
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title': {{prompt}}",
$settings?.titleAutoGenerateModel ?? selectedModels[0],
userPrompt
);
if (title) { if (title) {
await setChatTitle(_chatId, title); await setChatTitle(_chatId, title);
...@@ -657,6 +781,34 @@ ...@@ -657,6 +781,34 @@
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
}; };
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
const deleteTag = async (tagName) => {
const res = await deleteTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
onMount(async () => { onMount(async () => {
if (!($settings.saveChatHistory ?? true)) { if (!($settings.saveChatHistory ?? true)) {
await goto('/'); await goto('/');
...@@ -664,67 +816,69 @@ ...@@ -664,67 +816,69 @@
}); });
</script> </script>
<svelte:window
on:scroll={(e) => {
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
}}
/>
{#if loaded} {#if loaded}
<Navbar <div class="min-h-screen max-h-screen w-full flex flex-col">
{title} <Navbar
shareEnabled={messages.length > 0} {title}
initNewChat={() => { shareEnabled={messages.length > 0}
goto('/'); initNewChat={async () => {
}} if (currentRequestId !== null) {
/> await cancelChatCompletion(localStorage.token, currentRequestId);
<div class="min-h-screen w-full flex justify-center"> currentRequestId = null;
<div class=" py-2.5 flex flex-col justify-between w-full"> }
<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
<ModelSelector bind:selectedModels disabled={messages.length > 0} />
</div>
<div class=" h-full mt-10 mb-32 w-full flex flex-col"> goto('/');
<Messages }}
chatId={$chatId} {tags}
{selectedModels} {addTag}
{selectedModelfiles} {deleteTag}
{processing} />
bind:history <div class="flex flex-col flex-auto">
bind:messages <div
bind:autoScroll class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
bottomPadding={files.length > 0} id="messages-container"
{sendPrompt} on:scroll={(e) => {
{regenerateResponse} autoScroll = e.target.scrollHeight - e.target.scrollTop <= e.target.clientHeight + 50;
/> }}
>
<div
class="{$settings?.fullScreenMode ?? null
? 'max-w-full'
: 'max-w-2xl md:px-0'} mx-auto w-full px-4"
>
<ModelSelector
bind:selectedModels
disabled={messages.length > 0 && !selectedModels.includes('')}
/>
</div>
<div class=" h-full w-full flex flex-col py-8">
<Messages
chatId={$chatId}
{selectedModels}
{selectedModelfiles}
{processing}
bind:history
bind:messages
bind:autoScroll
bottomPadding={files.length > 0}
{sendPrompt}
{continueGeneration}
{regenerateResponse}
/>
</div>
</div> </div>
</div>
<MessageInput <MessageInput
bind:files bind:files
bind:prompt bind:prompt
bind:autoScroll bind:autoScroll
suggestionPrompts={selectedModelfile?.suggestionPrompts ?? [ suggestionPrompts={selectedModelfile?.suggestionPrompts ??
{ $config.default_prompt_suggestions}
title: ['Help me study', 'vocabulary for a college entrance exam'], {messages}
content: `Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.` {submitPrompt}
}, {stopResponse}
{ />
title: ['Give me ideas', `for what to do with my kids' art`], </div>
content: `What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.`
},
{
title: ['Tell me a fun fact', 'about the Roman Empire'],
content: 'Tell me a random fun fact about the Roman Empire'
},
{
title: ['Show me a code snippet', `of a website's sticky header`],
content: `Show me a code snippet of a website's sticky header in CSS and JavaScript.`
}
]}
{messages}
{submitPrompt}
{stopResponse}
/>
</div> </div>
{/if} {/if}
<script lang="ts">
import toast from 'svelte-french-toast';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount } from 'svelte';
import { documents } from '$lib/stores';
import { createNewDoc, deleteDocByName, getDocs } from '$lib/apis/documents';
import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS } from '$lib/constants';
import { uploadDocToVectorDB } from '$lib/apis/rag';
import { transformFileName } from '$lib/utils';
import EditDocModal from '$lib/components/documents/EditDocModal.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import SettingsModal from '$lib/components/documents/SettingsModal.svelte';
let importFiles = '';
let inputFiles = '';
let query = '';
let tags = [];
let showSettingsModal = false;
let showEditDocModal = false;
let selectedDoc;
let selectedTag = '';
let dragged = false;
const deleteDoc = async (name) => {
await deleteDocByName(localStorage.token, name);
await documents.set(await getDocs(localStorage.token));
};
const uploadDoc = async (file) => {
const res = await uploadDocToVectorDB(localStorage.token, '', file).catch((error) => {
toast.error(error);
return null;
});
if (res) {
await createNewDoc(
localStorage.token,
res.collection_name,
res.filename,
transformFileName(res.filename),
res.filename
).catch((error) => {
toast.error(error);
return null;
});
await documents.set(await getDocs(localStorage.token));
}
};
onMount(() => {
documents.subscribe((docs) => {
tags = docs.reduce((a, e, i, arr) => {
return [...new Set([...a, ...(e?.content?.tags ?? []).map((tag) => tag.name)])];
}, []);
});
const dropZone = document.querySelector('body');
const onDragOver = (e) => {
e.preventDefault();
dragged = true;
};
const onDragLeave = () => {
dragged = false;
};
const onDrop = async (e) => {
e.preventDefault();
console.log(e);
if (e.dataTransfer?.files) {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
};
const inputFiles = e.dataTransfer?.files;
if (inputFiles && inputFiles.length > 0) {
for (const file of inputFiles) {
console.log(file, file.name.split('.').at(-1));
if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
} else {
toast.error(
`Unknown File Type '${file['type']}', but accepting and treating as plain text`
);
uploadDoc(file);
}
}
} else {
toast.error(`File not found.`);
}
}
dragged = false;
};
dropZone?.addEventListener('dragover', onDragOver);
dropZone?.addEventListener('drop', onDrop);
dropZone?.addEventListener('dragleave', onDragLeave);
return () => {
dropZone?.removeEventListener('dragover', onDragOver);
dropZone?.removeEventListener('drop', onDrop);
dropZone?.removeEventListener('dragleave', onDragLeave);
};
});
</script>
{#if dragged}
<div
class="fixed w-full h-full flex z-50 touch-none pointer-events-none"
id="dropzone"
role="region"
aria-label="Drag and Drop Container"
>
<div class="absolute rounded-xl w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
<div class="m-auto pt-64 flex flex-col justify-center">
<div class="max-w-md">
<AddFilesPlaceholder>
<div class=" mt-2 text-center text-sm dark:text-gray-200 w-full">
Drop any files here to add to my documents
</div>
</AddFilesPlaceholder>
</div>
</div>
</div>
</div>
{/if}
{#key selectedDoc}
<EditDocModal bind:show={showEditDocModal} {selectedDoc} />
{/key}
<input
id="upload-doc-input"
bind:files={inputFiles}
type="file"
multiple
hidden
on:change={async (e) => {
if (inputFiles && inputFiles.length > 0) {
for (const file of inputFiles) {
console.log(file, file.name.split('.').at(-1));
if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
} else {
toast.error(
`Unknown File Type '${file['type']}', but accepting and treating as plain text`
);
uploadDoc(file);
}
}
inputFiles = null;
e.target.value = '';
} else {
toast.error(`File not found.`);
}
}}
/>
<SettingsModal bind:show={showSettingsModal} />
<div class="min-h-screen max-h-[100dvh] w-full flex justify-center dark:text-white">
<div class=" flex flex-col justify-between w-full overflow-y-auto">
<div class="max-w-2xl mx-auto w-full px-3 md:px-0 my-10">
<div class="mb-6">
<div class="flex justify-between items-center">
<div class=" text-2xl font-semibold self-center">My Documents</div>
<div>
<button
class="flex items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition"
type="button"
on:click={() => {
showSettingsModal = !showSettingsModal;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
<div class=" text-xs">Document Settings</div>
</button>
</div>
</div>
<div class=" text-gray-500 text-xs mt-1">
ⓘ Use '#' in the prompt input to load and select your documents.
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder="Search Document"
/>
</div>
<div>
<button
class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
document.getElementById('upload-doc-input')?.click();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
</div>
</div>
<!-- <div>
<div
class="my-3 py-16 rounded-lg border-2 border-dashed dark:border-gray-600 {dragged &&
' dark:bg-gray-700'} "
role="region"
on:drop={onDrop}
on:dragover={onDragOver}
on:dragleave={onDragLeave}
>
<div class=" pointer-events-none">
<div class="text-center dark:text-white text-2xl font-semibold z-50">Add Files</div>
<div class=" mt-2 text-center text-sm dark:text-gray-200 w-full">
Drop any files here to add to my documents
</div>
</div>
</div>
</div> -->
<hr class=" dark:border-gray-700 my-2.5" />
{#if tags.length > 0}
<div class="px-2.5 pt-1 flex gap-1 flex-wrap">
<button
class="px-2 py-0.5 space-x-1 flex h-fit items-center rounded-full transition bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:text-white"
on:click={async () => {
selectedTag = '';
// await chats.set(await getChatListByTagName(localStorage.token, tag.name));
}}
>
<div class=" text-xs font-medium self-center line-clamp-1">all</div>
</button>
{#each tags as tag}
<button
class="px-2 py-0.5 space-x-1 flex h-fit items-center rounded-full transition bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:text-white"
on:click={async () => {
selectedTag = tag;
// await chats.set(await getChatListByTagName(localStorage.token, tag.name));
}}
>
<div class=" text-xs font-medium self-center line-clamp-1">
#{tag}
</div>
</button>
{/each}
</div>
{/if}
<div class="my-3 mb-5">
{#each $documents.filter((doc) => (selectedTag === '' || (doc?.content?.tags ?? [])
.map((tag) => tag.name)
.includes(selectedTag)) && (query === '' || doc.name.includes(query))) as doc}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<div class=" flex items-center space-x-3">
<div class="p-2.5 bg-red-400 text-white rounded-lg">
{#if doc}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6 h-6"
>
<path
fill-rule="evenodd"
d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
clip-rule="evenodd"
/>
<path
d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
/>
</svg>
{:else}
<svg
class=" w-6 h-6 translate-y-[0.5px]"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
r="2.5"
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="2.5" /></svg
>
{/if}
</div>
<div class=" self-center flex-1">
<div class=" font-bold line-clamp-1">#{doc.name} ({doc.filename})</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{doc.title}
</div>
</div>
</div>
</div>
<div class="flex flex-row space-x-1 self-center">
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={async () => {
showEditDocModal = !showEditDocModal;
selectedDoc = doc;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
<!-- <button
class="self-center w-fit text-sm px-2 py-2 border dark:border-gray-600 rounded-xl"
type="button"
on:click={() => {
console.log('download file');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
</button> -->
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteDoc(doc.name);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
<div class=" flex justify-end w-full mb-2">
<div class="flex space-x-2">
<input
id="documents-import-input"
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
const savedDocs = JSON.parse(event.target.result);
console.log(savedDocs);
for (const doc of savedDocs) {
await createNewDoc(
localStorage.token,
doc.collection_name,
doc.filename,
doc.name,
doc.title
).catch((error) => {
toast.error(error);
return null;
});
}
await documents.set(await getDocs(localStorage.token));
};
reader.readAsText(importFiles[0]);
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
document.getElementById('documents-import-input')?.click();
}}
>
<div class=" self-center mr-2 font-medium">Import Documents Mapping</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
let blob = new Blob([JSON.stringify($documents)], {
type: 'application/json'
});
saveAs(blob, `documents-mapping-export-${Date.now()}.json`);
}}
>
<div class=" self-center mr-2 font-medium">Export Documents Mapping</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment