Unverified Commit 38ff3209 authored by Timothy Jaeryang Baek's avatar Timothy Jaeryang Baek Committed by GitHub
Browse files

Merge pull request #1881 from open-webui/dev

0.1.123
parents c9589e21 2789102d
Name,Email,Password,Role
...@@ -21,14 +21,14 @@ describe('Settings', () => { ...@@ -21,14 +21,14 @@ describe('Settings', () => {
// Click on the model selector // Click on the model selector
cy.get('button[aria-label="Select a model"]').click(); cy.get('button[aria-label="Select a model"]').click();
// Select the first model // Select the first model
cy.get('div[role="option"][data-value]').first().click(); cy.get('div[role="menuitem"]').first().click();
}); });
it('user can perform text chat', () => { it('user can perform text chat', () => {
// Click on the model selector // Click on the model selector
cy.get('button[aria-label="Select a model"]').click(); cy.get('button[aria-label="Select a model"]').click();
// Select the first model // Select the first model
cy.get('div[role="option"][data-value]').first().click(); cy.get('div[role="menuitem"]').first().click();
// Type a message // Type a message
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', { cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
force: true force: true
......
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.1.122", "version": "0.1.123",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.1.122", "version": "0.1.123",
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-node": "^1.3.1",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.19.7", "bits-ui": "^0.19.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"i18next": "^23.10.0", "i18next": "^23.10.0",
...@@ -3167,6 +3168,14 @@ ...@@ -3167,6 +3168,14 @@
"integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
"dev": true "dev": true
}, },
"node_modules/eventsource-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
"integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
"engines": {
"node": ">=14.18"
}
},
"node_modules/execa": { "node_modules/execa": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
......
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.1.122", "version": "0.1.123",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
...@@ -49,6 +49,7 @@ ...@@ -49,6 +49,7 @@
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.19.7", "bits-ui": "^0.19.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"i18next": "^23.10.0", "i18next": "^23.10.0",
......
...@@ -95,6 +95,45 @@ export const userSignUp = async ( ...@@ -95,6 +95,45 @@ export const userSignUp = async (
return res; return res;
}; };
export const addUser = async (
token: string,
name: string,
email: string,
password: string,
role: string = 'pending'
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
name: name,
email: email,
password: password,
role: role
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => { export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => {
let error = null; let error = null;
......
...@@ -221,6 +221,37 @@ export const uploadWebToVectorDB = async (token: string, collection_name: string ...@@ -221,6 +221,37 @@ export const uploadWebToVectorDB = async (token: string, collection_name: string
return res; return res;
}; };
export const uploadYoutubeTranscriptionToVectorDB = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/youtube`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const queryDoc = async ( export const queryDoc = async (
token: string, token: string,
collection_name: string, collection_name: string,
......
import { EventSourceParserStream } from 'eventsource-parser/stream';
import type { ParsedEvent } from 'eventsource-parser';
type TextStreamUpdate = { type TextStreamUpdate = {
done: boolean; done: boolean;
value: string; value: string;
}; };
// createOpenAITextStream takes a ReadableStreamDefaultReader from an SSE response, // createOpenAITextStream takes a responseBody with a SSE response,
// and returns an async generator that emits delta updates with large deltas chunked into random sized chunks // and returns an async generator that emits delta updates with large deltas chunked into random sized chunks
export async function createOpenAITextStream( export async function createOpenAITextStream(
messageStream: ReadableStreamDefaultReader, responseBody: ReadableStream<Uint8Array>,
splitLargeDeltas: boolean splitLargeDeltas: boolean
): Promise<AsyncGenerator<TextStreamUpdate>> { ): Promise<AsyncGenerator<TextStreamUpdate>> {
let iterator = openAIStreamToIterator(messageStream); const eventStream = responseBody
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
.getReader();
let iterator = openAIStreamToIterator(eventStream);
if (splitLargeDeltas) { if (splitLargeDeltas) {
iterator = streamLargeDeltasAsRandomChunks(iterator); iterator = streamLargeDeltasAsRandomChunks(iterator);
} }
...@@ -17,7 +24,7 @@ export async function createOpenAITextStream( ...@@ -17,7 +24,7 @@ export async function createOpenAITextStream(
} }
async function* openAIStreamToIterator( async function* openAIStreamToIterator(
reader: ReadableStreamDefaultReader reader: ReadableStreamDefaultReader<ParsedEvent>
): AsyncGenerator<TextStreamUpdate> { ): AsyncGenerator<TextStreamUpdate> {
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
...@@ -25,31 +32,22 @@ async function* openAIStreamToIterator( ...@@ -25,31 +32,22 @@ async function* openAIStreamToIterator(
yield { done: true, value: '' }; yield { done: true, value: '' };
break; break;
} }
const lines = value.split('\n'); if (!value) {
for (let line of lines) { continue;
if (line.endsWith('\r')) { }
// Remove trailing \r const data = value.data;
line = line.slice(0, -1); if (data.startsWith('[DONE]')) {
} yield { done: true, value: '' };
if (line !== '') { break;
console.log(line); }
if (line === 'data: [DONE]') {
yield { done: true, value: '' };
} else if (line.startsWith(':')) {
// Events starting with : are comments https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
// OpenRouter sends heartbeats like ": OPENROUTER PROCESSING"
continue;
} else {
try {
const data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
yield { done: false, value: data.choices?.[0]?.delta?.content ?? '' }; try {
} catch (e) { const parsedData = JSON.parse(data);
console.error('Error extracting delta from SSE event:', e); console.log(parsedData);
}
} yield { done: false, value: parsedData.choices?.[0]?.delta?.content ?? '' };
} } catch (e) {
console.error('Error extracting delta from SSE event:', e);
} }
} }
} }
...@@ -73,7 +71,11 @@ async function* streamLargeDeltasAsRandomChunks( ...@@ -73,7 +71,11 @@ async function* streamLargeDeltasAsRandomChunks(
const chunkSize = Math.min(Math.floor(Math.random() * 3) + 1, content.length); const chunkSize = Math.min(Math.floor(Math.random() * 3) + 1, content.length);
const chunk = content.slice(0, chunkSize); const chunk = content.slice(0, chunkSize);
yield { done: false, value: chunk }; yield { done: false, value: chunk };
await sleep(5); // Do not sleep if the tab is hidden
// Timers are throttled to 1s in hidden tabs
if (document?.visibilityState !== 'hidden') {
await sleep(5);
}
content = content.slice(chunkSize); content = content.slice(chunkSize);
} }
} }
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
</script> </script>
<Modal bind:show> <Modal bind:show>
<div class="px-5 py-4 dark:text-gray-300"> <div class="px-5 py-4 dark:text-gray-300 text-gray-700">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div class="text-xl font-bold"> <div class="text-xl font-bold">
{$i18n.t('What’s New in')} {$i18n.t('What’s New in')}
...@@ -59,7 +59,7 @@ ...@@ -59,7 +59,7 @@
<hr class=" dark:border-gray-800" /> <hr class=" dark:border-gray-800" />
<div class=" w-full p-4 px-5"> <div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-80"> <div class=" overflow-y-scroll max-h-80">
<div class="mb-3"> <div class="mb-3">
{#if changelog} {#if changelog}
......
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher } from 'svelte';
import { onMount, getContext } from 'svelte';
import { addUser } from '$lib/apis/auths';
import Modal from '../common/Modal.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
let loading = false;
let tab = '';
let inputFiles;
let _user = {
name: '',
email: '',
password: '',
role: 'user'
};
$: if (show) {
_user = {
name: '',
email: '',
password: '',
role: 'user'
};
}
const submitHandler = async () => {
const stopLoading = () => {
dispatch('save');
loading = false;
};
if (tab === '') {
loading = true;
const res = await addUser(
localStorage.token,
_user.name,
_user.email,
_user.password,
_user.role
).catch((error) => {
toast.error(error);
});
if (res) {
stopLoading();
show = false;
}
} else {
if (inputFiles) {
loading = true;
const file = inputFiles[0];
const reader = new FileReader();
reader.onload = async (e) => {
const csv = e.target.result;
const rows = csv.split('\n');
let userCount = 0;
for (const [idx, row] of rows.entries()) {
const columns = row.split(',').map((col) => col.trim());
console.log(idx, columns);
if (idx > 0) {
if (columns.length === 4 && ['admin', 'user', 'pending'].includes(columns[3])) {
const res = await addUser(
localStorage.token,
columns[0],
columns[1],
columns[2],
columns[3]
).catch((error) => {
toast.error(`Row ${idx + 1}: ${error}`);
return null;
});
if (res) {
userCount = userCount + 1;
}
} else {
toast.error(`Row ${idx + 1}: invalid format.`);
}
}
}
toast.success(`Successfully imported ${userCount} users.`);
inputFiles = null;
const uploadInputElement = document.getElementById('upload-user-csv-input');
if (uploadInputElement) {
uploadInputElement.value = null;
}
stopLoading();
};
reader.readAsText(file);
} else {
toast.error(`File not found.`);
}
}
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Add User')}</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>
<div class="flex flex-col md:flex-row w-full px-5 pb-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 text-center text-sm font-medium rounded-xl bg-transparent/10 p-1 mb-2">
<button
class="w-full rounded-lg p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
type="button"
on:click={() => {
tab = '';
}}>Form</button
>
<button
class="w-full rounded-lg p-1 {tab === 'import' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
type="button"
on:click={() => {
tab = 'import';
}}>CSV Import</button
>
</div>
<div class="px-1">
{#if tab === ''}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
<div class="flex-1">
<select
class="w-full capitalize rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
bind:value={_user.role}
placeholder={$i18n.t('Enter Your Role')}
required
>
<option value="pending"> pending </option>
<option value="user"> user </option>
<option value="admin"> admin </option>
</select>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Full Name')}
autocomplete="off"
required
/>
</div>
</div>
<hr class=" dark:border-gray-800 my-3 w-full" />
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Password')}</div>
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="password"
bind:value={_user.password}
placeholder={$i18n.t('Enter Your Password')}
autocomplete="off"
/>
</div>
</div>
{:else if tab === 'import'}
<div>
<div class="mb-3 w-full">
<input
id="upload-user-csv-input"
hidden
bind:files={inputFiles}
type="file"
accept=".csv"
/>
<button
class="w-full text-sm font-medium py-3 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-800 dark:hover:bg-gray-850 text-center rounded-xl"
type="button"
on:click={() => {
document.getElementById('upload-user-csv-input')?.click();
}}
>
{#if inputFiles}
{inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected.
{:else}
{$i18n.t('Click here to select a csv file.')}
{/if}
</button>
</div>
<div class=" text-xs text-gray-500">
ⓘ {$i18n.t(
'Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.'
)}
<a
class="underline dark:text-gray-200"
href="{WEBUI_BASE_URL}/static/user-import.csv"
>
Click here to download user import template file.
</a>
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Submit')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
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>
</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>
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
<Modal bind:show> <Modal bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4"> <div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Admin Settings')}</div> <div class=" text-lg font-medium self-center">{$i18n.t('Admin Settings')}</div>
<button <button
class="self-center" class="self-center"
...@@ -35,7 +35,6 @@ ...@@ -35,7 +35,6 @@
</svg> </svg>
</button> </button>
</div> </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="flex flex-col md:flex-row w-full p-4 md:space-x-4">
<div <div
......
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
> >
<tr> <tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th> <th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2 hidden md:flex"> {$i18n.t('Created At')} </th> <th scope="col" class="px-3 py-2 hidden md:flex"> {$i18n.t('Created at')} </th>
<th scope="col" class="px-3 py-2 text-right" /> <th scope="col" class="px-3 py-2 text-right" />
</tr> </tr>
</thead> </thead>
...@@ -96,7 +96,7 @@ ...@@ -96,7 +96,7 @@
<td class="px-3 py-1 text-right"> <td class="px-3 py-1 text-right">
<div class="flex justify-end w-full"> <div class="flex justify-end w-full">
<Tooltip content="Delete Chat"> <Tooltip content={$i18n.t('Delete Chat')}>
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" 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 () => { on:click={async () => {
...@@ -133,7 +133,10 @@ ...@@ -133,7 +133,10 @@
{/each} --> {/each} -->
</div> </div>
{:else} {:else}
<div class="text-left text-sm w-full mb-8">{user.name} has no conversations.</div> <div class="text-left text-sm w-full mb-8">
{user.name}
{$i18n.t('has no conversations.')}
</div>
{/if} {/if}
</div> </div>
</div> </div>
......
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { settings } from '$lib/stores'; import { modelfiles, settings, showSidebar } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
import {
uploadDocToVectorDB,
uploadWebToVectorDB,
uploadYoutubeTranscriptionToVectorDB
} from '$lib/apis/rag';
import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS, WEBUI_BASE_URL } from '$lib/constants';
import { transcribeAudio } from '$lib/apis/audio';
import Prompts from './MessageInput/PromptCommands.svelte'; import Prompts from './MessageInput/PromptCommands.svelte';
import Suggestions from './MessageInput/Suggestions.svelte'; import Suggestions from './MessageInput/Suggestions.svelte';
import { uploadDocToVectorDB, uploadWebToVectorDB } from '$lib/apis/rag';
import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte'; import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS } from '$lib/constants';
import Documents from './MessageInput/Documents.svelte'; import Documents from './MessageInput/Documents.svelte';
import Models from './MessageInput/Models.svelte'; import Models from './MessageInput/Models.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import Page from '../../../routes/(app)/+page.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let submitPrompt: Function; export let submitPrompt: Function;
export let stopResponse: Function; export let stopResponse: Function;
export let suggestionPrompts = [];
export let autoScroll = true; export let autoScroll = true;
export let selectedModel = '';
let chatTextAreaElement: HTMLTextAreaElement; let chatTextAreaElement: HTMLTextAreaElement;
let filesInputElement; let filesInputElement;
...@@ -291,7 +298,36 @@ ...@@ -291,7 +298,36 @@
} }
}; };
const uploadYoutubeTranscription = async (url) => {
console.log(url);
const doc = {
type: 'doc',
name: url,
collection_name: '',
upload_status: false,
url: url,
error: ''
};
try {
files = [...files, doc];
const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
if (res) {
doc.upload_status = true;
doc.collection_name = res.collection_name;
files = files;
}
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
}
};
onMount(() => { onMount(() => {
console.log(document.getElementById('sidebar'));
window.setTimeout(() => chatTextAreaElement?.focus(), 0); window.setTimeout(() => chatTextAreaElement?.focus(), 0);
const dropZone = document.querySelector('body'); const dropZone = document.querySelector('body');
...@@ -390,142 +426,252 @@ ...@@ -390,142 +426,252 @@
</div> </div>
{/if} {/if}
<div class="w-full"> <div class="fixed bottom-0 {$showSidebar ? 'left-0 lg:left-[260px]' : 'left-0'} right-0">
<div class="px-2.5 -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center"> <div class="w-full">
<div class="flex flex-col max-w-3xl w-full"> <div class="px-2.5 lg:px-16 -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="relative"> <div class="flex flex-col max-w-5xl w-full">
{#if autoScroll === false && messages.length > 0} <div class="relative">
<div class=" absolute -top-12 left-0 right-0 flex justify-center"> {#if autoScroll === false && messages.length > 0}
<button <div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full" <button
on:click={() => { class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
autoScroll = true; on:click={() => {
scrollToBottom(); autoScroll = true;
}} scrollToBottom();
> }}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z" viewBox="0 0 20 20"
clip-rule="evenodd" fill="currentColor"
/> class="w-5 h-5"
</svg> >
</button> <path
</div> fill-rule="evenodd"
{/if} d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
</div> clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="w-full relative">
{#if prompt.charAt(0) === '/'}
<Prompts bind:this={promptsElement} bind:prompt />
{:else if prompt.charAt(0) === '#'}
<Documents
bind:this={documentsElement}
bind:prompt
on:youtube={(e) => {
console.log(e);
uploadYoutubeTranscription(e.detail);
}}
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
type: e?.detail?.type ?? 'doc',
...e.detail,
upload_status: true
}
];
}}
/>
{/if}
<div class="w-full relative">
{#if prompt.charAt(0) === '/'}
<Prompts bind:this={promptsElement} bind:prompt />
{:else if prompt.charAt(0) === '#'}
<Documents
bind:this={documentsElement}
bind:prompt
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
type: e?.detail?.type ?? 'doc',
...e.detail,
upload_status: true
}
];
}}
/>
{:else if prompt.charAt(0) === '@'}
<Models <Models
bind:this={modelsElement} bind:this={modelsElement}
bind:prompt bind:prompt
bind:user bind:user
bind:chatInputPlaceholder bind:chatInputPlaceholder
{messages} {messages}
on:select={(e) => {
selectedModel = e.detail;
chatTextAreaElement?.focus();
}}
/> />
{/if}
{#if messages.length == 0 && suggestionPrompts.length !== 0} {#if selectedModel !== ''}
<Suggestions {suggestionPrompts} {submitPrompt} /> <div
{/if} class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900"
>
<div class="flex items-center gap-2 text-sm dark:text-gray-500">
<img
alt="model profile"
class="size-5 max-w-[28px] object-cover rounded-full"
src={$modelfiles.find((modelfile) => modelfile.tagName === selectedModel.id)
?.imageUrl ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
/>
<div>
Talking to <span class=" font-medium">{selectedModel.name} </span>
</div>
</div>
<div>
<button
class="flex items-center"
on:click={() => {
selectedModel = '';
}}
>
<XMark />
</button>
</div>
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div>
<div class="bg-white dark:bg-gray-900"> <div class="bg-white dark:bg-gray-900">
<div class="max-w-3xl px-2.5 mx-auto inset-x-0"> <div class="max-w-6xl px-2.5 lg:px-16 mx-auto inset-x-0">
<div class=" pb-2"> <div class=" pb-2">
<input <input
bind:this={filesInputElement} bind:this={filesInputElement}
bind:files={inputFiles} bind:files={inputFiles}
type="file" type="file"
hidden hidden
multiple multiple
on:change={async () => { on:change={async () => {
if (inputFiles && inputFiles.length > 0) { if (inputFiles && inputFiles.length > 0) {
const _inputFiles = Array.from(inputFiles); const _inputFiles = Array.from(inputFiles);
_inputFiles.forEach((file) => { _inputFiles.forEach((file) => {
if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) {
let reader = new FileReader(); let reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
files = [ files = [
...files, ...files,
{ {
type: 'image', type: 'image',
url: `${event.target.result}` url: `${event.target.result}`
} }
]; ];
inputFiles = null; inputFiles = null;
filesInputElement.value = '';
};
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
filesInputElement.value = ''; filesInputElement.value = '';
}; } else {
reader.readAsDataURL(file); toast.error(
} else if ( $i18n.t(
SUPPORTED_FILE_TYPE.includes(file['type']) || `Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) { file_type: file['type'] }
) { )
uploadDoc(file); );
filesInputElement.value = ''; uploadDoc(file);
} else { filesInputElement.value = '';
toast.error( }
$i18n.t( });
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`, } else {
{ file_type: file['type'] } toast.error($i18n.t(`File not found.`));
) }
); }}
uploadDoc(file); />
filesInputElement.value = ''; <form
} class=" flex flex-col relative w-full rounded-3xl px-1.5 border border-gray-100 dark:border-gray-850 bg-white dark:bg-gray-900 dark:text-gray-100"
}); on:submit|preventDefault={() => {
} else { submitPrompt(prompt, user);
toast.error($i18n.t(`File not found.`)); }}
} >
}} {#if files.length > 0}
/> <div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2">
<form {#each files as file, fileIdx}
class=" flex flex-col relative w-full rounded-3xl px-1.5 border border-gray-100 dark:border-gray-850 bg-white dark:bg-gray-900 dark:text-gray-100" <div class=" relative group">
on:submit|preventDefault={() => { {#if file.type === 'image'}
submitPrompt(prompt, user); <img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" />
}} {:else if file.type === 'doc'}
> <div
{#if files.length > 0} class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
<div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2"> >
{#each files as file, fileIdx} <div class="p-2.5 bg-red-400 text-white rounded-lg">
<div class=" relative group"> {#if file.upload_status}
{#if file.type === 'image'} <svg
<img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" /> xmlns="http://www.w3.org/2000/svg"
{:else if file.type === 'doc'} viewBox="0 0 24 24"
<div fill="currentColor"
class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none" class="w-6 h-6"
> >
<div class="p-2.5 bg-red-400 text-white rounded-lg"> <path
{#if file.upload_status} 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="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file.name}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
</div>
</div>
{:else if file.type === 'collection'}
<div
class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
>
<div class="p-2.5 bg-red-400 text-white rounded-lg">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -533,423 +679,376 @@ ...@@ -533,423 +679,376 @@
class="w-6 h-6" class="w-6 h-6"
> >
<path <path
fill-rule="evenodd" d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z"
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 <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" d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
/> />
</svg> </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="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file.name}
</div> </div>
<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div> <div class="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file?.title ?? `#${file.name}`}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
</div>
</div> </div>
</div> {/if}
{:else if file.type === 'collection'}
<div <div class=" absolute -top-1 -right-1">
class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none" <button
> class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
<div class="p-2.5 bg-red-400 text-white rounded-lg"> type="button"
on:click={() => {
files.splice(fileIdx, 1);
files = files;
}}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
class="w-6 h-6" class="w-4 h-4"
> >
<path <path
d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z" 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"
/>
<path
d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
/> />
</svg> </svg>
</div> </button>
<div class="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file?.title ?? `#${file.name}`}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
</div>
</div> </div>
{/if} </div>
{/each}
</div>
{/if}
<div class=" absolute -top-1 -right-1"> <div class=" flex">
{#if fileUploadEnabled}
<div class=" self-end mb-2 ml-1">
<Tooltip content={$i18n.t('Upload files')}>
<button <button
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition" class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
type="button" type="button"
on:click={() => { on:click={() => {
files.splice(fileIdx, 1); filesInputElement.click();
files = files;
}} }}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 16 16"
fill="currentColor" fill="currentColor"
class="w-4 h-4" class="w-[1.2rem] h-[1.2rem]"
> >
<path <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" 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> </svg>
</button> </button>
</div> </Tooltip>
</div> </div>
{/each} {/if}
</div>
{/if}
<div class=" flex">
{#if fileUploadEnabled}
<div class=" self-end mb-2 ml-1">
<Tooltip content={$i18n.t('Upload files')}>
<button
class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
type="button"
on:click={() => {
filesInputElement.click();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-[1.2rem] h-[1.2rem]"
>
<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>
</Tooltip>
</div>
{/if}
<textarea <textarea
id="chat-textarea" id="chat-textarea"
bind:this={chatTextAreaElement} bind:this={chatTextAreaElement}
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled class="scrollbar-none dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
? '' ? ''
: ' pl-4'} rounded-xl resize-none h-[48px]" : ' pl-4'} rounded-xl resize-none h-[48px]"
placeholder={chatInputPlaceholder !== '' placeholder={chatInputPlaceholder !== ''
? chatInputPlaceholder ? chatInputPlaceholder
: isRecording : isRecording
? $i18n.t('Listening...') ? $i18n.t('Listening...')
: $i18n.t('Send a Message')} : $i18n.t('Send a Message')}
bind:value={prompt} bind:value={prompt}
on:keypress={(e) => { on:keypress={(e) => {
if (window.innerWidth > 1024) { if (
if (e.keyCode == 13 && !e.shiftKey) { !(
e.preventDefault(); 'ontouchstart' in window ||
} navigator.maxTouchPoints > 0 ||
if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) { navigator.msMaxTouchPoints > 0
submitPrompt(prompt, user); )
) {
if (e.keyCode == 13 && !e.shiftKey) {
e.preventDefault();
}
if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) {
submitPrompt(prompt, user);
}
} }
} }}
}} on:keydown={async (e) => {
on:keydown={async (e) => { const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
// Check if Ctrl + R is pressed // Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault(); e.preventDefault();
console.log('regenerate'); console.log('regenerate');
const regenerateButton = [ const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button') ...document.getElementsByClassName('regenerate-response-button')
]?.at(-1); ]?.at(-1);
regenerateButton?.click(); regenerateButton?.click();
} }
if (prompt === '' && e.key == 'ArrowUp') { if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault(); e.preventDefault();
const userMessageElement = [ const userMessageElement = [
...document.getElementsByClassName('user-message') ...document.getElementsByClassName('user-message')
]?.at(-1); ]?.at(-1);
const editButton = [ const editButton = [
...document.getElementsByClassName('edit-user-message-button') ...document.getElementsByClassName('edit-user-message-button')
]?.at(-1); ]?.at(-1);
console.log(userMessageElement); console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' }); userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click(); editButton?.click();
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
(promptsElement || documentsElement || modelsElement).selectUp(); (promptsElement || documentsElement || modelsElement).selectUp();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' }); commandOptionButton.scrollIntoView({ block: 'center' });
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
(promptsElement || documentsElement || modelsElement).selectDown(); (promptsElement || documentsElement || modelsElement).selectDown();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' }); commandOptionButton.scrollIntoView({ block: 'center' });
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
if (commandOptionButton) { if (commandOptionButton) {
commandOptionButton?.click(); commandOptionButton?.click();
} else { } else {
document.getElementById('send-message-button')?.click(); document.getElementById('send-message-button')?.click();
}
} }
}
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
commandOptionButton?.click(); commandOptionButton?.click();
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
const words = findWordIndices(prompt); const words = findWordIndices(prompt);
if (words.length > 0) { if (words.length > 0) {
const word = words.at(0); const word = words.at(0);
const fullPrompt = prompt; const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1); prompt = prompt.substring(0, word?.endIndex + 1);
await tick(); await tick();
e.target.scrollTop = e.target.scrollHeight; e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt; prompt = fullPrompt;
await tick(); await tick();
e.preventDefault(); e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1); e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
}
}}
rows="1"
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
on:paste={(e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
} }
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
} }
}
}}
/>
<div class="self-end mb-2 flex space-x-1 mr-1"> if (e.key === 'Escape') {
{#if messages.length == 0 || messages.at(-1).done == true} console.log('Escape');
<Tooltip content={$i18n.t('Record voice')}> selectedModel = '';
{#if speechRecognitionEnabled} }
<button }}
id="voice-input-button" rows="1"
class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center" on:input={(e) => {
type="button" e.target.style.height = '';
on:click={() => { e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
speechRecognitionHandler(); user = null;
}} }}
> on:focus={(e) => {
{#if isRecording} e.target.style.height = '';
<svg e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
class=" w-5 h-5 translate-y-[0.5px]" }}
fill="currentColor" on:paste={(e) => {
viewBox="0 0 24 24" const clipboardData = e.clipboardData || window.clipboardData;
xmlns="http://www.w3.org/2000/svg"
><style> if (clipboardData && clipboardData.items) {
.spinner_qM83 { for (const item of clipboardData.items) {
animation: spinner_8HQG 1.05s infinite; if (item.type.indexOf('image') !== -1) {
} const blob = item.getAsFile();
.spinner_oXPr { const reader = new FileReader();
animation-delay: 0.1s;
} reader.onload = function (e) {
.spinner_ZTLf { files = [
animation-delay: 0.2s; ...files,
{
type: 'image',
url: `${e.target.result}`
} }
@keyframes spinner_8HQG { ];
0%, };
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); reader.readAsDataURL(blob);
transform: translate(0); }
}
}
}}
/>
<div class="self-end mb-2 flex space-x-1 mr-1">
{#if messages.length == 0 || messages.at(-1).done == true}
<Tooltip content={$i18n.t('Record voice')}>
{#if speechRecognitionEnabled}
<button
id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
type="button"
on:click={() => {
speechRecognitionHandler();
}}
>
{#if isRecording}
<svg
class=" w-5 h-5 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;
} }
28.57% { .spinner_oXPr {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); animation-delay: 0.1s;
transform: translateY(-6px);
} }
100% { .spinner_ZTLf {
transform: translate(0); animation-delay: 0.2s;
} }
} @keyframes spinner_8HQG {
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle 0%,
class="spinner_qM83 spinner_oXPr" 57.14% {
cx="12" animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
cy="12" transform: translate(0);
r="2.5" }
/><circle 28.57% {
class="spinner_qM83 spinner_ZTLf" animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
cx="20" transform: translateY(-6px);
cy="12" }
r="2.5" 100% {
/></svg transform: translate(0);
> }
{:else} }
<svg </style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
xmlns="http://www.w3.org/2000/svg" class="spinner_qM83 spinner_oXPr"
viewBox="0 0 20 20" cx="12"
fill="currentColor" cy="12"
class="w-5 h-5 translate-y-[0.5px]" r="2.5"
> /><circle
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" /> class="spinner_qM83 spinner_ZTLf"
<path cx="20"
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z" cy="12"
/> r="2.5"
</svg> /></svg
{/if} >
</button> {:else}
{/if} <svg
</Tooltip> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
{/if}
</button>
{/if}
</Tooltip>
<Tooltip content={$i18n.t('Send message')}> <Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-100 dark:text-gray-900 dark:bg-gray-800 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === ''}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
{:else}
<button <button
id="send-message-button" class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
class="{prompt !== '' on:click={stopResponse}
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-100 dark:text-gray-900 dark:bg-gray-800 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === ''}
> >
<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-5 h-5" class="w-5 h-5"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
</button> </button>
</Tooltip> {/if}
{:else} </div>
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={stopResponse}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div> </div>
</div> </form>
</form>
<div class="mt-1.5 text-xs text-gray-500 text-center"> <div class="mt-1.5 text-xs text-gray-500 text-center">
{$i18n.t('LLMs can make mistakes. Verify important information.')} {$i18n.t('LLMs can make mistakes. Verify important information.')}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style>
.scrollbar-none:active::-webkit-scrollbar-thumb,
.scrollbar-none:focus::-webkit-scrollbar-thumb,
.scrollbar-none:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-none::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>
...@@ -87,6 +87,17 @@ ...@@ -87,6 +87,17 @@
chatInputElement?.focus(); chatInputElement?.focus();
await tick(); await tick();
}; };
const confirmSelectYoutube = async (url) => {
dispatch('youtube', url);
prompt = removeFirstHashWord(prompt);
const chatInputElement = document.getElementById('chat-textarea');
await tick();
chatInputElement?.focus();
await tick();
};
</script> </script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
...@@ -132,7 +143,30 @@ ...@@ -132,7 +143,30 @@
</button> </button>
{/each} {/each}
{#if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('https://www.youtube.com')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-100 selected-command-option-button"
type="button"
on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectYoutube(url);
} else {
toast.error(
$i18n.t(
'Oops! Looks like the URL is invalid. Please double-check and try again.'
)
);
}
}}
>
<div class=" font-medium text-black line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
</button>
{:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<button <button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-100 selected-command-option-button" class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-100 selected-command-option-button"
type="button" type="button"
......
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
import { generatePrompt } from '$lib/apis/ollama'; import { generatePrompt } from '$lib/apis/ollama';
import { models } from '$lib/stores'; import { models } from '$lib/stores';
import { splitStream } from '$lib/utils'; import { splitStream } from '$lib/utils';
...@@ -7,6 +9,8 @@ ...@@ -7,6 +9,8 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let prompt = ''; export let prompt = '';
export let user = null; export let user = null;
...@@ -17,12 +21,7 @@ ...@@ -17,12 +21,7 @@
let filteredModels = []; let filteredModels = [];
$: filteredModels = $models $: filteredModels = $models
.filter( .filter((p) => p.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? ''))
(p) =>
p.name !== 'hr' &&
!p.external &&
p.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? '')
)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
$: if (prompt) { $: if (prompt) {
...@@ -38,6 +37,11 @@ ...@@ -38,6 +37,11 @@
}; };
const confirmSelect = async (model) => { const confirmSelect = async (model) => {
prompt = '';
dispatch('select', model);
};
const confirmSelectCollaborativeChat = async (model) => {
// dispatch('select', model); // dispatch('select', model);
prompt = ''; prompt = '';
user = JSON.parse(JSON.stringify(model.name)); user = JSON.parse(JSON.stringify(model.name));
...@@ -127,40 +131,42 @@ ...@@ -127,40 +131,42 @@
}; };
</script> </script>
{#if filteredModels.length > 0} {#if prompt.charAt(0) === '@'}
<div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> {#if filteredModels.length > 0}
<div class="flex w-full px-2"> <div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center"> <div class="flex w-full px-2">
<div class=" text-lg font-semibold mt-2">@</div> <div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center">
</div> <div class=" text-lg font-semibold mt-2">@</div>
</div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white"> <div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5"> <div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
{#each filteredModels as model, modelIdx} {#each filteredModels as model, modelIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx class=" px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-100 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
on:click={() => { on:click={() => {
confirmSelect(model); confirmSelect(model);
}} }}
on:mousemove={() => { on:mousemove={() => {
selectedIdx = modelIdx; selectedIdx = modelIdx;
}} }}
on:focus={() => {}} on:focus={() => {}}
> >
<div class=" font-medium text-black line-clamp-1"> <div class=" font-medium text-black line-clamp-1">
{model.name} {model.name}
</div> </div>
<!-- <div class=" text-xs text-gray-600 line-clamp-1"> <!-- <div class=" text-xs text-gray-600 line-clamp-1">
{doc.title} {doc.title}
</div> --> </div> -->
</button> </button>
{/each} {/each}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> {/if}
{/if} {/if}
<script lang="ts"> <script lang="ts">
import Bolt from '$lib/components/icons/Bolt.svelte';
import { onMount } from 'svelte';
export let submitPrompt: Function; export let submitPrompt: Function;
export let suggestionPrompts = []; export let suggestionPrompts = [];
let prompts = []; let prompts = [];
$: prompts = $: prompts = suggestionPrompts
suggestionPrompts.length <= 4 .reduce((acc, current) => [...acc, ...[current]], [])
? suggestionPrompts .sort(() => Math.random() - 0.5);
: suggestionPrompts.sort(() => Math.random() - 0.5).slice(0, 4); // suggestionPrompts.length <= 4
// ? suggestionPrompts
// : suggestionPrompts.sort(() => Math.random() - 0.5).slice(0, 4);
onMount(() => {
const containerElement = document.getElementById('suggestions-container');
if (containerElement) {
containerElement.addEventListener('wheel', function (event) {
if (event.deltaY !== 0) {
// If scrolling vertically, prevent default behavior
event.preventDefault();
// Adjust horizontal scroll position based on vertical scroll
containerElement.scrollLeft += event.deltaY;
}
});
}
});
</script> </script>
<div class=" mb-3 md:p-1 text-left w-full"> {#if prompts.length > 0}
<div class=" flex flex-wrap-reverse px-2 text-left"> <div class="mb-2 flex gap-1 text-sm font-medium items-center text-gray-400 dark:text-gray-600">
<Bolt />
Suggested
</div>
{/if}
<div class="w-full">
<div
class="relative w-full flex gap-2 snap-x snap-mandatory md:snap-none overflow-x-auto tabs"
id="suggestions-container"
>
{#each prompts as prompt, promptIdx} {#each prompts as prompt, promptIdx}
<div <div class="snap-center shrink-0">
class="{promptIdx > 1 ? 'hidden sm:inline-flex' : ''} basis-full sm:basis-1/2 p-[5px] px-1"
>
<button <button
class=" flex-1 flex justify-between w-full h-full px-4 py-2.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 rounded-2xl transition group" class="flex flex-col flex-1 shrink-0 w-64 justify-between h-36 p-5 px-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 rounded-3xl transition group"
on:click={() => { on:click={() => {
submitPrompt(prompt.content); submitPrompt(prompt.content);
}} }}
> >
<div class="flex flex-col text-left self-center"> <div class="flex flex-col text-left">
{#if prompt.title && prompt.title[0] !== ''} {#if prompt.title && prompt.title[0] !== ''}
<div class="text-sm font-medium dark:text-gray-300">{prompt.title[0]}</div> <div
<div class="text-sm text-gray-500 line-clamp-1">{prompt.title[1]}</div> class=" font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition"
>
{prompt.title[0]}
</div>
<div class="text-sm text-gray-600 font-normal line-clamp-2">{prompt.title[1]}</div>
{:else} {:else}
<div class=" self-center text-sm font-medium dark:text-gray-300 line-clamp-2"> <div
class=" self-center text-sm font-medium dark:text-gray-300 dark:group-hover:text-gray-100 transition line-clamp-2"
>
{prompt.content} {prompt.content}
</div> </div>
{/if} {/if}
</div> </div>
<div <div class="w-full flex justify-between">
class="self-center p-1 rounded-lg text-gray-50 group-hover:text-gray-800 dark:text-gray-850 dark:group-hover:text-gray-100 transition" <div
> class="text-xs text-gray-400 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-500 transition self-center"
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path Prompt
fill-rule="evenodd" </div>
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd" <div
/> class="self-end p-1 rounded-lg text-gray-300 group-hover:text-gray-800 dark:text-gray-700 dark:group-hover:text-gray-100 transition"
</svg> >
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</div>
</div> </div>
</button> </button>
</div> </div>
{/each} {/each}
<!-- <div class="snap-center shrink-0">
<img
class="shrink-0 w-80 h-40 rounded-lg shadow-xl bg-white"
src="https://images.unsplash.com/photo-1604999565976-8913ad2ddb7c?ixlib=rb-1.2.1&amp;ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&amp;auto=format&amp;fit=crop&amp;w=320&amp;h=160&amp;q=80"
/>
</div> -->
</div> </div>
</div> </div>
<style>
.tabs::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.tabs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
import Placeholder from './Messages/Placeholder.svelte'; import Placeholder from './Messages/Placeholder.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import { imageGenerations } from '$lib/apis/images'; import { imageGenerations } from '$lib/apis/images';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard, findWordIndices } from '$lib/utils';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -22,6 +22,8 @@ ...@@ -22,6 +22,8 @@
export let continueGeneration: Function; export let continueGeneration: Function;
export let regenerateResponse: Function; export let regenerateResponse: Function;
export let prompt;
export let suggestionPrompts;
export let processing = ''; export let processing = '';
export let bottomPadding = false; export let bottomPadding = false;
export let autoScroll; export let autoScroll;
...@@ -236,108 +238,111 @@ ...@@ -236,108 +238,111 @@
history: history history: history
}); });
}; };
// const messageDeleteHandler = async (messageId) => {
// const message = history.messages[messageId];
// const parentId = message.parentId;
// const childrenIds = message.childrenIds ?? [];
// const grandchildrenIds = [];
// // Iterate through childrenIds to find grandchildrenIds
// for (const childId of childrenIds) {
// const childMessage = history.messages[childId];
// const grandChildrenIds = childMessage.childrenIds ?? [];
// for (const grandchildId of grandchildrenIds) {
// const childMessage = history.messages[grandchildId];
// childMessage.parentId = parentId;
// }
// grandchildrenIds.push(...grandChildrenIds);
// }
// history.messages[parentId].childrenIds.push(...grandchildrenIds);
// history.messages[parentId].childrenIds = history.messages[parentId].childrenIds.filter(
// (id) => id !== messageId
// );
// // Select latest message
// let currentMessageId = grandchildrenIds.at(-1);
// if (currentMessageId) {
// let messageChildrenIds = history.messages[currentMessageId].childrenIds;
// while (messageChildrenIds.length !== 0) {
// currentMessageId = messageChildrenIds.at(-1);
// messageChildrenIds = history.messages[currentMessageId].childrenIds;
// }
// history.currentId = currentMessageId;
// }
// await updateChatById(localStorage.token, chatId, { messages, history });
// };
</script> </script>
{#if messages.length == 0} <div class="h-full flex mb-16">
<Placeholder models={selectedModels} modelfiles={selectedModelfiles} /> {#if messages.length == 0}
{:else} <Placeholder
<div class=" pb-10"> models={selectedModels}
{#key chatId} modelfiles={selectedModelfiles}
{#each messages as message, messageIdx} {suggestionPrompts}
<div class=" w-full"> submitPrompt={async (p) => {
<div let text = p;
class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null
? 'max-w-full' if (p.includes('{{CLIPBOARD}}')) {
: 'max-w-3xl'} mx-auto rounded-lg group" const clipboardText = await navigator.clipboard.readText().catch((err) => {
> toast.error($i18n.t('Failed to read clipboard contents'));
{#if message.role === 'user'} return '{{CLIPBOARD}}';
<UserMessage });
on:delete={() => messageDeleteHandler(message.id)}
user={$user} text = p.replaceAll('{{CLIPBOARD}}', clipboardText);
{readOnly} }
{message}
isFirstMessage={messageIdx === 0} prompt = text;
siblings={message.parentId !== null
? history.messages[message.parentId]?.childrenIds ?? [] await tick();
: Object.values(history.messages)
.filter((message) => message.parentId === null) const chatInputElement = document.getElementById('chat-textarea');
.map((message) => message.id) ?? []} if (chatInputElement) {
{confirmEditMessage} prompt = p;
{showPreviousMessage}
{showNextMessage} chatInputElement.style.height = '';
copyToClipboard={copyToClipboardWithToast} chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
/> chatInputElement.focus();
{:else}
<ResponseMessage const words = findWordIndices(prompt);
{message}
modelfiles={selectedModelfiles} if (words.length > 0) {
siblings={history.messages[message.parentId]?.childrenIds ?? []} const word = words.at(0);
isLastMessage={messageIdx + 1 === messages.length} chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
{readOnly} }
{updateChatMessages} }
{confirmEditResponseMessage}
{showPreviousMessage} await tick();
{showNextMessage} }}
{rateMessage} />
copyToClipboard={copyToClipboardWithToast} {:else}
{continueGeneration} <div class="w-full pt-2">
{regenerateResponse} {#key chatId}
on:save={async (e) => { {#each messages as message, messageIdx}
console.log('save', e); <div class=" w-full {messageIdx === messages.length - 1 ? 'pb-28' : ''}">
<div
const message = e.detail; class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null
history.messages[message.id] = message; ? 'max-w-full'
await updateChatById(localStorage.token, chatId, { : 'max-w-5xl'} mx-auto rounded-lg group"
messages: messages, >
history: history {#if message.role === 'user'}
}); <UserMessage
}} on:delete={() => messageDeleteHandler(message.id)}
/> user={$user}
{/if} {readOnly}
{message}
isFirstMessage={messageIdx === 0}
siblings={message.parentId !== null
? history.messages[message.parentId]?.childrenIds ?? []
: Object.values(history.messages)
.filter((message) => message.parentId === null)
.map((message) => message.id) ?? []}
{confirmEditMessage}
{showPreviousMessage}
{showNextMessage}
copyToClipboard={copyToClipboardWithToast}
/>
{:else}
<ResponseMessage
{message}
modelfiles={selectedModelfiles}
siblings={history.messages[message.parentId]?.childrenIds ?? []}
isLastMessage={messageIdx + 1 === messages.length}
{readOnly}
{updateChatMessages}
{confirmEditResponseMessage}
{showPreviousMessage}
{showNextMessage}
{rateMessage}
copyToClipboard={copyToClipboardWithToast}
{continueGeneration}
{regenerateResponse}
on:save={async (e) => {
console.log('save', e);
const message = e.detail;
history.messages[message.id] = message;
await updateChatById(localStorage.token, chatId, {
messages: messages,
history: history
});
}}
/>
{/if}
</div>
</div> </div>
</div> {/each}
{/each}
{#if bottomPadding}
{#if bottomPadding} <div class=" pb-20" />
<div class=" mb-10" /> {/if}
{/if} {/key}
{/key} </div>
</div> {/if}
{/if} </div>
...@@ -31,7 +31,9 @@ ...@@ -31,7 +31,9 @@
> >
</div> </div>
<pre class=" rounded-b-lg hljs p-4 px-5 overflow-x-auto rounded-t-none"><code <pre
class=" hljs p-4 px-5 overflow-x-auto"
style="border-top-left-radius: 0px; border-top-right-radius: 0px;"><code
class="language-{lang} rounded-t-none whitespace-pre">{@html highlightedCode || code}</code class="language-{lang} rounded-t-none whitespace-pre">{@html highlightedCode || code}</code
></pre> ></pre>
</div> </div>
......
...@@ -3,11 +3,19 @@ ...@@ -3,11 +3,19 @@
import { user } from '$lib/stores'; import { user } from '$lib/stores';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { blur, fade } from 'svelte/transition';
import Suggestions from '../MessageInput/Suggestions.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let models = []; export let models = [];
export let modelfiles = []; export let modelfiles = [];
export let submitPrompt;
export let suggestionPrompts;
let mounted = false;
let modelfile = null; let modelfile = null;
let selectedModelIdx = 0; let selectedModelIdx = 0;
...@@ -17,12 +25,16 @@ ...@@ -17,12 +25,16 @@
$: if (models.length > 0) { $: if (models.length > 0) {
selectedModelIdx = models.length - 1; selectedModelIdx = models.length - 1;
} }
onMount(() => {
mounted = true;
});
</script> </script>
{#if models.length > 0} {#key mounted}
<div class="m-auto text-center max-w-md px-2"> <div class="m-auto w-full max-w-6xl px-8 lg:px-24 pb-16">
<div class="flex justify-center mt-8"> <div class="flex justify-start">
<div class="flex -space-x-4 mb-1"> <div class="flex -space-x-4 mb-1" in:fade={{ duration: 200 }}>
{#each models as model, modelIdx} {#each models as model, modelIdx}
<button <button
on:click={() => { on:click={() => {
...@@ -33,15 +45,15 @@ ...@@ -33,15 +45,15 @@
<img <img
src={modelfiles[model]?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`} src={modelfiles[model]?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`}
alt="modelfile" alt="modelfile"
class=" size-12 rounded-full border-[1px] border-gray-200 dark:border-none" class=" size-[2.7rem] rounded-full border-[1px] border-gray-200 dark:border-none"
draggable="false" draggable="false"
/> />
{:else} {:else}
<img <img
src={models.length === 1 src={$i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/static/favicon.png` ? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`} : `${WEBUI_BASE_URL}/static/favicon.png`}
class=" size-12 rounded-full border-[1px] border-gray-200 dark:border-none" class=" size-[2.7rem] rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo" alt="logo"
draggable="false" draggable="false"
/> />
...@@ -50,26 +62,42 @@ ...@@ -50,26 +62,42 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class=" mt-2 mb-5 text-2xl text-gray-800 dark:text-gray-100 font-semibold">
{#if modelfile} <div
<span class=" capitalize"> class=" mt-2 mb-4 text-3xl text-gray-800 dark:text-gray-100 font-semibold text-left flex items-center gap-4"
{modelfile.title} >
</span> <div>
<div class="mt-0.5 text-base font-normal text-gray-600 dark:text-gray-400"> <div class=" capitalize line-clamp-1" in:fade={{ duration: 200 }}>
{modelfile.desc} {#if modelfile}
{modelfile.title}
{:else}
{$i18n.t('Hello, {{name}}', { name: $user.name })}
{/if}
</div> </div>
{#if modelfile.user}
<div class="mt-0.5 text-sm font-normal text-gray-500 dark:text-gray-500">
By <a href="https://openwebui.com/m/{modelfile.user.username}"
>{modelfile.user.name ? modelfile.user.name : `@${modelfile.user.username}`}</a
>
</div>
{/if}
{:else}
<div class=" line-clamp-1">{$i18n.t('Hello, {{name}}', { name: $user.name })}</div>
<div>{$i18n.t('How can I help you today?')}</div> <div in:fade={{ duration: 200, delay: 200 }}>
{/if} {#if modelfile}
<div class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400">
{modelfile.desc}
</div>
{#if modelfile.user}
<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
By <a href="https://openwebui.com/m/{modelfile.user.username}"
>{modelfile.user.name ? modelfile.user.name : `@${modelfile.user.username}`}</a
>
</div>
{/if}
{:else}
<div class=" font-medium text-gray-400 dark:text-gray-500">
{$i18n.t('How can I help you today?')}
</div>
{/if}
</div>
</div>
</div>
<div class=" w-full" in:fade={{ duration: 200, delay: 300 }}>
<Suggestions {suggestionPrompts} {submitPrompt} />
</div> </div>
</div> </div>
{/if} {/key}
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