Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
chenpangpang
open-webui
Commits
1eebb85f
"vscode:/vscode.git/clone" did not exist on "7fec9ed62cd23852f06866efe30fd6e3c34fa8dd"
Unverified
Commit
1eebb85f
authored
Jun 27, 2024
by
Timothy Jaeryang Baek
Committed by
GitHub
Jun 27, 2024
Browse files
Merge pull request #3323 from open-webui/dev
0.3.6
parents
9e4dd4b8
b224ba00
Changes
177
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
388 additions
and
322 deletions
+388
-322
src/lib/components/admin/AddUserModal.svelte
src/lib/components/admin/AddUserModal.svelte
+6
-6
src/lib/components/admin/Settings/Audio.svelte
src/lib/components/admin/Settings/Audio.svelte
+7
-16
src/lib/components/admin/Settings/Connections.svelte
src/lib/components/admin/Settings/Connections.svelte
+6
-8
src/lib/components/admin/Settings/Database.svelte
src/lib/components/admin/Settings/Database.svelte
+4
-2
src/lib/components/admin/Settings/Documents.svelte
src/lib/components/admin/Settings/Documents.svelte
+33
-24
src/lib/components/admin/Settings/Images.svelte
src/lib/components/admin/Settings/Images.svelte
+28
-9
src/lib/components/admin/Settings/Pipelines.svelte
src/lib/components/admin/Settings/Pipelines.svelte
+8
-8
src/lib/components/admin/Settings/WebSearch.svelte
src/lib/components/admin/Settings/WebSearch.svelte
+27
-67
src/lib/components/admin/SettingsModal.svelte
src/lib/components/admin/SettingsModal.svelte
+0
-43
src/lib/components/chat/Chat.svelte
src/lib/components/chat/Chat.svelte
+110
-46
src/lib/components/chat/MessageInput.svelte
src/lib/components/chat/MessageInput.svelte
+85
-68
src/lib/components/chat/MessageInput/CallOverlay.svelte
src/lib/components/chat/MessageInput/CallOverlay.svelte
+20
-4
src/lib/components/chat/MessageInput/Documents.svelte
src/lib/components/chat/MessageInput/Documents.svelte
+12
-3
src/lib/components/chat/MessageInput/Models.svelte
src/lib/components/chat/MessageInput/Models.svelte
+4
-2
src/lib/components/chat/MessageInput/PromptCommands.svelte
src/lib/components/chat/MessageInput/PromptCommands.svelte
+2
-2
src/lib/components/chat/MessageInput/Suggestions.svelte
src/lib/components/chat/MessageInput/Suggestions.svelte
+1
-1
src/lib/components/chat/Messages.svelte
src/lib/components/chat/Messages.svelte
+1
-1
src/lib/components/chat/Messages/CodeBlock.svelte
src/lib/components/chat/Messages/CodeBlock.svelte
+11
-1
src/lib/components/chat/Messages/Placeholder.svelte
src/lib/components/chat/Messages/Placeholder.svelte
+19
-9
src/lib/components/chat/Messages/ProfileImage.svelte
src/lib/components/chat/Messages/ProfileImage.svelte
+4
-2
No files found.
src/lib/components/admin/AddUserModal.svelte
View file @
1eebb85f
...
@@ -153,7 +153,7 @@
...
@@ -153,7 +153,7 @@
type="button"
type="button"
on:click={() => {
on:click={() => {
tab = '';
tab = '';
}}>Form</button
}}>
{$i18n.t('
Form
')}
</button
>
>
<button
<button
...
@@ -161,7 +161,7 @@
...
@@ -161,7 +161,7 @@
type="button"
type="button"
on:click={() => {
on:click={() => {
tab = 'import';
tab = 'import';
}}>CSV Import</button
}}>
{$i18n.t('
CSV Import
')}
</button
>
>
</div>
</div>
<div class="px-1">
<div class="px-1">
...
@@ -176,9 +176,9 @@
...
@@ -176,9 +176,9 @@
placeholder={$i18n.t('Enter Your Role')}
placeholder={$i18n.t('Enter Your Role')}
required
required
>
>
<option value="pending"> pending </option>
<option value="pending">
{$i18n.t('
pending
')}
</option>
<option value="user"> user </option>
<option value="user">
{$i18n.t('
user
')}
</option>
<option value="admin"> admin </option>
<option value="admin">
{$i18n.t('
admin
')}
</option>
</select>
</select>
</div>
</div>
</div>
</div>
...
@@ -262,7 +262,7 @@
...
@@ -262,7 +262,7 @@
class="underline dark:text-gray-200"
class="underline dark:text-gray-200"
href="{WEBUI_BASE_URL}/static/user-import.csv"
href="{WEBUI_BASE_URL}/static/user-import.csv"
>
>
Click here to download user import template file.
{$i18n.t('
Click here to download user import template file.
')}
</a>
</a>
</div>
</div>
</div>
</div>
...
...
src/lib/components/admin/Settings/Audio.svelte
View file @
1eebb85f
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
import { toast } from 'svelte-sonner';
import { toast } from 'svelte-sonner';
import Switch from '$lib/components/common/Switch.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import { getBackendConfig } from '$lib/apis';
import { getBackendConfig } from '$lib/apis';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -72,7 +73,7 @@
...
@@ -72,7 +73,7 @@
});
});
if (res) {
if (res) {
toast.success('Audio settings updated successfully');
toast.success(
$i18n.t(
'Audio settings updated successfully')
)
;
config.set(await getBackendConfig());
config.set(await getBackendConfig());
}
}
...
@@ -137,18 +138,13 @@
...
@@ -137,18 +138,13 @@
<div>
<div>
<div class="mt-1 flex gap-2 mb-1">
<div class="mt-1 flex gap-2 mb-1">
<input
<input
class="w-full rounded-lg py-2 p
x
-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="
flex-1
w-full rounded-
l-
lg py-2 p
l
-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
placeholder={$i18n.t('API Base URL')}
bind:value={STT_OPENAI_API_BASE_URL}
bind:value={STT_OPENAI_API_BASE_URL}
required
required
/>
/>
<input
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={STT_OPENAI_API_KEY} />
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={STT_OPENAI_API_KEY}
required
/>
</div>
</div>
</div>
</div>
...
@@ -198,7 +194,7 @@
...
@@ -198,7 +194,7 @@
}}
}}
>
>
<option value="">{$i18n.t('Web API')}</option>
<option value="">{$i18n.t('Web API')}</option>
<option value="openai">{$i18n.t('Open
AI')}</option>
<option value="openai">{$i18n.t('OpenAI')}</option>
</select>
</select>
</div>
</div>
</div>
</div>
...
@@ -207,18 +203,13 @@
...
@@ -207,18 +203,13 @@
<div>
<div>
<div class="mt-1 flex gap-2 mb-1">
<div class="mt-1 flex gap-2 mb-1">
<input
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="
flex-1
w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
placeholder={$i18n.t('API Base URL')}
bind:value={TTS_OPENAI_API_BASE_URL}
bind:value={TTS_OPENAI_API_BASE_URL}
required
required
/>
/>
<input
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={TTS_OPENAI_API_KEY} />
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={TTS_OPENAI_API_KEY}
required
/>
</div>
</div>
</div>
</div>
{/if}
{/if}
...
...
src/lib/components/admin/Settings/Connections.svelte
View file @
1eebb85f
<script lang="ts">
<script lang="ts">
import { models, user } from '$lib/stores';
import { models, user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
import {
import {
...
@@ -24,6 +25,7 @@
...
@@ -24,6 +25,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getModels as _getModels } from '$lib/apis';
import { getModels as _getModels } from '$lib/apis';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -228,14 +230,10 @@
...
@@ -228,14 +230,10 @@
{/if}
{/if}
</div>
</div>
<div class="flex-1">
<SensitiveInput
<input
placeholder={$i18n.t('API Key')}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={OPENAI_API_KEYS[idx]}
placeholder={$i18n.t('API Key')}
/>
bind:value={OPENAI_API_KEYS[idx]}
autocomplete="off"
/>
</div>
<div class="self-center flex items-center">
<div class="self-center flex items-center">
{#if idx === 0}
{#if idx === 0}
<button
<button
...
...
src/lib/components/admin/Settings/Database.svelte
View file @
1eebb85f
...
@@ -126,7 +126,9 @@
...
@@ -126,7 +126,9 @@
/>
/>
</svg>
</svg>
</div>
</div>
<div class=" self-center text-sm font-medium">Export LiteLLM config.yaml</div>
<div class=" self-center text-sm font-medium">
{$i18n.t('Export LiteLLM config.yaml')}
</div>
</button>
</button>
</div>
</div>
</div>
</div>
...
@@ -137,7 +139,7 @@
...
@@ -137,7 +139,7 @@
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
type="submit"
>
>
Save
{$i18n.t('
Save
')}
</button>
</button>
</div> -->
</div> -->
...
...
src/lib/components/admin/Settings/Documents.svelte
View file @
1eebb85f
<script lang="ts">
<script lang="ts">
import { getDocs } from '$lib/apis/documents';
import { getDocs } from '$lib/apis/documents';
import { deleteAllFiles, deleteFileById } from '$lib/apis/files';
import {
import {
getQuerySettings,
getQuerySettings,
scanDocs,
scanDocs,
...
@@ -19,6 +20,7 @@
...
@@ -19,6 +20,7 @@
import { documents, models } from '$lib/stores';
import { documents, models } from '$lib/stores';
import { onMount, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { toast } from 'svelte-sonner';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -217,8 +219,8 @@
...
@@ -217,8 +219,8 @@
<ResetUploadDirConfirmDialog
<ResetUploadDirConfirmDialog
bind:show={showResetUploadDirConfirm}
bind:show={showResetUploadDirConfirm}
on:confirm={() => {
on:confirm={
async
() => {
const res =
resetUploadDir
(localStorage.token).catch((error) => {
const res =
await deleteAllFiles
(localStorage.token).catch((error) => {
toast.error(error);
toast.error(error);
return null;
return null;
});
});
...
@@ -279,24 +281,28 @@
...
@@ -279,24 +281,28 @@
viewBox="0 0 24 24"
viewBox="0 0 24 24"
fill="currentColor"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
><style>
>
<style>
.spinner_ajPY {
.spinner_ajPY {
transform-origin: center;
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
animation: spinner_AtaB 0.75s infinite linear;
}
}
@keyframes spinner_AtaB {
@keyframes spinner_AtaB {
100% {
100% {
transform: rotate(360deg);
transform: rotate(360deg);
}
}
}
}
</style><path
</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"
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"
opacity=".25"
/><path
/>
<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"
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"
class="spinner_ajPY"
/>
</svg
/>
>
</svg
>
</div>
</div>
{/if}
{/if}
</button>
</button>
...
@@ -329,18 +335,13 @@
...
@@ -329,18 +335,13 @@
{#if embeddingEngine === 'openai'}
{#if embeddingEngine === 'openai'}
<div class="my-0.5 flex gap-2">
<div class="my-0.5 flex gap-2">
<input
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="
flex-1
w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
placeholder={$i18n.t('API Base URL')}
bind:value={OpenAIUrl}
bind:value={OpenAIUrl}
required
required
/>
/>
<input
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={OpenAIKey}
required
/>
</div>
</div>
<div class="flex mt-0.5 space-x-2">
<div class="flex mt-0.5 space-x-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
...
@@ -438,24 +439,28 @@
...
@@ -438,24 +439,28 @@
viewBox="0 0 24 24"
viewBox="0 0 24 24"
fill="currentColor"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
><style>
>
<style>
.spinner_ajPY {
.spinner_ajPY {
transform-origin: center;
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
animation: spinner_AtaB 0.75s infinite linear;
}
}
@keyframes spinner_AtaB {
@keyframes spinner_AtaB {
100% {
100% {
transform: rotate(360deg);
transform: rotate(360deg);
}
}
}
}
</style><path
</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"
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"
opacity=".25"
/><path
/>
<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"
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"
class="spinner_ajPY"
/>
</svg
/>
>
</svg
>
</div>
</div>
{:else}
{:else}
<svg
<svg
...
@@ -511,24 +516,28 @@
...
@@ -511,24 +516,28 @@
viewBox="0 0 24 24"
viewBox="0 0 24 24"
fill="currentColor"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
><style>
>
<style>
.spinner_ajPY {
.spinner_ajPY {
transform-origin: center;
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
animation: spinner_AtaB 0.75s infinite linear;
}
}
@keyframes spinner_AtaB {
@keyframes spinner_AtaB {
100% {
100% {
transform: rotate(360deg);
transform: rotate(360deg);
}
}
}
}
</style><path
</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"
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"
opacity=".25"
/><path
/>
<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"
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"
class="spinner_ajPY"
/>
</svg
/>
>
</svg
>
</div>
</div>
{:else}
{:else}
<svg
<svg
...
...
src/lib/components/admin/Settings/Images.svelte
View file @
1eebb85f
...
@@ -19,6 +19,7 @@
...
@@ -19,6 +19,7 @@
updateOpenAIConfig
updateOpenAIConfig
} from '$lib/apis/images';
} from '$lib/apis/images';
import { getBackendConfig } from '$lib/apis';
import { getBackendConfig } from '$lib/apis';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -29,6 +30,7 @@
...
@@ -29,6 +30,7 @@
let enableImageGeneration = false;
let enableImageGeneration = false;
let AUTOMATIC1111_BASE_URL = '';
let AUTOMATIC1111_BASE_URL = '';
let AUTOMATIC1111_API_AUTH = '';
let COMFYUI_BASE_URL = '';
let COMFYUI_BASE_URL = '';
let OPENAI_API_BASE_URL = '';
let OPENAI_API_BASE_URL = '';
...
@@ -74,7 +76,8 @@
...
@@ -74,7 +76,8 @@
}
}
} else {
} else {
const res = await updateImageGenerationEngineUrls(localStorage.token, {
const res = await updateImageGenerationEngineUrls(localStorage.token, {
AUTOMATIC1111_BASE_URL: AUTOMATIC1111_BASE_URL
AUTOMATIC1111_BASE_URL: AUTOMATIC1111_BASE_URL,
AUTOMATIC1111_API_AUTH: AUTOMATIC1111_API_AUTH
}).catch((error) => {
}).catch((error) => {
toast.error(error);
toast.error(error);
return null;
return null;
...
@@ -82,6 +85,7 @@
...
@@ -82,6 +85,7 @@
if (res) {
if (res) {
AUTOMATIC1111_BASE_URL = res.AUTOMATIC1111_BASE_URL;
AUTOMATIC1111_BASE_URL = res.AUTOMATIC1111_BASE_URL;
AUTOMATIC1111_API_AUTH = res.AUTOMATIC1111_API_AUTH;
await getModels();
await getModels();
...
@@ -89,7 +93,9 @@
...
@@ -89,7 +93,9 @@
toast.success($i18n.t('Server connection verified'));
toast.success($i18n.t('Server connection verified'));
}
}
} else {
} else {
({ AUTOMATIC1111_BASE_URL } = await getImageGenerationEngineUrls(localStorage.token));
({ AUTOMATIC1111_BASE_URL, AUTOMATIC1111_API_AUTH } = await getImageGenerationEngineUrls(
localStorage.token
));
}
}
}
}
};
};
...
@@ -128,6 +134,7 @@
...
@@ -128,6 +134,7 @@
const URLS = await getImageGenerationEngineUrls(localStorage.token);
const URLS = await getImageGenerationEngineUrls(localStorage.token);
AUTOMATIC1111_BASE_URL = URLS.AUTOMATIC1111_BASE_URL;
AUTOMATIC1111_BASE_URL = URLS.AUTOMATIC1111_BASE_URL;
AUTOMATIC1111_API_AUTH = URLS.AUTOMATIC1111_API_AUTH;
COMFYUI_BASE_URL = URLS.COMFYUI_BASE_URL;
COMFYUI_BASE_URL = URLS.COMFYUI_BASE_URL;
const config = await getOpenAIConfig(localStorage.token);
const config = await getOpenAIConfig(localStorage.token);
...
@@ -270,6 +277,23 @@
...
@@ -270,6 +277,23 @@
{$i18n.t('(e.g. `sh webui.sh --api`)')}
{$i18n.t('(e.g. `sh webui.sh --api`)')}
</a>
</a>
</div>
</div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('AUTOMATIC1111 Api Auth String')}</div>
<SensitiveInput
placeholder={$i18n.t('Enter api auth string (e.g. username:password)')}
bind:value={AUTOMATIC1111_API_AUTH}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Include `--api-auth` flag when running stable-diffusion-webui')}
<a
class=" text-gray-300 font-medium"
href="https://github.com/AUTOMATIC1111/stable-diffusion-webui/discussions/13993"
target="_blank"
>
{$i18n.t('(e.g. `sh webui.sh --api --api-auth username_password`)').replace('_', ':')}
</a>
</div>
{:else if imageGenerationEngine === 'comfyui'}
{:else if imageGenerationEngine === 'comfyui'}
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('ComfyUI Base URL')}</div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('ComfyUI Base URL')}</div>
<div class="flex w-full">
<div class="flex w-full">
...
@@ -307,18 +331,13 @@
...
@@ -307,18 +331,13 @@
<div class="flex gap-2 mb-1">
<div class="flex gap-2 mb-1">
<input
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="
flex-1
w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
placeholder={$i18n.t('API Base URL')}
bind:value={OPENAI_API_BASE_URL}
bind:value={OPENAI_API_BASE_URL}
required
required
/>
/>
<input
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OPENAI_API_KEY} />
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={OPENAI_API_KEY}
required
/>
</div>
</div>
</div>
</div>
{/if}
{/if}
...
...
src/lib/components/admin/Settings/Pipelines.svelte
View file @
1eebb85f
...
@@ -60,13 +60,13 @@
...
@@ -60,13 +60,13 @@
});
});
if (res) {
if (res) {
toast.success('Valves updated successfully');
toast.success(
$i18n.t(
'Valves updated successfully')
)
;
setPipelines();
setPipelines();
models.set(await getModels(localStorage.token));
models.set(await getModels(localStorage.token));
saveHandler();
saveHandler();
}
}
} else {
} else {
toast.error('No valves to update');
toast.error(
$i18n.t(
'No valves to update')
)
;
}
}
};
};
...
@@ -122,7 +122,7 @@
...
@@ -122,7 +122,7 @@
});
});
if (res) {
if (res) {
toast.success('Pipeline downloaded successfully');
toast.success(
$i18n.t(
'Pipeline downloaded successfully')
)
;
setPipelines();
setPipelines();
models.set(await getModels(localStorage.token));
models.set(await getModels(localStorage.token));
}
}
...
@@ -147,12 +147,12 @@
...
@@ -147,12 +147,12 @@
);
);
if (res) {
if (res) {
toast.success('Pipeline downloaded successfully');
toast.success(
$i18n.t(
'Pipeline downloaded successfully')
)
;
setPipelines();
setPipelines();
models.set(await getModels(localStorage.token));
models.set(await getModels(localStorage.token));
}
}
} else {
} else {
toast.error('No file selected');
toast.error(
$i18n.t(
'No file selected')
)
;
}
}
pipelineFiles = null;
pipelineFiles = null;
...
@@ -176,7 +176,7 @@
...
@@ -176,7 +176,7 @@
});
});
if (res) {
if (res) {
toast.success('Pipeline deleted successfully');
toast.success(
$i18n.t(
'Pipeline deleted successfully')
)
;
setPipelines();
setPipelines();
models.set(await getModels(localStorage.token));
models.set(await getModels(localStorage.token));
}
}
...
@@ -509,7 +509,7 @@
...
@@ -509,7 +509,7 @@
</div>
</div>
{/if}
{/if}
{:else}
{:else}
<div>Pipelines Not Detected</div>
<div>
{$i18n.t('
Pipelines Not Detected
')}
</div>
{/if}
{/if}
{:else}
{:else}
<div class="flex justify-center h-full">
<div class="flex justify-center h-full">
...
@@ -525,7 +525,7 @@
...
@@ -525,7 +525,7 @@
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
type="submit"
>
>
Save
{$i18n.t('
Save
')}
</button>
</button>
</div>
</div>
</form>
</form>
src/lib/components/admin/Settings/WebSearch.svelte
View file @
1eebb85f
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
import { documents, models } from '$lib/stores';
import { documents, models } from '$lib/stores';
import { onMount, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { toast } from 'svelte-sonner';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -19,7 +20,8 @@
...
@@ -19,7 +20,8 @@
'serper',
'serper',
'serply',
'serply',
'duckduckgo',
'duckduckgo',
'tavily'
'tavily',
'jina'
];
];
let youtubeLanguage = 'en';
let youtubeLanguage = 'en';
...
@@ -114,17 +116,10 @@
...
@@ -114,17 +116,10 @@
{$i18n.t('Google PSE API Key')}
{$i18n.t('Google PSE API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Google PSE API Key')}
<input
bind:value={webConfig.search.google_pse_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Google PSE API Key')}
bind:value={webConfig.search.google_pse_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<div class="mt-1.5">
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
<div class=" self-center text-xs font-medium mb-1">
...
@@ -149,17 +144,10 @@
...
@@ -149,17 +144,10 @@
{$i18n.t('Brave Search API Key')}
{$i18n.t('Brave Search API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Brave Search API Key')}
<input
bind:value={webConfig.search.brave_search_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Brave Search API Key')}
bind:value={webConfig.search.brave_search_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'serpstack'}
{:else if webConfig.search.engine === 'serpstack'}
<div>
<div>
...
@@ -167,17 +155,10 @@
...
@@ -167,17 +155,10 @@
{$i18n.t('Serpstack API Key')}
{$i18n.t('Serpstack API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Serpstack API Key')}
<input
bind:value={webConfig.search.serpstack_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Serpstack API Key')}
bind:value={webConfig.search.serpstack_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'serper'}
{:else if webConfig.search.engine === 'serper'}
<div>
<div>
...
@@ -185,17 +166,10 @@
...
@@ -185,17 +166,10 @@
{$i18n.t('Serper API Key')}
{$i18n.t('Serper API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Serper API Key')}
<input
bind:value={webConfig.search.serper_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Serper API Key')}
bind:value={webConfig.search.serper_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'serply'}
{:else if webConfig.search.engine === 'serply'}
<div>
<div>
...
@@ -203,17 +177,10 @@
...
@@ -203,17 +177,10 @@
{$i18n.t('Serply API Key')}
{$i18n.t('Serply API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Serply API Key')}
<input
bind:value={webConfig.search.serply_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Serply API Key')}
bind:value={webConfig.search.serply_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'tavily'}
{:else if webConfig.search.engine === 'tavily'}
<div>
<div>
...
@@ -221,17 +188,10 @@
...
@@ -221,17 +188,10 @@
{$i18n.t('Tavily API Key')}
{$i18n.t('Tavily API Key')}
</div>
</div>
<div class="flex w-full">
<SensitiveInput
<div class="flex-1">
placeholder={$i18n.t('Enter Tavily API Key')}
<input
bind:value={webConfig.search.tavily_api_key}
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/>
type="text"
placeholder={$i18n.t('Enter Tavily API Key')}
bind:value={webConfig.search.tavily_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
...
...
src/lib/components/admin/SettingsModal.svelte
deleted
100644 → 0
View file @
9e4dd4b8
<script>
import { getContext } from 'svelte';
import Modal from '../common/Modal.svelte';
import Database from './Settings/Database.svelte';
import General from './Settings/General.svelte';
import Users from './Settings/Users.svelte';
import Banners from '$lib/components/admin/Settings/Banners.svelte';
import { toast } from 'svelte-sonner';
import Pipelines from './Settings/Pipelines.svelte';
const i18n = getContext('i18n');
export let show = false;
let selectedTab = 'general';
</script>
<Modal 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('Admin 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>
</div>
</Modal>
src/lib/components/chat/Chat.svelte
View file @
1eebb85f
...
@@ -127,6 +127,42 @@
...
@@ -127,6 +127,42 @@
}
}
onMount(async () => {
onMount(async () => {
const onMessageHandler = async (event) => {
if (event.origin === window.origin) {
// Replace with your iframe's origin
console.log('Message received from iframe:', event.data);
if (event.data.type === 'input:prompt') {
console.log(event.data.text);
const inputElement = document.getElementById('chat-textarea');
if (inputElement) {
prompt = event.data.text;
inputElement.focus();
}
}
if (event.data.type === 'action:submit') {
console.log(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(prompt);
}
}
if (event.data.type === 'input:prompt:submit') {
console.log(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(event.data.text);
}
}
}
};
window.addEventListener('message', onMessageHandler);
if (!$chatId) {
if (!$chatId) {
chatId.subscribe(async (value) => {
chatId.subscribe(async (value) => {
if (!value) {
if (!value) {
...
@@ -138,6 +174,10 @@
...
@@ -138,6 +174,10 @@
await goto('/');
await goto('/');
}
}
}
}
return () => {
window.removeEventListener('message', onMessageHandler);
};
});
});
//////////////////////////
//////////////////////////
...
@@ -273,11 +313,14 @@
...
@@ -273,11 +313,14 @@
id: m.id,
id: m.id,
role: m.role,
role: m.role,
content: m.content,
content: m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp
timestamp: m.timestamp
})),
})),
chat_id: $chatId
chat_id: $chatId
}).catch((error) => {
}).catch((error) => {
console.error(error);
toast.error(error);
messages.at(-1).error = { content: error };
return null;
return null;
});
});
...
@@ -322,9 +365,16 @@
...
@@ -322,9 +365,16 @@
} 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 (messages.length != 0 && messages.at(-1).error) {
// Error in response
toast.error(
$i18n.t(
`Oops! There was an error in the previous response. Please try again or contact admin.`
)
);
} else if (
} else if (
files.length > 0 &&
files.length > 0 &&
files.filter((file) => file.
upload_
status
=
==
false
).length > 0
files.filter((file) => file.
type !== 'image' && file.
status
!
==
'processed'
).length > 0
) {
) {
// Upload not done
// Upload not done
toast.error(
toast.error(
...
@@ -479,14 +529,13 @@
...
@@ -479,14 +529,13 @@
});
});
if (res) {
if (res) {
if (res.documents[0].length > 0) {
if (res.documents[0].length > 0) {
userContext = res.documents.reduce((acc, doc, index) => {
userContext = res.documents
[0]
.reduce((acc, doc, index) => {
const createdAtTimestamp = res.metadatas[index]
[0]
.created_at;
const createdAtTimestamp = res.metadatas[
0][
index].created_at;
const createdAtDate = new Date(createdAtTimestamp * 1000)
const createdAtDate = new Date(createdAtTimestamp * 1000)
.toISOString()
.toISOString()
.split('T')[0];
.split('T')[0];
acc.push(`${index + 1}. [${createdAtDate}]. ${doc[0]}`);
return `${acc}${index + 1}. [${createdAtDate}]. ${doc}\n`;
return acc;
}, '');
}, []);
}
}
console.log(userContext);
console.log(userContext);
...
@@ -542,7 +591,7 @@
...
@@ -542,7 +591,7 @@
: undefined
: undefined
)}${
)}${
responseMessage?.userContext ?? null
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${
(
responseMessage?.userContext ??
[]).join('\n')
}`
? `\n\nUser Context:\n${responseMessage?.userContext ??
''
}`
: ''
: ''
}`
}`
}
}
...
@@ -585,23 +634,22 @@
...
@@ -585,23 +634,22 @@
}
}
});
});
let docs = [];
let files = [];
if (model?.info?.meta?.knowledge ?? false) {
if (model?.info?.meta?.knowledge ?? false) {
doc
s = model.info.meta.knowledge;
file
s = model.info.meta.knowledge;
}
}
const lastUserMessage = messages.filter((message) => message.role === 'user').at(-1);
docs = [
...docs,
files = [
...messages
...files,
.filter((message) => message?.files ?? null)
...(lastUserMessage?.files?.filter((item) =>
.map((message) =>
['doc', 'file', 'collection', 'web_search_results'].includes(item.type)
message.files.filter((item) =>
) ?? []),
['doc', 'collection', 'web_search_results'].includes(item.type)
...(responseMessage?.files?.filter((item) =>
)
['doc', 'file', 'collection', 'web_search_results'].includes(item.type)
)
) ?? [])
.flat(1)
].filter(
].filter(
// Remove duplicates
(item, index, array) =>
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
);
...
@@ -633,8 +681,8 @@
...
@@ -633,8 +681,8 @@
format: $settings.requestFormat ?? undefined,
format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined,
keep_alive: $settings.keepAlive ?? undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
docs: doc
s.length > 0 ?
doc
s : undefined,
files: file
s.length > 0 ?
file
s : undefined,
citations:
doc
s.length > 0,
citations:
file
s.length > 0
? true : undefined
,
chat_id: $chatId
chat_id: $chatId
});
});
...
@@ -830,23 +878,21 @@
...
@@ -830,23 +878,21 @@
let _response = null;
let _response = null;
const responseMessage = history.messages[responseMessageId];
const responseMessage = history.messages[responseMessageId];
let docs = [];
let files = [];
if (model?.info?.meta?.knowledge ?? false) {
if (model?.info?.meta?.knowledge ?? false) {
doc
s = model.info.meta.knowledge;
file
s = model.info.meta.knowledge;
}
}
const lastUserMessage = messages.filter((message) => message.role === 'user').at(-1);
docs = [
files = [
...docs,
...files,
...messages
...(lastUserMessage?.files?.filter((item) =>
.filter((message) => message?.files ?? null)
['doc', 'file', 'collection', 'web_search_results'].includes(item.type)
.map((message) =>
) ?? []),
message.files.filter((item) =>
...(responseMessage?.files?.filter((item) =>
['doc', 'collection', 'web_search_results'].includes(item.type)
['doc', 'file', 'collection', 'web_search_results'].includes(item.type)
)
) ?? [])
)
.flat(1)
].filter(
].filter(
// Remove duplicates
(item, index, array) =>
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
);
...
@@ -886,7 +932,7 @@
...
@@ -886,7 +932,7 @@
: undefined
: undefined
)}${
)}${
responseMessage?.userContext ?? null
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${
(
responseMessage?.userContext ??
[]).join('\n')
}`
? `\n\nUser Context:\n${responseMessage?.userContext ??
''
}`
: ''
: ''
}`
}`
}
}
...
@@ -936,11 +982,12 @@
...
@@ -936,11 +982,12 @@
frequency_penalty: $settings?.params?.frequency_penalty ?? undefined,
frequency_penalty: $settings?.params?.frequency_penalty ?? undefined,
max_tokens: $settings?.params?.max_tokens ?? undefined,
max_tokens: $settings?.params?.max_tokens ?? undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
docs: docs.length > 0 ? docs : undefined,
files: files.length > 0 ? files : undefined,
citations: docs.length > 0,
citations: files.length > 0 ? true : undefined,
chat_id: $chatId
chat_id: $chatId
},
},
`${
OPENAI_AP
I_BASE_URL}`
`${
WEBU
I_BASE_URL}
/api
`
);
);
// Wait until history/message have been updated
// Wait until history/message have been updated
...
@@ -1212,6 +1259,7 @@
...
@@ -1212,6 +1259,7 @@
const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
const responseMessage = history.messages[responseId];
const responseMessage = history.messages[responseId];
const userMessage = history.messages[parentId];
responseMessage.statusHistory = [
responseMessage.statusHistory = [
{
{
...
@@ -1222,7 +1270,7 @@
...
@@ -1222,7 +1270,7 @@
];
];
messages = messages;
messages = messages;
const prompt =
history.messages[parentId]
.content;
const prompt =
userMessage
.content;
let searchQuery = await generateSearchQuery(localStorage.token, model, messages, prompt).catch(
let searchQuery = await generateSearchQuery(localStorage.token, model, messages, prompt).catch(
(error) => {
(error) => {
console.log(error);
console.log(error);
...
@@ -1322,6 +1370,19 @@
...
@@ -1322,6 +1370,19 @@
? 'md:max-w-[calc(100%-260px)]'
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
: ''} w-full max-w-full flex flex-col"
>
>
{#if $settings?.backgroundImageUrl ?? null}
<div
class="absolute {$showSidebar
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$settings.backgroundImageUrl}) "
/>
<div
class="absolute top-0 left-0 w-full h-full bg-gradient-to-t from-white to-white/85 dark:from-gray-900 dark:to-[#171717]/90 z-0"
/>
{/if}
<Navbar
<Navbar
{title}
{title}
bind:selectedModels
bind:selectedModels
...
@@ -1333,7 +1394,9 @@
...
@@ -1333,7 +1394,9 @@
{#if $banners.length > 0 && messages.length === 0 && !$chatId && selectedModels.length <= 1}
{#if $banners.length > 0 && messages.length === 0 && !$chatId && selectedModels.length <= 1}
<div
<div
class="absolute top-[4.25rem] w-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}"
class="absolute top-[4.25rem] w-full {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} z-20"
>
>
<div class=" flex flex-col gap-1 w-full">
<div class=" flex flex-col gap-1 w-full">
{#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner}
{#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner}
...
@@ -1358,9 +1421,9 @@
...
@@ -1358,9 +1421,9 @@
</div>
</div>
{/if}
{/if}
<div class="flex flex-col flex-auto">
<div class="flex flex-col flex-auto
z-10
">
<div
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full"
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full
z-10
"
id="messages-container"
id="messages-container"
bind:this={messagesContainerElement}
bind:this={messagesContainerElement}
on:scroll={(e) => {
on:scroll={(e) => {
...
@@ -1399,6 +1462,7 @@
...
@@ -1399,6 +1462,7 @@
}
}
return a;
return a;
}, [])}
}, [])}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{selectedModels}
{selectedModels}
{messages}
{messages}
{submitPrompt}
{submitPrompt}
...
...
src/lib/components/chat/MessageInput.svelte
View file @
1eebb85f
...
@@ -15,11 +15,19 @@
...
@@ -15,11 +15,19 @@
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
import {
import {
processDocToVectorDB,
uploadDocToVectorDB,
uploadDocToVectorDB,
uploadWebToVectorDB,
uploadWebToVectorDB,
uploadYoutubeTranscriptionToVectorDB
uploadYoutubeTranscriptionToVectorDB
} from '$lib/apis/rag';
} from '$lib/apis/rag';
import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS, WEBUI_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files';
import {
SUPPORTED_FILE_TYPE,
SUPPORTED_FILE_EXTENSIONS,
WEBUI_BASE_URL,
WEBUI_API_BASE_URL
} from '$lib/constants';
import Prompts from './MessageInput/PromptCommands.svelte';
import Prompts from './MessageInput/PromptCommands.svelte';
import Suggestions from './MessageInput/Suggestions.svelte';
import Suggestions from './MessageInput/Suggestions.svelte';
...
@@ -35,6 +43,8 @@
...
@@ -35,6 +43,8 @@
const i18n = getContext('i18n');
const i18n = getContext('i18n');
export let transparentBackground = false;
export let submitPrompt: Function;
export let submitPrompt: Function;
export let stopResponse: Function;
export let stopResponse: Function;
...
@@ -84,44 +94,75 @@
...
@@ -84,44 +94,75 @@
element.scrollTop = element.scrollHeight;
element.scrollTop = element.scrollHeight;
};
};
const upload
Doc
= async (file) => {
const upload
FileHandler
= async (file) => {
console.log(file);
console.log(file);
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
const doc = {
if (res) {
type: 'doc',
console.log(res);
name: file.name,
const blob = new Blob([res.text], { type: 'text/plain' });
collection_name: '',
file = blobToFile(blob, `${file.name}.txt`);
upload_status: false,
}
error: ''
}
};
try {
files = [...files, doc];
if (['audio/mpeg', 'audio/wav'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
if (res) {
// Upload the file to the server
console.log(res);
const uploadedFile = await uploadFile(localStorage.token, file).catch((error) => {
const blob = new Blob([res.text], { type: 'text/plain' });
toast.error(error);
file = blobToFile(blob, `${file.name}.txt`);
return null;
}
});
if (uploadedFile) {
const fileItem = {
type: 'file',
file: uploadedFile,
id: uploadedFile.id,
url: `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`,
name: file.name,
collection_name: '',
status: 'uploaded',
error: ''
};
files = [...files, fileItem];
// TODO: Check if tools & functions have files support to skip this step to delegate file processing
// Default Upload to VectorDB
if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
processFileItem(fileItem);
} else {
toast.error(
$i18n.t(`Unknown file type '{{file_type}}'. Proceeding with the file upload anyway.`, {
file_type: file['type']
})
);
processFileItem(fileItem);
}
}
}
};
const res = await uploadDocToVectorDB(localStorage.token, '', file);
const processFileItem = async (fileItem) => {
try {
const res = await processDocToVectorDB(localStorage.token, fileItem.id);
if (res) {
if (res) {
doc.upload_status = true
;
fileItem.status = 'processed'
;
doc
.collection_name = res.collection_name;
fileItem
.collection_name = res.collection_name;
files = files;
files = files;
}
}
} catch (e) {
} catch (e) {
// Remove the failed doc from the files array
// Remove the failed doc from the files array
files = files.filter((f) => f.
name
!== file
.name
);
//
files = files.filter((f) => f.
id
!== file
Item.id
);
toast.error(e);
toast.error(e);
fileItem.status = 'processed';
files = files;
}
}
};
};
...
@@ -132,7 +173,7 @@
...
@@ -132,7 +173,7 @@
type: 'doc',
type: 'doc',
name: url,
name: url,
collection_name: '',
collection_name: '',
upload_
status: false,
status: false,
url: url,
url: url,
error: ''
error: ''
};
};
...
@@ -142,7 +183,7 @@
...
@@ -142,7 +183,7 @@
const res = await uploadWebToVectorDB(localStorage.token, '', url);
const res = await uploadWebToVectorDB(localStorage.token, '', url);
if (res) {
if (res) {
doc.
upload_
status =
true
;
doc.status =
'processed'
;
doc.collection_name = res.collection_name;
doc.collection_name = res.collection_name;
files = files;
files = files;
}
}
...
@@ -160,7 +201,7 @@
...
@@ -160,7 +201,7 @@
type: 'doc',
type: 'doc',
name: url,
name: url,
collection_name: '',
collection_name: '',
upload_
status: false,
status: false,
url: url,
url: url,
error: ''
error: ''
};
};
...
@@ -170,7 +211,7 @@
...
@@ -170,7 +211,7 @@
const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
if (res) {
if (res) {
doc.
upload_
status =
true
;
doc.status =
'processed'
;
doc.collection_name = res.collection_name;
doc.collection_name = res.collection_name;
files = files;
files = files;
}
}
...
@@ -228,19 +269,8 @@
...
@@ -228,19 +269,8 @@
];
];
};
};
reader.readAsDataURL(file);
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
} else {
} else {
toast.error(
uploadFileHandler(file);
$i18n.t(
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
{ file_type: file['type'] }
)
);
uploadDoc(file);
}
}
});
});
} else {
} else {
...
@@ -291,9 +321,11 @@
...
@@ -291,9 +321,11 @@
<div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
<div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
<div class="relative">
<div class="relative">
{#if autoScroll === false && messages.length > 0}
{#if autoScroll === false && messages.length > 0}
<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
<div
class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
>
<button
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full
pointer-events-auto
"
on:click={() => {
on:click={() => {
autoScroll = true;
autoScroll = true;
scrollToBottom();
scrollToBottom();
...
@@ -336,9 +368,9 @@
...
@@ -336,9 +368,9 @@
files = [
files = [
...files,
...files,
{
{
type: e?.detail?.type ?? '
doc
',
type: e?.detail?.type ?? '
file
',
...e.detail,
...e.detail,
upload_
status:
true
status:
'processed'
}
}
];
];
}}
}}
...
@@ -391,7 +423,7 @@
...
@@ -391,7 +423,7 @@
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-900">
<div class="
{transparentBackground ? 'bg-transparent' : '
bg-white dark:bg-gray-900
'}
">
<div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
<div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
<div class=" pb-2">
<div class=" pb-2">
<input
<input
...
@@ -407,8 +439,6 @@
...
@@ -407,8 +439,6 @@
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
if (visionCapableModels.length === 0) {
if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs'));
toast.error($i18n.t('Selected model(s) do not support image inputs'));
inputFiles = null;
filesInputElement.value = '';
return;
return;
}
}
let reader = new FileReader();
let reader = new FileReader();
...
@@ -420,30 +450,17 @@
...
@@ -420,30 +450,17 @@
url: `${event.target.result}`
url: `${event.target.result}`
}
}
];
];
inputFiles = null;
filesInputElement.value = '';
};
};
reader.readAsDataURL(file);
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
filesInputElement.value = '';
} else {
} else {
toast.error(
uploadFileHandler(file);
$i18n.t(
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
{ file_type: file['type'] }
)
);
uploadDoc(file);
filesInputElement.value = '';
}
}
});
});
} else {
} else {
toast.error($i18n.t(`File not found.`));
toast.error($i18n.t(`File not found.`));
}
}
filesInputElement.value = '';
}}
}}
/>
/>
...
@@ -517,12 +534,12 @@
...
@@ -517,12 +534,12 @@
</Tooltip>
</Tooltip>
{/if}
{/if}
</div>
</div>
{:else if
file.type === 'doc'
}
{:else if
['doc', 'file'].includes(file.type)
}
<div
<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"
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">
<div class="p-2.5 bg-red-400 text-white rounded-lg">
{#if file.
upload_status
}
{#if file.
status === 'processed'
}
<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"
...
...
src/lib/components/chat/MessageInput/CallOverlay.svelte
View file @
1eebb85f
<
script
lang
=
"ts"
>
<
script
lang
=
"ts"
>
import { config, settings, showCallOverlay } from '$lib/stores';
import
{
config
,
models
,
settings
,
showCallOverlay
}
from
'$lib/stores'
;
import
{
onMount
,
tick
,
getContext
}
from
'svelte'
;
import
{
onMount
,
tick
,
getContext
}
from
'svelte'
;
import
{
import
{
...
@@ -28,6 +28,8 @@
...
@@ -28,6 +28,8 @@
export
let
chatId
;
export
let
chatId
;
export
let
modelId
;
export
let
modelId
;
let
model
=
null
;
let
loading
=
false
;
let
loading
=
false
;
let
confirmed
=
false
;
let
confirmed
=
false
;
let
interrupted
=
false
;
let
interrupted
=
false
;
...
@@ -269,7 +271,7 @@
...
@@ -269,7 +271,7 @@
return
;
return
;
}
}
if (assistantSpeaking) {
if
(
assistantSpeaking
&&
!($settings?.voiceInterruption ?? false)
) {
//
Mute
the
audio
if
the
assistant
is
speaking
//
Mute
the
audio
if
the
assistant
is
speaking
analyser
.
maxDecibels
=
0
;
analyser
.
maxDecibels
=
0
;
analyser
.
minDecibels
=
-
1
;
analyser
.
minDecibels
=
-
1
;
...
@@ -507,6 +509,8 @@
...
@@ -507,6 +509,8 @@
};
};
onMount
(
async
()
=>
{
onMount
(
async
()
=>
{
model
=
$
models
.
find
((
m
)
=>
m
.
id
===
modelId
);
startRecording
();
startRecording
();
const
chatStartHandler
=
async
(
e
)
=>
{
const
chatStartHandler
=
async
(
e
)
=>
{
...
@@ -657,7 +661,13 @@
...
@@ -657,7 +661,13 @@
? ' size-16'
? ' size-16'
: rmsLevel * 100 > 1
: rmsLevel * 100 > 1
? 'size-14'
? 'size-14'
: 'size-12'} transition-all bg-black dark:bg-white rounded-full"
: 'size-12'} transition-all rounded-full {(model?.info?.meta
?.profile_image_url ?? '/favicon.png') !== '/favicon.png'
? ' bg-cover bg-center bg-no-repeat'
: 'bg-black dark:bg-white'} bg-black dark:bg-white"
style
={(
model
?.
info
?.
meta
?.
profile_image_url
??
'/favicon.png'
)
!== '/favicon.png'
?
`
background
-
image
:
url
(
'${model?.info?.meta?.profile_image_url}'
);`
:
''
}
/>
/>
{/
if
}
{/
if
}
<
!-- navbar -->
<
!-- navbar -->
...
@@ -732,7 +742,13 @@
...
@@ -732,7 +742,13 @@
? 'size-48'
? 'size-48'
: rmsLevel * 100 > 1
: rmsLevel * 100 > 1
? 'size-[11.5rem]'
? 'size-[11.5rem]'
: 'size-44'} transition-all bg-black dark:bg-white rounded-full"
: 'size-44'} transition-all rounded-full {(model?.info?.meta
?.profile_image_url ?? '/favicon.png') !== '/favicon.png'
? ' bg-cover bg-center bg-no-repeat'
: 'bg-black dark:bg-white'} "
style
={(
model
?.
info
?.
meta
?.
profile_image_url
??
'/favicon.png'
)
!== '/favicon.png'
?
`
background
-
image
:
url
(
'${model?.info?.meta?.profile_image_url}'
);`
:
''
}
/>
/>
{/
if
}
{/
if
}
</
button
>
</
button
>
...
...
src/lib/components/chat/MessageInput/Documents.svelte
View file @
1eebb85f
...
@@ -43,11 +43,11 @@
...
@@ -43,11 +43,11 @@
];
];
$: filteredCollections = collections
$: filteredCollections = collections
.filter((collection) =>
collection.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? ''
))
.filter((collection) =>
findByName(collection, prompt
))
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => a.name.localeCompare(b.name));
$: filteredDocs = $documents
$: filteredDocs = $documents
.filter((doc) =>
doc.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? ''
))
.filter((doc) =>
findByName(doc, prompt
))
.sort((a, b) => a.title.localeCompare(b.title));
.sort((a, b) => a.title.localeCompare(b.title));
$: filteredItems = [...filteredCollections, ...filteredDocs];
$: filteredItems = [...filteredCollections, ...filteredDocs];
...
@@ -58,6 +58,15 @@
...
@@ -58,6 +58,15 @@
console.log(filteredCollections);
console.log(filteredCollections);
}
}
type ObjectWithName = {
name: string;
};
const findByName = (obj: ObjectWithName, prompt: string) => {
const name = obj.name.toLowerCase();
return name.includes(prompt.toLowerCase().split(' ')?.at(0)?.substring(1) ?? '');
};
export const selectUp = () => {
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
selectedIdx = Math.max(0, selectedIdx - 1);
};
};
...
@@ -101,7 +110,7 @@
...
@@ -101,7 +110,7 @@
</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')}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0
z-10
">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">#</div>
<div class=" text-lg font-semibold mt-2">#</div>
...
...
src/lib/components/chat/MessageInput/Models.svelte
View file @
1eebb85f
...
@@ -21,7 +21,9 @@
...
@@ -21,7 +21,9 @@
let filteredModels = [];
let filteredModels = [];
$: filteredModels = $models
$: filteredModels = $models
.filter((p) => p.name.includes(prompt.split(' ')?.at(0)?.substring(1) ?? ''))
.filter((p) =>
p.name.toLowerCase().includes(prompt.toLowerCase().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) {
...
@@ -133,7 +135,7 @@
...
@@ -133,7 +135,7 @@
{#if prompt.charAt(0) === '@'}
{#if prompt.charAt(0) === '@'}
{#if filteredModels.length > 0}
{#if filteredModels.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0
z-10
">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">@</div>
<div class=" text-lg font-semibold mt-2">@</div>
...
...
src/lib/components/chat/MessageInput/PromptCommands.svelte
View file @
1eebb85f
...
@@ -12,7 +12,7 @@
...
@@ -12,7 +12,7 @@
let filteredPromptCommands = [];
let filteredPromptCommands = [];
$: filteredPromptCommands = $prompts
$: filteredPromptCommands = $prompts
.filter((p) => p.command.includes(prompt))
.filter((p) => p.command.
toLowerCase().
includes(prompt
.toLowerCase()
))
.sort((a, b) => a.title.localeCompare(b.title));
.sort((a, b) => a.title.localeCompare(b.title));
$: if (prompt) {
$: if (prompt) {
...
@@ -88,7 +88,7 @@
...
@@ -88,7 +88,7 @@
</script>
</script>
{#if filteredPromptCommands.length > 0}
{#if filteredPromptCommands.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0
z-10
">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">/</div>
<div class=" text-lg font-semibold mt-2">/</div>
...
...
src/lib/components/chat/MessageInput/Suggestions.svelte
View file @
1eebb85f
...
@@ -62,7 +62,7 @@
...
@@ -62,7 +62,7 @@
<div class="text-sm text-gray-600 font-normal line-clamp-2">{prompt.title[1]}</div>
<div class="text-sm text-gray-600 font-normal line-clamp-2">{prompt.title[1]}</div>
{:else}
{:else}
<div
<div
class="
self-center
text-sm font-medium dark:text-gray-300 dark:group-hover:text-gray-100 transition line-clamp-2"
class=" text-sm font-medium dark:text-gray-300 dark:group-hover:text-gray-100 transition line-clamp-2"
>
>
{prompt.content}
{prompt.content}
</div>
</div>
...
...
src/lib/components/chat/Messages.svelte
View file @
1eebb85f
...
@@ -385,7 +385,7 @@
...
@@ -385,7 +385,7 @@
{/each}
{/each}
{#if bottomPadding}
{#if bottomPadding}
<div class=" pb-
20
" />
<div class=" pb-
6
" />
{/if}
{/if}
{/key}
{/key}
</div>
</div>
...
...
src/lib/components/chat/Messages/CodeBlock.svelte
View file @
1eebb85f
...
@@ -203,8 +203,18 @@ __builtins__.input = input`);
...
@@ -203,8 +203,18 @@ __builtins__.input = input`);
};
};
};
};
let debounceTimeout;
$: if (code) {
$: if (code) {
highlightedCode = hljs.highlightAuto(code, hljs.getLanguage(lang)?.aliases).value || code;
// Function to perform the code highlighting
const highlightCode = () => {
highlightedCode = hljs.highlightAuto(code, hljs.getLanguage(lang)?.aliases).value || code;
};
// Clear the previous timeout if it exists
clearTimeout(debounceTimeout);
// Set a new timeout to debounce the code highlighting
debounceTimeout = setTimeout(highlightCode, 10);
}
}
</script>
</script>
...
...
src/lib/components/chat/Messages/Placeholder.svelte
View file @
1eebb85f
...
@@ -9,6 +9,7 @@
...
@@ -9,6 +9,7 @@
import Suggestions from '../MessageInput/Suggestions.svelte';
import Suggestions from '../MessageInput/Suggestions.svelte';
import { sanitizeResponseContent } from '$lib/utils';
import { sanitizeResponseContent } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n');
...
@@ -32,7 +33,7 @@
...
@@ -32,7 +33,7 @@
</script>
</script>
{#key mounted}
{#key mounted}
<div class="m-auto w-full max-w-6xl px-8 lg:px-2
4
pb-10">
<div class="m-auto w-full max-w-6xl px-8 lg:px-2
0
pb-10">
<div class="flex justify-start">
<div class="flex justify-start">
<div class="flex -space-x-4 mb-1" in:fade={{ duration: 200 }}>
<div class="flex -space-x-4 mb-1" in:fade={{ duration: 200 }}>
{#each models as model, modelIdx}
{#each models as model, modelIdx}
...
@@ -41,14 +42,23 @@
...
@@ -41,14 +42,23 @@
selectedModelIdx = modelIdx;
selectedModelIdx = modelIdx;
}}
}}
>
>
<img
<Tooltip
crossorigin="anonymous"
content={marked.parse(
src={model?.info?.meta?.profile_image_url ??
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
)}
class=" size-[2.7rem] rounded-full border-[1px] border-gray-200 dark:border-none"
placement="right"
alt="logo"
>
draggable="false"
<img
/>
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-[2.7rem] rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo"
draggable="false"
/>
</Tooltip>
</button>
</button>
{/each}
{/each}
</div>
</div>
...
...
src/lib/components/chat/Messages/ProfileImage.svelte
View file @
1eebb85f
...
@@ -2,10 +2,12 @@
...
@@ -2,10 +2,12 @@
import { settings } from '$lib/stores';
import { settings } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_BASE_URL } from '$lib/constants';
export let className = 'size-8';
export let src = '/user.png';
export let src = '/user.png';
</script>
</script>
<div class={($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}>
<div class={
`flex-shrink-0 ${
($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}
`}
>
<img
<img
crossorigin="anonymous"
crossorigin="anonymous"
src={src.startsWith(WEBUI_BASE_URL) ||
src={src.startsWith(WEBUI_BASE_URL) ||
...
@@ -14,7 +16,7 @@
...
@@ -14,7 +16,7 @@
src.startsWith('/')
src.startsWith('/')
? src
? src
: `/user.png`}
: `/user.png`}
class="
w-8
object-cover rounded-full"
class="
{className}
object-cover rounded-full
-translate-y-[1px]
"
alt="profile"
alt="profile"
draggable="false"
draggable="false"
/>
/>
...
...
Prev
1
2
3
4
5
6
7
8
9
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment