Merge branch 'dev' into main
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
{#if content}
|
||||
{content}
|
||||
{:else}
|
||||
{$i18n.t('Drop any files here to add to the conversation')}
|
||||
{$i18n.t('Drop any files here to upload')}
|
||||
{/if}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Functions')} | {$WEBUI_NAME}
|
||||
{$i18n.t('Functions')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ class Pipe:
|
||||
<div class="flex-1 pr-3">
|
||||
<div class="text-xs text-gray-500 line-clamp-2">
|
||||
<span class=" font-semibold dark:text-gray-200">{$i18n.t('Warning:')}</span>
|
||||
{$i18n.t('Functions allow arbitrary code execution')} <br />—
|
||||
{$i18n.t('Functions allow arbitrary code execution.')} <br />—
|
||||
<span class=" font-medium dark:text-gray-400"
|
||||
>{$i18n.t(`don't install random functions from sources you don't trust.`)}</span
|
||||
>
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
let STT_AZURE_API_KEY = '';
|
||||
let STT_AZURE_REGION = '';
|
||||
let STT_AZURE_LOCALES = '';
|
||||
let STT_AZURE_BASE_URL = '';
|
||||
let STT_AZURE_MAX_SPEAKERS = '';
|
||||
let STT_DEEPGRAM_API_KEY = '';
|
||||
|
||||
let STT_WHISPER_MODEL_LOADING = false;
|
||||
@@ -114,7 +116,9 @@
|
||||
DEEPGRAM_API_KEY: STT_DEEPGRAM_API_KEY,
|
||||
AZURE_API_KEY: STT_AZURE_API_KEY,
|
||||
AZURE_REGION: STT_AZURE_REGION,
|
||||
AZURE_LOCALES: STT_AZURE_LOCALES
|
||||
AZURE_LOCALES: STT_AZURE_LOCALES,
|
||||
AZURE_BASE_URL: STT_AZURE_BASE_URL,
|
||||
AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS
|
||||
}
|
||||
});
|
||||
|
||||
@@ -157,6 +161,8 @@
|
||||
STT_AZURE_API_KEY = res.stt.AZURE_API_KEY;
|
||||
STT_AZURE_REGION = res.stt.AZURE_REGION;
|
||||
STT_AZURE_LOCALES = res.stt.AZURE_LOCALES;
|
||||
STT_AZURE_BASE_URL = res.stt.AZURE_BASE_URL;
|
||||
STT_AZURE_MAX_SPEAKERS = res.stt.AZURE_MAX_SPEAKERS;
|
||||
STT_DEEPGRAM_API_KEY = res.stt.DEEPGRAM_API_KEY;
|
||||
}
|
||||
|
||||
@@ -288,6 +294,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Base URL')}</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
bind:value={STT_AZURE_BASE_URL}
|
||||
placeholder={$i18n.t('(leave blank for Azure Commercial URL auto-generation)')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Max Speakers')}</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
bind:value={STT_AZURE_MAX_SPEAKERS}
|
||||
placeholder={$i18n.t('e.g., 3, 4, 5 (leave blank for default)')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if STT_ENGINE === ''}
|
||||
<div>
|
||||
|
||||
@@ -161,6 +161,16 @@
|
||||
toast.error($i18n.t('Docling Server URL required.'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
|
||||
((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
|
||||
(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
|
||||
) {
|
||||
toast.error(
|
||||
$i18n.t('Both Docling OCR Engine and Language(s) must be provided or both left empty.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' &&
|
||||
@@ -326,6 +336,18 @@
|
||||
bind:value={RAGConfig.DOCLING_SERVER_URL}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full mt-2">
|
||||
<input
|
||||
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Enter Docling OCR Engine')}
|
||||
bind:value={RAGConfig.DOCLING_OCR_ENGINE}
|
||||
/>
|
||||
<input
|
||||
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
|
||||
bind:value={RAGConfig.DOCLING_OCR_LANG}
|
||||
/>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
|
||||
<div class="my-0.5 flex gap-2 pr-2">
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
|
||||
import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
|
||||
import {
|
||||
getAdminConfig,
|
||||
getLdapConfig,
|
||||
@@ -601,6 +601,14 @@
|
||||
<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Notes')} ({$i18n.t('Beta')})
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_NOTES} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Channels')} ({$i18n.t('Beta')})
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
let webSearchEngines = [
|
||||
'searxng',
|
||||
'yacy',
|
||||
'google_pse',
|
||||
'brave',
|
||||
'kagi',
|
||||
@@ -30,9 +31,11 @@
|
||||
'bing',
|
||||
'exa',
|
||||
'perplexity',
|
||||
'sougou'
|
||||
'sougou',
|
||||
'firecrawl',
|
||||
'external'
|
||||
];
|
||||
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily'];
|
||||
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
|
||||
|
||||
let webConfig = null;
|
||||
|
||||
@@ -143,6 +146,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Yacy Instance URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Yacy URL (e.g. http://yacy.example.com:8090)')}
|
||||
bind:value={webConfig.YACY_QUERY_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div class="flex gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Yacy Username')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
placeholder={$i18n.t('Enter Yacy Username')}
|
||||
bind:value={webConfig.YACY_USERNAME}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Yacy Password')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Yacy Password')}
|
||||
bind:value={webConfig.YACY_PASSWORD}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'google_pse'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
@@ -431,6 +481,68 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'firecrawl'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Firecrawl API Base URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Firecrawl API Base URL')}
|
||||
bind:value={webConfig.FIRECRAWL_API_BASE_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Firecrawl API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Firecrawl API Key')}
|
||||
bind:value={webConfig.FIRECRAWL_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'external'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('External Web Search URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter External Web Search URL')}
|
||||
bind:value={webConfig.EXTERNAL_WEB_SEARCH_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('External Web Search API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter External Web Search API Key')}
|
||||
bind:value={webConfig.EXTERNAL_WEB_SEARCH_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -588,7 +700,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl'}
|
||||
{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl' && webConfig.WEB_SEARCH_ENGINE !== 'firecrawl'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
@@ -652,6 +764,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if webConfig.WEB_LOADER_ENGINE === 'external'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('External Web Loader URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter External Web Loader URL')}
|
||||
bind:value={webConfig.EXTERNAL_WEB_LOADER_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('External Web Loader API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter External Web Loader API Key')}
|
||||
bind:value={webConfig.EXTERNAL_WEB_LOADER_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
|
||||
@@ -5,32 +5,19 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
import { getUsers } from '$lib/apis/users';
|
||||
|
||||
import UserList from './Users/UserList.svelte';
|
||||
import Groups from './Users/Groups.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let users = [];
|
||||
|
||||
let selectedTab = 'overview';
|
||||
let loaded = false;
|
||||
|
||||
$: if (selectedTab) {
|
||||
getUsersHandler();
|
||||
}
|
||||
|
||||
const getUsersHandler = async () => {
|
||||
users = await getUsers(localStorage.token);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
} else {
|
||||
users = await getUsers(localStorage.token);
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
|
||||
const containerElement = document.getElementById('users-tabs-container');
|
||||
@@ -102,9 +89,9 @@
|
||||
|
||||
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
|
||||
{#if selectedTab === 'overview'}
|
||||
<UserList {users} />
|
||||
<UserList />
|
||||
{:else if selectedTab === 'groups'}
|
||||
<Groups {users} />
|
||||
<Groups />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,13 +23,18 @@
|
||||
import GroupItem from './Groups/GroupItem.svelte';
|
||||
import AddGroupModal from './Groups/AddGroupModal.svelte';
|
||||
import { createNewGroup, getGroups } from '$lib/apis/groups';
|
||||
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
|
||||
import {
|
||||
getUserDefaultPermissions,
|
||||
getAllUsers,
|
||||
updateUserDefaultPermissions
|
||||
} from '$lib/apis/users';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let loaded = false;
|
||||
|
||||
export let users = [];
|
||||
let users = [];
|
||||
let total = 0;
|
||||
|
||||
let groups = [];
|
||||
let filteredGroups;
|
||||
@@ -63,6 +68,8 @@
|
||||
file_upload: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
share: true,
|
||||
export: true,
|
||||
stt: true,
|
||||
tts: true,
|
||||
call: true,
|
||||
@@ -74,7 +81,8 @@
|
||||
direct_tool_servers: false,
|
||||
web_search: true,
|
||||
image_generation: true,
|
||||
code_interpreter: true
|
||||
code_interpreter: true,
|
||||
notes: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,10 +124,22 @@
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
} else {
|
||||
await setGroups();
|
||||
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getAllUsers(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
users = res.users;
|
||||
total = res.total;
|
||||
}
|
||||
|
||||
await setGroups();
|
||||
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
|
||||
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
file_upload: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
share: true,
|
||||
export: true,
|
||||
stt: true,
|
||||
tts: true,
|
||||
call: true,
|
||||
@@ -35,7 +37,8 @@
|
||||
direct_tool_servers: false,
|
||||
web_search: true,
|
||||
image_generation: true,
|
||||
code_interpreter: true
|
||||
code_interpreter: true,
|
||||
notes: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -276,6 +279,22 @@
|
||||
<Switch bind:state={permissions.chat.edit} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Chat Share')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.chat.share} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Chat Export')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.chat.export} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Speech to Text')}
|
||||
@@ -362,5 +381,13 @@
|
||||
|
||||
<Switch bind:state={permissions.features.code_interpreter} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Notes')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.features.notes} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
|
||||
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
@@ -33,19 +35,33 @@
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let users = [];
|
||||
|
||||
let search = '';
|
||||
let selectedUser = null;
|
||||
|
||||
let page = 1;
|
||||
|
||||
let users = [];
|
||||
let total = 0;
|
||||
|
||||
let query = '';
|
||||
let orderBy = 'created_at'; // default sort key
|
||||
let direction = 'asc'; // default sort order
|
||||
|
||||
let selectedUser = null;
|
||||
|
||||
let showDeleteConfirmDialog = false;
|
||||
let showAddUserModal = false;
|
||||
|
||||
let showUserChatsModal = false;
|
||||
let showEditUserModal = false;
|
||||
let showUpdateRoleModal = false;
|
||||
|
||||
const onUpdateRole = (user) => {
|
||||
if (user.role === 'user') {
|
||||
updateRoleHandler(user.id, 'admin');
|
||||
} else if (user.role === 'pending') {
|
||||
updateRoleHandler(user.id, 'user');
|
||||
} else {
|
||||
updateRoleHandler(user.id, 'pending');
|
||||
}
|
||||
};
|
||||
const updateRoleHandler = async (id, role) => {
|
||||
const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
@@ -53,7 +69,7 @@
|
||||
});
|
||||
|
||||
if (res) {
|
||||
users = await getUsers(localStorage.token);
|
||||
getUserList();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,42 +78,51 @@
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
// if the user is deleted and the current page has only one user, go back to the previous page
|
||||
if (users.length === 1 && page > 1) {
|
||||
page -= 1;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
users = await getUsers(localStorage.token);
|
||||
getUserList();
|
||||
}
|
||||
};
|
||||
|
||||
let sortKey = 'created_at'; // default sort key
|
||||
let sortOrder = 'asc'; // default sort order
|
||||
|
||||
function setSortKey(key) {
|
||||
if (sortKey === key) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
const setSortKey = (key) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortOrder = 'asc';
|
||||
orderBy = key;
|
||||
direction = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const getUserList = async () => {
|
||||
try {
|
||||
const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
users = res.users;
|
||||
total = res.total;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (page) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
let filteredUsers;
|
||||
|
||||
$: filteredUsers = users
|
||||
.filter((user) => {
|
||||
if (search === '') {
|
||||
return true;
|
||||
} else {
|
||||
let name = user.name.toLowerCase();
|
||||
let email = user.email.toLowerCase();
|
||||
const query = search.toLowerCase();
|
||||
return name.includes(query) || email.includes(query);
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
})
|
||||
.slice((page - 1) * 20, page * 20);
|
||||
$: if (query !== null && orderBy && direction) {
|
||||
getUserList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -107,13 +132,28 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<RoleUpdateConfirmDialog
|
||||
bind:show={showUpdateRoleModal}
|
||||
on:confirm={() => {
|
||||
onUpdateRole(selectedUser);
|
||||
}}
|
||||
message={$i18n.t(`Are you sure you want to update this user\'s role to **{{ROLE}}**?`, {
|
||||
ROLE:
|
||||
selectedUser?.role === 'user'
|
||||
? 'admin'
|
||||
: selectedUser?.role === 'pending'
|
||||
? 'user'
|
||||
: 'pending'
|
||||
})}
|
||||
/>
|
||||
|
||||
{#key selectedUser}
|
||||
<EditUserModal
|
||||
bind:show={showEditUserModal}
|
||||
{selectedUser}
|
||||
sessionUser={$user}
|
||||
on:save={async () => {
|
||||
users = await getUsers(localStorage.token);
|
||||
getUserList();
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
@@ -121,7 +161,7 @@
|
||||
<AddUserModal
|
||||
bind:show={showAddUserModal}
|
||||
on:save={async () => {
|
||||
users = await getUsers(localStorage.token);
|
||||
getUserList();
|
||||
}}
|
||||
/>
|
||||
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
|
||||
@@ -149,19 +189,19 @@
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
||||
|
||||
{#if ($config?.license_metadata?.seats ?? null) !== null}
|
||||
{#if users.length > $config?.license_metadata?.seats}
|
||||
{#if total > $config?.license_metadata?.seats}
|
||||
<span class="text-lg font-medium text-red-500"
|
||||
>{users.length} of {$config?.license_metadata?.seats}
|
||||
>{total} of {$config?.license_metadata?.seats}
|
||||
<span class="text-sm font-normal">available users</span></span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
|
||||
>{users.length} of {$config?.license_metadata?.seats}
|
||||
>{total} of {$config?.license_metadata?.seats}
|
||||
<span class="text-sm font-normal">available users</span></span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
|
||||
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -184,7 +224,7 @@
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={search}
|
||||
bind:value={query}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
@@ -223,9 +263,9 @@
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Role')}
|
||||
|
||||
{#if sortKey === 'role'}
|
||||
{#if orderBy === 'role'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -246,9 +286,9 @@
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Name')}
|
||||
|
||||
{#if sortKey === 'name'}
|
||||
{#if orderBy === 'name'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -269,9 +309,9 @@
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Email')}
|
||||
|
||||
{#if sortKey === 'email'}
|
||||
{#if orderBy === 'email'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -293,9 +333,9 @@
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Last Active')}
|
||||
|
||||
{#if sortKey === 'last_active_at'}
|
||||
{#if orderBy === 'last_active_at'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -315,9 +355,9 @@
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Created at')}
|
||||
{#if sortKey === 'created_at'}
|
||||
{#if orderBy === 'created_at'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -339,9 +379,9 @@
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('OAuth ID')}
|
||||
|
||||
{#if sortKey === 'oauth_sub'}
|
||||
{#if orderBy === 'oauth_sub'}
|
||||
<span class="font-normal"
|
||||
>{#if sortOrder === 'asc'}
|
||||
>{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
@@ -359,19 +399,14 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="">
|
||||
{#each filteredUsers as user, userIdx}
|
||||
{#each users as user, userIdx}
|
||||
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
||||
<td class="px-3 py-1 min-w-[7rem] w-28">
|
||||
<button
|
||||
class=" translate-y-0.5"
|
||||
on:click={() => {
|
||||
if (user.role === 'user') {
|
||||
updateRoleHandler(user.id, 'admin');
|
||||
} else if (user.role === 'pending') {
|
||||
updateRoleHandler(user.id, 'user');
|
||||
} else {
|
||||
updateRoleHandler(user.id, 'pending');
|
||||
}
|
||||
selectedUser = user;
|
||||
showUpdateRoleModal = true;
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
@@ -486,10 +521,10 @@
|
||||
ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
|
||||
</div>
|
||||
|
||||
<Pagination bind:page count={users.length} />
|
||||
<Pagination bind:page count={total} perPage={10} />
|
||||
|
||||
{#if !$config?.license_metadata}
|
||||
{#if users.length > 50}
|
||||
{#if total > 50}
|
||||
<div class="text-sm">
|
||||
<Markdown
|
||||
content={`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import { generateInitialsImage } from '$lib/utils';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -47,7 +48,8 @@
|
||||
_user.name,
|
||||
_user.email,
|
||||
_user.password,
|
||||
_user.role
|
||||
_user.role,
|
||||
generateInitialsImage(_user.name)
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
@@ -83,7 +85,8 @@
|
||||
columns[0],
|
||||
columns[1],
|
||||
columns[2],
|
||||
columns[3].toLowerCase()
|
||||
columns[3].toLowerCase(),
|
||||
generateInitialsImage(columns[0])
|
||||
).catch((error) => {
|
||||
toast.error(`Row ${idx + 1}: ${error}`);
|
||||
return null;
|
||||
@@ -109,7 +112,7 @@
|
||||
stopLoading();
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
reader.readAsText(file, 'utf-8');
|
||||
} else {
|
||||
toast.error($i18n.t('File not found.'));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<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('Edit User')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
@@ -65,9 +65,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<hr class="border-gray-100 dark:border-gray-850" />
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full 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"
|
||||
@@ -75,7 +74,7 @@
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center rounded-md py-2 px-4 w-full">
|
||||
<div class=" flex items-center rounded-md px-5 py-2 w-full">
|
||||
<div class=" self-center mr-5">
|
||||
<img
|
||||
src={selectedUser.profile_image_url}
|
||||
@@ -94,59 +93,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-3 w-full" />
|
||||
<div class=" px-5 pt-3 pb-5">
|
||||
<div class=" flex flex-col space-y-1.5">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
|
||||
|
||||
<div class=" flex flex-col space-y-1.5">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
|
||||
type="email"
|
||||
bind:value={_user.email}
|
||||
placeholder={$i18n.t('Enter Your Email')}
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={_user.id == sessionUser.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
|
||||
type="email"
|
||||
bind:value={_user.email}
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={_user.id == sessionUser.id}
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm text-sm bg-transparent outline-hidden"
|
||||
type="text"
|
||||
bind:value={_user.name}
|
||||
placeholder={$i18n.t('Enter Your Name')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm text-sm bg-transparent outline-hidden"
|
||||
type="password"
|
||||
placeholder={$i18n.t('Enter New Password')}
|
||||
bind:value={_user.password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
|
||||
type="text"
|
||||
bind:value={_user.name}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
|
||||
type="password"
|
||||
bind:value={_user.password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
|
||||
<title>#{channel?.name ?? 'Channel'} • Open WebUI</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
@@ -262,7 +262,7 @@
|
||||
{#if threadId !== null}
|
||||
<Drawer
|
||||
show={threadId !== null}
|
||||
on:close={() => {
|
||||
onClose={() => {
|
||||
threadId = null;
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -357,14 +357,14 @@
|
||||
{#if recording}
|
||||
<VoiceRecording
|
||||
bind:recording
|
||||
on:cancel={async () => {
|
||||
onCancel={async () => {
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById(`chat-input-${id}`)?.focus();
|
||||
}}
|
||||
on:confirm={async (e) => {
|
||||
const { text, filename } = e.detail;
|
||||
onConfirm={async (data) => {
|
||||
const { text, filename } = data;
|
||||
content = `${content}${text} `;
|
||||
recording = false;
|
||||
|
||||
|
||||
@@ -236,9 +236,11 @@
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const messageElement = document.getElementById(`message-${message.id}`);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const messageElement = document.getElementById(`message-${message.id}`);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
@@ -1919,7 +1921,7 @@
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$chatTitle
|
||||
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
|
||||
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
|
||||
: `${$WEBUI_NAME}`}
|
||||
</title>
|
||||
</svelte:head>
|
||||
@@ -2038,7 +2040,7 @@
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
onChange={(input) => {
|
||||
if (input.prompt) {
|
||||
if (input.prompt !== null) {
|
||||
localStorage.setItem(`chat-input-${$chatId}`, JSON.stringify(input));
|
||||
} else {
|
||||
localStorage.removeItem(`chat-input-${$chatId}`);
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
{#if $showControls}
|
||||
<Drawer
|
||||
show={$showControls}
|
||||
on:close={() => {
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -395,39 +395,37 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full relative">
|
||||
{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || ($settings?.webSearch ?? false) === 'always' || imageGenerationEnabled || codeInterpreterEnabled}
|
||||
{#if atSelectedModel !== undefined}
|
||||
<div
|
||||
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
|
||||
>
|
||||
{#if atSelectedModel !== undefined}
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
alt="model profile"
|
||||
class="size-3.5 max-w-[28px] object-cover rounded-full"
|
||||
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
||||
?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG'
|
||||
? `/doge.png`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
/>
|
||||
<div class="translate-y-[0.5px]">
|
||||
Talking to <span class=" font-medium">{atSelectedModel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center dark:text-gray-500"
|
||||
on:click={() => {
|
||||
atSelectedModel = undefined;
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
alt="model profile"
|
||||
class="size-3.5 max-w-[28px] object-cover rounded-full"
|
||||
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
||||
?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG'
|
||||
? `/doge.png`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
/>
|
||||
<div class="translate-y-[0.5px]">
|
||||
Talking to <span class=" font-medium">{atSelectedModel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center dark:text-gray-500"
|
||||
on:click={() => {
|
||||
atSelectedModel = undefined;
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -481,14 +479,14 @@
|
||||
{#if recording}
|
||||
<VoiceRecording
|
||||
bind:recording
|
||||
on:cancel={async () => {
|
||||
onCancel={async () => {
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}}
|
||||
on:confirm={async (e) => {
|
||||
const { text, filename } = e.detail;
|
||||
onConfirm={async (data) => {
|
||||
const { text, filename } = data;
|
||||
prompt = `${prompt}${text} `;
|
||||
|
||||
recording = false;
|
||||
@@ -1063,9 +1061,9 @@
|
||||
);
|
||||
}
|
||||
}}
|
||||
uploadOneDriveHandler={async () => {
|
||||
uploadOneDriveHandler={async (authorityType) => {
|
||||
try {
|
||||
const fileData = await pickAndDownloadFile();
|
||||
const fileData = await pickAndDownloadFile(authorityType);
|
||||
if (fileData) {
|
||||
const file = new File([fileData.blob], fileData.name, {
|
||||
type: fileData.blob.type || 'application/octet-stream'
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
<div class=" text-gray-500 text-xs mt-1 mb-2">
|
||||
{$i18n.t('No files found.')}
|
||||
{$i18n.t('File not found.')}
|
||||
</div>
|
||||
{/if}
|
||||
</div> -->
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
{/if}
|
||||
|
||||
<Tooltip
|
||||
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
|
||||
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
|
||||
className="w-full"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
@@ -173,7 +173,7 @@
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
|
||||
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
|
||||
className="w-full"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
@@ -229,94 +229,119 @@
|
||||
{/if}
|
||||
|
||||
{#if $config?.features?.enable_onedrive_integration}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
uploadOneDriveHandler();
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
|
||||
<mask
|
||||
id="mask0_87_7796"
|
||||
style="mask-type:alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="6"
|
||||
width="32"
|
||||
height="20"
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
|
||||
<mask
|
||||
id="mask0_87_7796"
|
||||
style="mask-type:alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="6"
|
||||
width="32"
|
||||
height="20"
|
||||
>
|
||||
<path
|
||||
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_87_7796)">
|
||||
<path
|
||||
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
|
||||
fill="url(#paint0_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
|
||||
fill="url(#paint1_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
|
||||
fill="url(#paint2_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
|
||||
fill="url(#paint3_linear_87_7796)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_87_7796"
|
||||
x1="4.42591"
|
||||
y1="24.6668"
|
||||
x2="27.2309"
|
||||
y2="23.2764"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#2086B8" />
|
||||
<stop offset="1" stop-color="#46D3F6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_87_7796"
|
||||
x1="23.8302"
|
||||
y1="19.6668"
|
||||
x2="30.2108"
|
||||
y2="15.2082"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#1694DB" />
|
||||
<stop offset="1" stop-color="#62C3FE" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_87_7796"
|
||||
x1="8.51037"
|
||||
y1="7.33333"
|
||||
x2="23.3335"
|
||||
y2="15.9348"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#0D3D78" />
|
||||
<stop offset="1" stop-color="#063B83" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_87_7796"
|
||||
x1="-0.340429"
|
||||
y1="19.9998"
|
||||
x2="14.5634"
|
||||
y2="14.4649"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#16589B" />
|
||||
<stop offset="1" stop-color="#1464B7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
side={$mobile ? 'bottom' : 'right'}
|
||||
sideOffset={$mobile ? 5 : 0}
|
||||
alignOffset={$mobile ? 0 : -8}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
uploadOneDriveHandler('personal');
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_87_7796)">
|
||||
<path
|
||||
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
|
||||
fill="url(#paint0_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
|
||||
fill="url(#paint1_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
|
||||
fill="url(#paint2_linear_87_7796)"
|
||||
/>
|
||||
<path
|
||||
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
|
||||
fill="url(#paint3_linear_87_7796)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_87_7796"
|
||||
x1="4.42591"
|
||||
y1="24.6668"
|
||||
x2="27.2309"
|
||||
y2="23.2764"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#2086B8" />
|
||||
<stop offset="1" stop-color="#46D3F6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_87_7796"
|
||||
x1="23.8302"
|
||||
y1="19.6668"
|
||||
x2="30.2108"
|
||||
y2="15.2082"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#1694DB" />
|
||||
<stop offset="1" stop-color="#62C3FE" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_87_7796"
|
||||
x1="8.51037"
|
||||
y1="7.33333"
|
||||
x2="23.3335"
|
||||
y2="15.9348"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#0D3D78" />
|
||||
<stop offset="1" stop-color="#063B83" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_87_7796"
|
||||
x1="-0.340429"
|
||||
y1="19.9998"
|
||||
x2="14.5634"
|
||||
y2="14.4649"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#16589B" />
|
||||
<stop offset="1" stop-color="#1464B7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
|
||||
</DropdownMenu.Item>
|
||||
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
uploadOneDriveHandler('organizations');
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (work/school)')}</div>
|
||||
<div class="text-xs text-gray-500">Includes SharePoint</div>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
|
||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||
import { config, settings } from '$lib/stores';
|
||||
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
|
||||
|
||||
import { transcribeAudio } from '$lib/apis/audio';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
dayjs.extend(LocalizedFormat);
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let recording = false;
|
||||
export let transcribe = true;
|
||||
export let displayMedia = false;
|
||||
|
||||
export let className = ' p-2.5 w-full max-w-full';
|
||||
|
||||
export let onCancel = () => {};
|
||||
export let onConfirm = (data) => {};
|
||||
|
||||
let loading = false;
|
||||
let confirmed = false;
|
||||
|
||||
@@ -130,45 +138,73 @@
|
||||
detectSound();
|
||||
};
|
||||
|
||||
const transcribeHandler = async (audioBlob) => {
|
||||
const onStopHandler = async (audioBlob, ext: string = 'wav') => {
|
||||
// Create a blob from the audio chunks
|
||||
|
||||
await tick();
|
||||
const file = blobToFile(audioBlob, 'recording.wav');
|
||||
const file = blobToFile(audioBlob, `Recording-${dayjs().format('L LT')}.${ext}`);
|
||||
|
||||
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (transcribe) {
|
||||
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
|
||||
// with web stt, we don't need to send the file to the server
|
||||
return;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
console.log(res);
|
||||
dispatch('confirm', res);
|
||||
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
console.log(res);
|
||||
onConfirm(res);
|
||||
}
|
||||
} else {
|
||||
onConfirm({
|
||||
file: file,
|
||||
blob: audioBlob
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const saveRecording = (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
document.body.appendChild(a);
|
||||
a.style = 'display: none';
|
||||
a.href = url;
|
||||
a.download = 'recording.wav';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
loading = true;
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
try {
|
||||
if (displayMedia) {
|
||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: true
|
||||
});
|
||||
|
||||
stream = new MediaStream();
|
||||
for (const track of mediaStream.getAudioTracks()) {
|
||||
stream.addTrack(track);
|
||||
}
|
||||
|
||||
for (const track of mediaStream.getVideoTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
} else {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error accessing media devices.', err);
|
||||
toast.error($i18n.t('Error accessing media devices.'));
|
||||
loading = false;
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm; codecs=opus'
|
||||
});
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
mediaRecorder.onstart = () => {
|
||||
console.log('Recording started');
|
||||
loading = false;
|
||||
@@ -180,77 +216,99 @@
|
||||
mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
|
||||
mediaRecorder.onstop = async () => {
|
||||
console.log('Recording stopped');
|
||||
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
|
||||
audioChunks = [];
|
||||
} else {
|
||||
if (confirmed) {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
|
||||
await transcribeHandler(audioBlob);
|
||||
if (confirmed) {
|
||||
// Use the actual type provided by MediaRecorder
|
||||
let type = audioChunks[0]?.type || mediaRecorder.mimeType || 'audio/webm';
|
||||
|
||||
confirmed = false;
|
||||
loading = false;
|
||||
// split `/` and `;` to get the extension
|
||||
let ext = type.split('/')[1].split(';')[0] || 'webm';
|
||||
|
||||
// If not audio, default to audio/webm
|
||||
if (!type.startsWith('audio/')) {
|
||||
ext = 'webm';
|
||||
}
|
||||
audioChunks = [];
|
||||
recording = false;
|
||||
|
||||
const audioBlob = new Blob(audioChunks, { type: type });
|
||||
await onStopHandler(audioBlob, ext);
|
||||
|
||||
confirmed = false;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
audioChunks = [];
|
||||
recording = false;
|
||||
};
|
||||
mediaRecorder.start();
|
||||
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
// Create a SpeechRecognition object
|
||||
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
|
||||
|
||||
// Set continuous to true for continuous recognition
|
||||
speechRecognition.continuous = true;
|
||||
try {
|
||||
mediaRecorder.start();
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
toast.error($i18n.t('Error starting recording.'));
|
||||
loading = false;
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
|
||||
const inactivityTimeout = 2000; // 3 seconds
|
||||
if (transcribe) {
|
||||
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
// Create a SpeechRecognition object
|
||||
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
|
||||
|
||||
let timeoutId;
|
||||
// Start recognition
|
||||
speechRecognition.start();
|
||||
// Set continuous to true for continuous recognition
|
||||
speechRecognition.continuous = true;
|
||||
|
||||
// Event triggered when speech is recognized
|
||||
speechRecognition.onresult = async (event) => {
|
||||
// Clear the inactivity timeout
|
||||
clearTimeout(timeoutId);
|
||||
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
|
||||
const inactivityTimeout = 2000; // 3 seconds
|
||||
|
||||
// Handle recognized speech
|
||||
console.log(event);
|
||||
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
|
||||
let timeoutId;
|
||||
// Start recognition
|
||||
speechRecognition.start();
|
||||
|
||||
transcription = `${transcription}${transcript}`;
|
||||
// Event triggered when speech is recognized
|
||||
speechRecognition.onresult = async (event) => {
|
||||
// Clear the inactivity timeout
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
// Handle recognized speech
|
||||
console.log(event);
|
||||
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
|
||||
|
||||
// Restart the inactivity timeout
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log('Speech recognition turned off due to inactivity.');
|
||||
speechRecognition.stop();
|
||||
}, inactivityTimeout);
|
||||
};
|
||||
transcription = `${transcription}${transcript}`;
|
||||
|
||||
// Event triggered when recognition is ended
|
||||
speechRecognition.onend = function () {
|
||||
// Restart recognition after it ends
|
||||
console.log('recognition ended');
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
|
||||
confirmRecording();
|
||||
dispatch('confirm', { text: transcription });
|
||||
confirmed = false;
|
||||
loading = false;
|
||||
};
|
||||
// Restart the inactivity timeout
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log('Speech recognition turned off due to inactivity.');
|
||||
speechRecognition.stop();
|
||||
}, inactivityTimeout);
|
||||
};
|
||||
|
||||
// Event triggered when an error occurs
|
||||
speechRecognition.onerror = function (event) {
|
||||
console.log(event);
|
||||
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
|
||||
dispatch('cancel');
|
||||
// Event triggered when recognition is ended
|
||||
speechRecognition.onend = function () {
|
||||
// Restart recognition after it ends
|
||||
console.log('recognition ended');
|
||||
|
||||
stopRecording();
|
||||
};
|
||||
confirmRecording();
|
||||
onConfirm({
|
||||
text: transcription
|
||||
});
|
||||
confirmed = false;
|
||||
loading = false;
|
||||
};
|
||||
|
||||
// Event triggered when an error occurs
|
||||
speechRecognition.onerror = function (event) {
|
||||
console.log(event);
|
||||
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
|
||||
onCancel();
|
||||
|
||||
stopRecording();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -339,7 +397,7 @@
|
||||
rounded-full"
|
||||
on:click={async () => {
|
||||
stopRecording();
|
||||
dispatch('cancel');
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
console.log('citations', citations);
|
||||
|
||||
showRelevance = calculateShowRelevance(citations);
|
||||
showPercentage = shouldShowPercentage(citations);
|
||||
|
||||
@@ -139,13 +139,16 @@
|
||||
{percentage.toFixed(2)}%
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if typeof document?.distance === 'number'}
|
||||
<span class="text-gray-500 dark:text-gray-500">
|
||||
({(document?.distance ?? 0).toFixed(4)})
|
||||
</span>
|
||||
{/if}
|
||||
{:else if typeof document?.distance === 'number'}
|
||||
<span class="text-gray-500 dark:text-gray-500">
|
||||
({(document?.distance ?? 0).toFixed(4)})
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-500 dark:text-gray-500">
|
||||
{(document?.distance ?? 0).toFixed(4)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -154,11 +154,11 @@
|
||||
}, [])}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
on:update={(e) => {
|
||||
dispatch('update', e.detail);
|
||||
onUpdate={(value) => {
|
||||
dispatch('update', value);
|
||||
}}
|
||||
on:code={(e) => {
|
||||
const { lang, code } = e.detail;
|
||||
onCode={(value) => {
|
||||
const { lang, code } = value;
|
||||
|
||||
if (
|
||||
($settings?.detectArtifacts ?? true) &&
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
import markedKatexExtension from '$lib/utils/marked/katex-extension';
|
||||
|
||||
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let id = '';
|
||||
export let content;
|
||||
@@ -18,6 +15,9 @@
|
||||
|
||||
export let sourceIds = [];
|
||||
|
||||
export let onUpdate = () => {};
|
||||
export let onCode = () => {};
|
||||
|
||||
export let onSourceClick = () => {};
|
||||
export let onTaskClick = () => {};
|
||||
|
||||
@@ -40,17 +40,5 @@
|
||||
</script>
|
||||
|
||||
{#key id}
|
||||
<MarkdownTokens
|
||||
{tokens}
|
||||
{id}
|
||||
{save}
|
||||
{onTaskClick}
|
||||
{onSourceClick}
|
||||
on:update={(e) => {
|
||||
dispatch('update', e.detail);
|
||||
}}
|
||||
on:code={(e) => {
|
||||
dispatch('code', e.detail);
|
||||
}}
|
||||
/>
|
||||
<MarkdownTokens {tokens} {id} {save} {onTaskClick} {onSourceClick} {onUpdate} {onCode} />
|
||||
{/key}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Token } from 'marked';
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import Source from './Source.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
export let id: string;
|
||||
export let token: Token;
|
||||
|
||||
export let onSourceClick: Function = () => {};
|
||||
|
||||
let html: string | null = null;
|
||||
|
||||
$: if (token.type === 'html' && token?.text) {
|
||||
html = DOMPurify.sanitize(token.text);
|
||||
} else {
|
||||
html = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if token.type === 'html'}
|
||||
{#if html && html.includes('<video')}
|
||||
{@const video = html.match(/<video[^>]*>([\s\S]*?)<\/video>/)}
|
||||
{@const videoSrc = video && video[1]}
|
||||
{#if videoSrc}
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
class="w-full my-2"
|
||||
src={videoSrc}
|
||||
title="Video player"
|
||||
frameborder="0"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></video>
|
||||
{:else}
|
||||
{token.text}
|
||||
{/if}
|
||||
{:else if token.text && token.text.match(/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/)}
|
||||
{@const match = token.text.match(
|
||||
/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/
|
||||
)}
|
||||
{@const ytId = match && match[1]}
|
||||
{#if ytId}
|
||||
<iframe
|
||||
class="w-full aspect-video my-2"
|
||||
src={`https://www.youtube.com/embed/${ytId}`}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
{/if}
|
||||
{:else if token.text.includes(`<file type="html"`)}
|
||||
{@const match = token.text.match(/<file type="html" id="([^"]+)"/)}
|
||||
{@const fileId = match && match[1]}
|
||||
{#if fileId}
|
||||
<iframe
|
||||
class="w-full my-2"
|
||||
src={`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content/html`}
|
||||
title="Content"
|
||||
frameborder="0"
|
||||
sandbox="allow-scripts{($settings?.iframeSandboxAllowForms ?? false)
|
||||
? ' allow-forms'
|
||||
: ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
width="100%"
|
||||
onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
|
||||
></iframe>
|
||||
{/if}
|
||||
{:else if token.text.includes(`<source_id`)}
|
||||
<Source {id} {token} onClick={onSourceClick} />
|
||||
{:else}
|
||||
{token.text}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -13,6 +13,7 @@
|
||||
import Image from '$lib/components/common/Image.svelte';
|
||||
import KatexRenderer from './KatexRenderer.svelte';
|
||||
import Source from './Source.svelte';
|
||||
import HtmlToken from './HTMLToken.svelte';
|
||||
|
||||
export let id: string;
|
||||
export let tokens: Token[];
|
||||
@@ -23,16 +24,7 @@
|
||||
{#if token.type === 'escape'}
|
||||
{unescapeHtml(token.text)}
|
||||
{:else if token.type === 'html'}
|
||||
{@const html = DOMPurify.sanitize(token.text)}
|
||||
{#if html && html.includes('<video')}
|
||||
{@html html}
|
||||
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
|
||||
{@html `${token.text}`}
|
||||
{:else if token.text.includes(`<source_id`)}
|
||||
<Source {id} {token} onClick={onSourceClick} />
|
||||
{:else}
|
||||
{@html html}
|
||||
{/if}
|
||||
<HtmlToken {id} {token} {onSourceClick} />
|
||||
{:else if token.type === 'link'}
|
||||
{#if token.tokens}
|
||||
<a href={token.href} target="_blank" rel="nofollow" title={token.title}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
import { createEventDispatcher, onMount, getContext } from 'svelte';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import fileSaver from 'file-saver';
|
||||
@@ -21,8 +21,7 @@
|
||||
|
||||
import Source from './Source.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
import HtmlToken from './HTMLToken.svelte';
|
||||
|
||||
export let id: string;
|
||||
export let tokens: Token[];
|
||||
@@ -31,6 +30,9 @@
|
||||
|
||||
export let save = false;
|
||||
|
||||
export let onUpdate: Function = () => {};
|
||||
export let onCode: Function = () => {};
|
||||
|
||||
export let onTaskClick: Function = () => {};
|
||||
export let onSourceClick: Function = () => {};
|
||||
|
||||
@@ -93,11 +95,9 @@
|
||||
code={token?.text ?? ''}
|
||||
{attributes}
|
||||
{save}
|
||||
onCode={(value) => {
|
||||
dispatch('code', value);
|
||||
}}
|
||||
{onCode}
|
||||
onSave={(value) => {
|
||||
dispatch('update', {
|
||||
onUpdate({
|
||||
raw: token.raw,
|
||||
oldContent: token.text,
|
||||
newContent: value
|
||||
@@ -267,16 +267,7 @@
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else if token.type === 'html'}
|
||||
{@const html = DOMPurify.sanitize(token.text)}
|
||||
{#if html && html.includes('<video')}
|
||||
{@html html}
|
||||
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
|
||||
{@html `${token.text}`}
|
||||
{:else if token.text.includes(`<source_id`)}
|
||||
<Source {id} {token} onClick={onSourceClick} />
|
||||
{:else}
|
||||
{token.text}
|
||||
{/if}
|
||||
<HtmlToken {id} {token} {onSourceClick} />
|
||||
{:else if token.type === 'iframe'}
|
||||
<iframe
|
||||
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"
|
||||
|
||||
@@ -200,9 +200,11 @@
|
||||
await initHandler();
|
||||
await tick();
|
||||
|
||||
const messageElement = document.getElementById(`message-${messageId}`);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ block: 'start' });
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const messageElement = document.getElementById(`message-${messageId}`);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -238,10 +240,9 @@
|
||||
messageChildrenIds = history.messages[currentMessageId].childrenIds;
|
||||
}
|
||||
history.currentId = currentMessageId;
|
||||
|
||||
await tick();
|
||||
await updateChat();
|
||||
triggerScroll();
|
||||
// await tick();
|
||||
// await updateChat();
|
||||
// triggerScroll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -293,7 +294,7 @@
|
||||
|
||||
<div class="w-full rounded-xl pl-5 pr-2 py-2">
|
||||
<Name>
|
||||
Merged Response
|
||||
{$i18n.t('Merged Response')}
|
||||
|
||||
{#if message.timestamp}
|
||||
<span
|
||||
|
||||
@@ -623,12 +623,6 @@
|
||||
).at(-1)}
|
||||
{#if !status?.hidden}
|
||||
<div class="status-description flex items-center gap-2 py-0.5">
|
||||
{#if status?.done === false}
|
||||
<div class="">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status?.action === 'web_search' && status?.urls}
|
||||
<WebSearchResults {status}>
|
||||
<div class="flex flex-col justify-center -space-y-0.5">
|
||||
@@ -683,6 +677,8 @@
|
||||
{$i18n.t('No search query generated')}
|
||||
{:else if status?.description === 'Generating search query'}
|
||||
{$i18n.t('Generating search query')}
|
||||
{:else if status?.description === 'Searching the web'}
|
||||
{$i18n.t('Searching the web...')}
|
||||
{:else}
|
||||
{status?.description}
|
||||
{/if}
|
||||
@@ -777,7 +773,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full flex flex-col relative" id="response-content-container">
|
||||
{#if message.content === '' && !message.error}
|
||||
{#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0}
|
||||
<Skeleton />
|
||||
{:else if message.content && message.error !== true}
|
||||
<!-- always show message contents even if there's an error -->
|
||||
|
||||
@@ -65,10 +65,12 @@
|
||||
|
||||
await tick();
|
||||
|
||||
messageEditTextAreaElement.style.height = '';
|
||||
messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
|
||||
if (messageEditTextAreaElement) {
|
||||
messageEditTextAreaElement.style.height = '';
|
||||
messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
|
||||
|
||||
messageEditTextAreaElement?.focus();
|
||||
messageEditTextAreaElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const editMessageConfirmHandler = async (submit = true) => {
|
||||
|
||||
@@ -778,7 +778,7 @@
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredItems.length === 0}
|
||||
{:else}
|
||||
<div class="mb-3"></div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
|
||||
|
||||
<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
|
||||
<div class="flex items-center w-full px-1.5">
|
||||
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
|
||||
<div class="flex items-center w-full pl-1.5 pr-1">
|
||||
<div
|
||||
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
|
||||
></div>
|
||||
|
||||
@@ -159,15 +159,21 @@ Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
|
||||
|
||||
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
|
||||
|
||||
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full justify-between text-sm">
|
||||
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
|
||||
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
|
||||
<input
|
||||
id="profile-image-input"
|
||||
bind:this={profileImageInputElement}
|
||||
@@ -236,7 +236,7 @@
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
@@ -265,98 +265,46 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="py-0.5">
|
||||
<hr class="border-gray-50 dark:border-gray-850 my-2" />
|
||||
|
||||
<div class="my-2">
|
||||
<UpdatePassword />
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-50 dark:border-gray-850 my-2" />
|
||||
{#if ($config?.features?.enable_api_key ?? true) || $user?.role === 'admin'}
|
||||
<div class="flex justify-between items-center text-sm mb-2">
|
||||
<div class=" font-medium">{$i18n.t('API keys')}</div>
|
||||
<button
|
||||
class=" text-xs font-medium text-gray-500"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAPIKeys = !showAPIKeys;
|
||||
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<div class=" font-medium">{$i18n.t('API keys')}</div>
|
||||
<button
|
||||
class=" text-xs font-medium text-gray-500"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAPIKeys = !showAPIKeys;
|
||||
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
|
||||
>
|
||||
</div>
|
||||
{#if showAPIKeys}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if $user?.role === 'admin'}
|
||||
<div class="justify-between w-full">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="self-center text-xs font-medium mb-1">{$i18n.t('JWT Token')}</div>
|
||||
</div>
|
||||
|
||||
{#if showAPIKeys}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="justify-between w-full">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2">
|
||||
<SensitiveInput value={localStorage.token} readOnly={true} />
|
||||
|
||||
<button
|
||||
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
|
||||
on:click={() => {
|
||||
copyToClipboard(localStorage.token);
|
||||
JWTTokenCopied = true;
|
||||
setTimeout(() => {
|
||||
JWTTokenCopied = false;
|
||||
}, 2000);
|
||||
}}
|
||||
>
|
||||
{#if JWTTokenCopied}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if $config?.features?.enable_api_key ?? true}
|
||||
<div class="justify-between w-full">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
|
||||
</div>
|
||||
<div class="flex mt-2">
|
||||
{#if APIKey}
|
||||
<SensitiveInput value={APIKey} readOnly={true} />
|
||||
<div class="flex">
|
||||
<SensitiveInput value={localStorage.token} readOnly={true} />
|
||||
|
||||
<button
|
||||
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
|
||||
on:click={() => {
|
||||
copyToClipboard(APIKey);
|
||||
APIKeyCopied = true;
|
||||
copyToClipboard(localStorage.token);
|
||||
JWTTokenCopied = true;
|
||||
setTimeout(() => {
|
||||
APIKeyCopied = false;
|
||||
JWTTokenCopied = false;
|
||||
}, 2000);
|
||||
}}
|
||||
>
|
||||
{#if APIKeyCopied}
|
||||
{#if JWTTokenCopied}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -389,46 +337,105 @@
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $config?.features?.enable_api_key ?? true}
|
||||
<div class="justify-between w-full">
|
||||
{#if $user?.role === 'admin'}
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="self-center text-xs font-medium mb-1">{$i18n.t('API Key')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex">
|
||||
{#if APIKey}
|
||||
<SensitiveInput value={APIKey} readOnly={true} />
|
||||
|
||||
<Tooltip content={$i18n.t('Create new key')}>
|
||||
<button
|
||||
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
|
||||
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
|
||||
on:click={() => {
|
||||
copyToClipboard(APIKey);
|
||||
APIKeyCopied = true;
|
||||
setTimeout(() => {
|
||||
APIKeyCopied = false;
|
||||
}, 2000);
|
||||
}}
|
||||
>
|
||||
{#if APIKeyCopied}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Tooltip content={$i18n.t('Create new key')}>
|
||||
<button
|
||||
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
|
||||
on:click={() => {
|
||||
createAPIKeyHandler();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => {
|
||||
createAPIKeyHandler();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => {
|
||||
createAPIKeyHandler();
|
||||
}}
|
||||
>
|
||||
<Plus strokeWidth="2" className=" size-3.5" />
|
||||
<Plus strokeWidth="2" className=" size-3.5" />
|
||||
|
||||
{$i18n.t('Create new secret key')}</button
|
||||
>
|
||||
{/if}
|
||||
{$i18n.t('Create new secret key')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -140,28 +140,31 @@
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Import Chats')}</div>
|
||||
</button>
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
exportChats();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
|
||||
</button>
|
||||
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
exportChats();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
import { setDefaultPromptSuggestions } from '$lib/apis/configs';
|
||||
import { config, models, settings, user } from '$lib/stores';
|
||||
import { createEventDispatcher, onMount, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -30,7 +28,9 @@
|
||||
// Interface
|
||||
let defaultModelId = '';
|
||||
let showUsername = false;
|
||||
|
||||
let notificationSound = true;
|
||||
let notificationSoundAlways = false;
|
||||
|
||||
let detectArtifacts = true;
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
saveSettings({ notificationSound: notificationSound });
|
||||
};
|
||||
|
||||
const toggleNotificationSoundAlways = async () => {
|
||||
notificationSoundAlways = !notificationSoundAlways;
|
||||
saveSettings({ notificationSoundAlways: notificationSoundAlways });
|
||||
};
|
||||
|
||||
const toggleShowChangelog = async () => {
|
||||
showChangelog = !showChangelog;
|
||||
saveSettings({ showChangelog: showChangelog });
|
||||
@@ -294,7 +299,8 @@
|
||||
chatDirection = $settings.chatDirection ?? 'auto';
|
||||
userLocation = $settings.userLocation ?? false;
|
||||
|
||||
notificationSound = $settings.notificationSound ?? true;
|
||||
notificationSound = $settings?.notificationSound ?? true;
|
||||
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
|
||||
|
||||
hapticFeedback = $settings.hapticFeedback ?? false;
|
||||
ctrlEnterToSend = $settings.ctrlEnterToSend ?? false;
|
||||
@@ -477,6 +483,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if notificationSound}
|
||||
<div>
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs">
|
||||
{$i18n.t('Always Play Notification Sound')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
toggleNotificationSoundAlways();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if notificationSoundAlways === true}
|
||||
<span class="ml-2 self-center">{$i18n.t('On')}</span>
|
||||
{:else}
|
||||
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin'}
|
||||
<div>
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
@@ -855,7 +885,7 @@
|
||||
<div>
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs">
|
||||
{$i18n.t('Scroll to bottom when switching between branches')}
|
||||
{$i18n.t('Scroll On Branch Change')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
import { setDefaultPromptSuggestions } from '$lib/apis/configs';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import { config, models, settings, user } from '$lib/stores';
|
||||
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { decode } from 'html-entities';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getContext, createEventDispatcher } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import dayjs from '$lib/dayjs';
|
||||
@@ -26,9 +26,6 @@
|
||||
// Assuming $i18n.languages is an array of language codes
|
||||
$: loadLocale($i18n.languages);
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
$: dispatch('change', open);
|
||||
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
@@ -55,6 +52,10 @@
|
||||
export let disabled = false;
|
||||
export let hide = false;
|
||||
|
||||
export let onChange: Function = () => {};
|
||||
|
||||
$: onChange(open);
|
||||
|
||||
const collapsibleId = uuidv4();
|
||||
|
||||
function parseJSONString(str) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { onMount, getContext, createEventDispatcher } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { marked } from 'marked';
|
||||
|
||||
export let title = '';
|
||||
export let message = '';
|
||||
@@ -90,7 +93,8 @@
|
||||
<slot>
|
||||
<div class=" text-sm text-gray-500 flex-1">
|
||||
{#if message !== ''}
|
||||
{message}
|
||||
{@const html = DOMPurify.sanitize(marked.parse(message))}
|
||||
{@html html}
|
||||
{:else}
|
||||
{$i18n.t('This action cannot be undone. Do you wish to continue?')}
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, createEventDispatcher } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { isApp } from '$lib/stores';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let show = false;
|
||||
export let className = '';
|
||||
export let onClose = () => {};
|
||||
|
||||
let modalElement = null;
|
||||
let mounted = false;
|
||||
@@ -33,7 +32,7 @@
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else if (modalElement) {
|
||||
dispatch('close');
|
||||
onClose();
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
if (document.body.contains(modalElement)) {
|
||||
|
||||
@@ -16,10 +16,22 @@
|
||||
export let edit = false;
|
||||
|
||||
let enableFullContent = false;
|
||||
|
||||
let isPdf = false;
|
||||
let isAudio = false;
|
||||
|
||||
$: isPDF =
|
||||
item?.meta?.content_type === 'application/pdf' ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.pdf'));
|
||||
|
||||
$: isAudio =
|
||||
item?.meta?.content_type.startsWith('audio/') ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.mp3')) ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.wav')) ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.ogg')) ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.m4a')) ||
|
||||
(item?.name && item?.name.toLowerCase().endsWith('.webm'));
|
||||
|
||||
onMount(() => {
|
||||
console.log(item);
|
||||
if (item?.context === 'full') {
|
||||
@@ -122,6 +134,15 @@
|
||||
class="w-full h-[70vh] border-0 rounded-lg mt-4"
|
||||
/>
|
||||
{:else}
|
||||
{#if isAudio}
|
||||
<audio
|
||||
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
|
||||
class="w-full border-0 rounded-lg mb-2"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
|
||||
{item?.file?.data?.content ?? 'No content'}
|
||||
</div>
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
bind:open
|
||||
className="w-full "
|
||||
buttonClassName="w-full"
|
||||
on:change={(e) => {
|
||||
dispatch('change', e.detail);
|
||||
onChange={(state) => {
|
||||
dispatch('change', state);
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
@@ -8,20 +8,48 @@
|
||||
export let className = ' w-full outline-hidden focus:outline-hidden';
|
||||
export let imageClassName = 'rounded-lg';
|
||||
|
||||
export let dismissible = false;
|
||||
export let onDismiss = () => {};
|
||||
|
||||
let _src = '';
|
||||
$: _src = src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src;
|
||||
|
||||
let showImagePreview = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={className}
|
||||
on:click={() => {
|
||||
showImagePreview = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<img src={_src} {alt} class={imageClassName} draggable="false" data-cy="image" />
|
||||
</button>
|
||||
|
||||
<ImagePreview bind:show={showImagePreview} src={_src} {alt} />
|
||||
|
||||
<div class=" relative group w-fit">
|
||||
<button
|
||||
class={className}
|
||||
on:click={() => {
|
||||
showImagePreview = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<img src={_src} {alt} class={imageClassName} draggable="false" data-cy="image" />
|
||||
</button>
|
||||
|
||||
{#if dismissible}
|
||||
<div class=" absolute -top-1 -right-1">
|
||||
<button
|
||||
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -29,16 +29,21 @@
|
||||
|
||||
export let oncompositionstart = (e) => {};
|
||||
export let oncompositionend = (e) => {};
|
||||
export let onChange = (e) => {};
|
||||
|
||||
// create a lowlight instance with all languages loaded
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
export let className = 'input-prose';
|
||||
export let placeholder = 'Type here...';
|
||||
export let value = '';
|
||||
export let id = '';
|
||||
|
||||
export let id = '';
|
||||
export let value = '';
|
||||
export let html = '';
|
||||
|
||||
export let json = false;
|
||||
export let raw = false;
|
||||
export let editable = true;
|
||||
|
||||
export let preserveBreaks = false;
|
||||
export let generateAutoCompletion: Function = async () => null;
|
||||
@@ -54,6 +59,16 @@
|
||||
throwOnError: false
|
||||
};
|
||||
|
||||
$: if (editor) {
|
||||
editor.setOptions({
|
||||
editable: editable
|
||||
});
|
||||
}
|
||||
|
||||
$: if (value === null && html !== null && editor) {
|
||||
editor.commands.setContent(html);
|
||||
}
|
||||
|
||||
// Function to find the next template in the document
|
||||
function findNextTemplate(doc, from = 0) {
|
||||
const patterns = [{ start: '{{', end: '}}' }];
|
||||
@@ -128,41 +143,47 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
console.log(value);
|
||||
|
||||
if (preserveBreaks) {
|
||||
turndownService.addRule('preserveBreaks', {
|
||||
filter: 'br', // Target <br> elements
|
||||
replacement: function (content) {
|
||||
return '<br/>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let content = value;
|
||||
|
||||
if (!raw) {
|
||||
async function tryParse(value, attempts = 3, interval = 100) {
|
||||
try {
|
||||
// Try parsing the value
|
||||
return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
|
||||
breaks: false
|
||||
});
|
||||
} catch (error) {
|
||||
// If no attempts remain, fallback to plain text
|
||||
if (attempts <= 1) {
|
||||
return value;
|
||||
if (!json) {
|
||||
if (preserveBreaks) {
|
||||
turndownService.addRule('preserveBreaks', {
|
||||
filter: 'br', // Target <br> elements
|
||||
replacement: function (content) {
|
||||
return '<br/>';
|
||||
}
|
||||
// Wait for the interval, then retry
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
return tryParse(value, attempts - 1, interval); // Recursive call
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Usage example
|
||||
content = await tryParse(value);
|
||||
if (!raw) {
|
||||
async function tryParse(value, attempts = 3, interval = 100) {
|
||||
try {
|
||||
// Try parsing the value
|
||||
return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
|
||||
breaks: false
|
||||
});
|
||||
} catch (error) {
|
||||
// If no attempts remain, fallback to plain text
|
||||
if (attempts <= 1) {
|
||||
return value;
|
||||
}
|
||||
// Wait for the interval, then retry
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
return tryParse(value, attempts - 1, interval); // Recursive call
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
content = await tryParse(value);
|
||||
}
|
||||
} else {
|
||||
if (html && !content) {
|
||||
content = html;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('content', content);
|
||||
|
||||
editor = new Editor({
|
||||
element: element,
|
||||
extensions: [
|
||||
@@ -198,32 +219,44 @@
|
||||
// force re-render so `editor.isActive` works as expected
|
||||
editor = editor;
|
||||
|
||||
if (!raw) {
|
||||
let newValue = turndownService
|
||||
.turndown(
|
||||
editor
|
||||
.getHTML()
|
||||
.replace(/<p><\/p>/g, '<br/>')
|
||||
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
|
||||
)
|
||||
.replace(/\u00a0/g, ' ');
|
||||
html = editor.getHTML();
|
||||
|
||||
if (!preserveBreaks) {
|
||||
newValue = newValue.replace(/<br\/>/g, '');
|
||||
}
|
||||
onChange({
|
||||
html: editor.getHTML(),
|
||||
json: editor.getJSON(),
|
||||
md: turndownService.turndown(editor.getHTML())
|
||||
});
|
||||
|
||||
if (value !== newValue) {
|
||||
value = newValue;
|
||||
if (json) {
|
||||
value = editor.getJSON();
|
||||
} else {
|
||||
if (!raw) {
|
||||
let newValue = turndownService
|
||||
.turndown(
|
||||
editor
|
||||
.getHTML()
|
||||
.replace(/<p><\/p>/g, '<br/>')
|
||||
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
|
||||
)
|
||||
.replace(/\u00a0/g, ' ');
|
||||
|
||||
// check if the node is paragraph as well
|
||||
if (editor.isActive('paragraph')) {
|
||||
if (value === '') {
|
||||
editor.commands.clearContent();
|
||||
if (!preserveBreaks) {
|
||||
newValue = newValue.replace(/<br\/>/g, '');
|
||||
}
|
||||
|
||||
if (value !== newValue) {
|
||||
value = newValue;
|
||||
|
||||
// check if the node is paragraph as well
|
||||
if (editor.isActive('paragraph')) {
|
||||
if (value === '') {
|
||||
editor.commands.clearContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = editor.getHTML();
|
||||
}
|
||||
} else {
|
||||
value = editor.getHTML();
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
@@ -356,35 +389,49 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Update the editor content if the external `value` changes
|
||||
$: if (
|
||||
editor &&
|
||||
(raw
|
||||
? value !== editor.getHTML()
|
||||
: value !==
|
||||
turndownService
|
||||
.turndown(
|
||||
(preserveBreaks
|
||||
? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
|
||||
: editor.getHTML()
|
||||
).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
|
||||
)
|
||||
.replace(/\u00a0/g, ' '))
|
||||
) {
|
||||
if (raw) {
|
||||
editor.commands.setContent(value);
|
||||
} else {
|
||||
preserveBreaks
|
||||
? editor.commands.setContent(value)
|
||||
: editor.commands.setContent(
|
||||
marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
|
||||
breaks: false
|
||||
})
|
||||
); // Update editor content
|
||||
}
|
||||
|
||||
selectTemplate();
|
||||
$: if (value !== null && editor) {
|
||||
onValueChange();
|
||||
}
|
||||
|
||||
const onValueChange = () => {
|
||||
if (!editor) return;
|
||||
|
||||
if (json) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(editor.getJSON())) {
|
||||
editor.commands.setContent(value);
|
||||
selectTemplate();
|
||||
}
|
||||
} else {
|
||||
if (raw) {
|
||||
if (value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value);
|
||||
selectTemplate();
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
value !==
|
||||
turndownService
|
||||
.turndown(
|
||||
(preserveBreaks
|
||||
? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
|
||||
: editor.getHTML()
|
||||
).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
|
||||
)
|
||||
.replace(/\u00a0/g, ' ')
|
||||
) {
|
||||
preserveBreaks
|
||||
? editor.commands.setContent(value)
|
||||
: editor.commands.setContent(
|
||||
marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
|
||||
breaks: false
|
||||
})
|
||||
); // Update editor content
|
||||
|
||||
selectTemplate();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export let value = '';
|
||||
export let placeholder = '';
|
||||
export let rows = 1;
|
||||
export let minSize = null;
|
||||
export let required = false;
|
||||
export let className =
|
||||
'w-full rounded-lg px-3 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden h-full';
|
||||
@@ -29,7 +30,9 @@
|
||||
const resize = () => {
|
||||
if (textareaElement) {
|
||||
textareaElement.style.height = '';
|
||||
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
|
||||
textareaElement.style.height = minSize
|
||||
? `${Math.max(textareaElement.scrollHeight, minSize)}px`
|
||||
: `${textareaElement.scrollHeight}px`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m15 15 6-6m0 0-6-6m6 6H9a6 6 0 0 0 0 12h3"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class={className}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 3.75A.75.75 0 0 1 2.75 3h10.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 3.75ZM2 8a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 8Zm0 4.25a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-3';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.75 2.25A.75.75 0 0 1 7.5 3v1.5h9V3A.75.75 0 0 1 18 3v1.5h.75a3 3 0 0 1 3 3v11.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V7.5a3 3 0 0 1 3-3H6V3a.75.75 0 0 1 .75-.75Zm13.5 9a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -1,10 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={className}>
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={className}>
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-3';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -18,7 +18,8 @@
|
||||
showArtifacts,
|
||||
mobile,
|
||||
temporaryChatEnabled,
|
||||
theme
|
||||
theme,
|
||||
user
|
||||
} from '$lib/stores';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
|
||||
@@ -212,7 +213,7 @@
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if !$temporaryChatEnabled}
|
||||
{#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
id="chat-share-button"
|
||||
@@ -288,14 +289,16 @@
|
||||
transition={flyAndScale}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
|
||||
@@ -570,6 +570,48 @@
|
||||
</div>
|
||||
{/if} -->
|
||||
|
||||
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
|
||||
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
class="grow flex items-center space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/notes"
|
||||
on:click={() => {
|
||||
selectedChatId = null;
|
||||
chatId.set('');
|
||||
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class="self-center">
|
||||
<svg
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 3v4a1 1 0 0 1-1 1H5m4 8h6m-6-4h6m4-8v16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7.914a1 1 0 0 1 .293-.707l3.914-3.914A1 1 0 0 1 9.914 3H18a1 1 0 0 1 1 1Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class=" self-center font-medium text-sm font-primary">{$i18n.t('Notes')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
|
||||
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Document from '$lib/components/icons/Document.svelte';
|
||||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||
import { generateTitle } from '$lib/apis';
|
||||
|
||||
export let className = '';
|
||||
|
||||
@@ -133,12 +135,10 @@
|
||||
dispatch('change');
|
||||
};
|
||||
|
||||
const focusEdit = async (node: HTMLInputElement) => {
|
||||
node.focus();
|
||||
};
|
||||
|
||||
let itemElement;
|
||||
|
||||
let generating = false;
|
||||
let doubleClicked = false;
|
||||
let dragged = false;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@@ -213,6 +213,52 @@
|
||||
chatTitle = '';
|
||||
}
|
||||
};
|
||||
|
||||
const renameHandler = async () => {
|
||||
chatTitle = title;
|
||||
confirmEdit = true;
|
||||
|
||||
await tick();
|
||||
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`chat-title-input-${id}`);
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const generateTitleHandler = async () => {
|
||||
generating = true;
|
||||
if (!chat) {
|
||||
chat = await getChatById(localStorage.token, id);
|
||||
}
|
||||
|
||||
const messages = (chat.chat?.messages ?? []).map((message) => {
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content
|
||||
};
|
||||
});
|
||||
|
||||
const model = chat.chat.models.at(0) ?? chat.models.at(0) ?? '';
|
||||
|
||||
chatTitle = '';
|
||||
|
||||
const generatedTitle = await generateTitle(localStorage.token, model, messages).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (generatedTitle) {
|
||||
if (generatedTitle !== title) {
|
||||
editChatTitle(id, generatedTitle);
|
||||
}
|
||||
|
||||
confirmEdit = false;
|
||||
}
|
||||
generating = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
|
||||
@@ -254,14 +300,43 @@
|
||||
? 'bg-gray-200 dark:bg-gray-900'
|
||||
: selected
|
||||
? 'bg-gray-100 dark:bg-gray-950'
|
||||
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
||||
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
<input
|
||||
use:focusEdit
|
||||
bind:value={chatTitle}
|
||||
id="chat-title-input-{id}"
|
||||
bind:value={chatTitle}
|
||||
class=" bg-transparent w-full outline-hidden mr-10"
|
||||
placeholder={generating ? $i18n.t('Generating...') : ''}
|
||||
on:keydown={chatTitleInputKeydownHandler}
|
||||
on:blur={async (e) => {
|
||||
// check if target is generate button
|
||||
if (e.relatedTarget?.id === 'generate-title-button') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (doubleClicked) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
await tick();
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`chat-title-input-${id}`);
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
|
||||
doubleClicked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatTitle !== title) {
|
||||
editChatTitle(id, chatTitle);
|
||||
}
|
||||
|
||||
confirmEdit = false;
|
||||
chatTitle = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -280,9 +355,12 @@
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
on:dblclick={() => {
|
||||
chatTitle = title;
|
||||
confirmEdit = true;
|
||||
on:dblclick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
doubleClicked = true;
|
||||
renameHandler();
|
||||
}}
|
||||
on:mouseenter={(e) => {
|
||||
mouseOver = true;
|
||||
@@ -311,7 +389,7 @@
|
||||
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
|
||||
absolute {className === 'pr-2'
|
||||
? 'right-[8px]'
|
||||
: 'right-0'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
|
||||
: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
|
||||
|
||||
to-transparent"
|
||||
on:mouseenter={(e) => {
|
||||
@@ -325,28 +403,19 @@
|
||||
<div
|
||||
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
|
||||
>
|
||||
<Tooltip content={$i18n.t('Confirm')}>
|
||||
<Tooltip content={$i18n.t('Generate')}>
|
||||
<button
|
||||
class=" self-center dark:hover:text-white transition"
|
||||
on:click={() => {
|
||||
editChatTitle(id, chatTitle);
|
||||
confirmEdit = false;
|
||||
chatTitle = '';
|
||||
}}
|
||||
>
|
||||
<Check className=" size-3.5" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
id="generate-title-button"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
|
||||
<Tooltip content={$i18n.t('Cancel')}>
|
||||
<button
|
||||
class=" self-center dark:hover:text-white transition"
|
||||
on:click={() => {
|
||||
confirmEdit = false;
|
||||
chatTitle = '';
|
||||
generateTitleHandler();
|
||||
}}
|
||||
>
|
||||
<XMark strokeWidth="2.5" />
|
||||
<Sparkles strokeWidth="2" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -377,7 +446,7 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex self-center space-x-1 z-10">
|
||||
<div class="flex self-center z-10 items-end">
|
||||
<ChatMenu
|
||||
chatId={id}
|
||||
cloneChatHandler={() => {
|
||||
@@ -389,16 +458,7 @@
|
||||
archiveChatHandler={() => {
|
||||
archiveChatHandler(id);
|
||||
}}
|
||||
renameHandler={async () => {
|
||||
chatTitle = title;
|
||||
confirmEdit = true;
|
||||
|
||||
await tick();
|
||||
const input = document.getElementById(`chat-title-input-${id}`);
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}}
|
||||
{renameHandler}
|
||||
deleteHandler={() => {
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
@@ -414,7 +474,7 @@
|
||||
>
|
||||
<button
|
||||
aria-label="Chat Menu"
|
||||
class=" self-center dark:hover:text-white transition"
|
||||
class=" self-center dark:hover:text-white transition m-0"
|
||||
on:click={() => {
|
||||
dispatch('select');
|
||||
}}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
getChatPinnedStatusById,
|
||||
toggleChatPinnedStatusById
|
||||
} from '$lib/apis/chats';
|
||||
import { chats, theme } from '$lib/stores';
|
||||
import { chats, theme, user } from '$lib/stores';
|
||||
import { createMessagesList } from '$lib/utils';
|
||||
import { downloadChatAsPDF } from '$lib/apis/utils';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
@@ -233,15 +233,17 @@
|
||||
<div class="flex items-center">{$i18n.t('Archive')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
}}
|
||||
>
|
||||
<Share />
|
||||
<div class="flex items-center">{$i18n.t('Share')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
}}
|
||||
>
|
||||
<Share />
|
||||
<div class="flex items-center">{$i18n.t('Share')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
@@ -256,14 +258,17 @@
|
||||
transition={flyAndScale}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
|
||||
const nameUpdateHandler = async () => {
|
||||
if (name === '') {
|
||||
toast.error($i18n.t('Folder name cannot be empty'));
|
||||
toast.error($i18n.t('Folder name cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,8 +374,8 @@
|
||||
buttonClassName="w-full"
|
||||
hide={(folders[folderId]?.childrenIds ?? []).length === 0 &&
|
||||
(folders[folderId].items?.chats ?? []).length === 0}
|
||||
on:change={(e) => {
|
||||
dispatch('open', e.detail);
|
||||
onChange={(state) => {
|
||||
dispatch('open', state);
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount, tick } from 'svelte';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas-pro';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { config, models, settings, showSidebar } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { compressImage, copyToClipboard, splitStream } from '$lib/utils';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { uploadFile } from '$lib/apis/files';
|
||||
|
||||
import dayjs from '$lib/dayjs';
|
||||
import calendar from 'dayjs/plugin/calendar';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(calendar);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
async function loadLocale(locales) {
|
||||
for (const locale of locales) {
|
||||
try {
|
||||
dayjs.locale(locale);
|
||||
break; // Stop after successfully loading the first available locale
|
||||
} catch (error) {
|
||||
console.error(`Could not load locale '${locale}':`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assuming $i18n.languages is an array of language codes
|
||||
$: loadLocale($i18n.languages);
|
||||
|
||||
import { deleteNoteById, getNoteById, updateNoteById } from '$lib/apis/notes';
|
||||
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import MicSolid from '../icons/MicSolid.svelte';
|
||||
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
|
||||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import Calendar from '../icons/Calendar.svelte';
|
||||
import Users from '../icons/Users.svelte';
|
||||
|
||||
import Image from '../common/Image.svelte';
|
||||
import FileItem from '../common/FileItem.svelte';
|
||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||
import RecordMenu from './RecordMenu.svelte';
|
||||
import NoteMenu from './Notes/NoteMenu.svelte';
|
||||
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
||||
import Sparkles from '../icons/Sparkles.svelte';
|
||||
import SparklesSolid from '../icons/SparklesSolid.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Bars3BottomLeft from '../icons/Bars3BottomLeft.svelte';
|
||||
import ArrowUturnLeft from '../icons/ArrowUturnLeft.svelte';
|
||||
import ArrowUturnRight from '../icons/ArrowUturnRight.svelte';
|
||||
import Sidebar from '../common/Sidebar.svelte';
|
||||
import ArrowRight from '../icons/ArrowRight.svelte';
|
||||
import Cog6 from '../icons/Cog6.svelte';
|
||||
import { chatCompletion } from '$lib/apis/openai';
|
||||
|
||||
export let id: null | string = null;
|
||||
|
||||
let note = null;
|
||||
|
||||
const newNote = {
|
||||
title: '',
|
||||
data: {
|
||||
content: {
|
||||
json: null,
|
||||
html: '',
|
||||
md: ''
|
||||
},
|
||||
versions: [],
|
||||
files: null
|
||||
},
|
||||
meta: null,
|
||||
access_control: null
|
||||
};
|
||||
|
||||
let files = [];
|
||||
|
||||
let versionIdx = null;
|
||||
let selectedModelId = null;
|
||||
|
||||
let recording = false;
|
||||
let displayMediaRecord = false;
|
||||
|
||||
let showSettings = false;
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
let dragged = false;
|
||||
let loading = false;
|
||||
|
||||
let enhancing = false;
|
||||
let streaming = false;
|
||||
|
||||
const init = async () => {
|
||||
loading = true;
|
||||
const res = await getNoteById(localStorage.token, id).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
note = res;
|
||||
files = res.data.files || [];
|
||||
} else {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
let debounceTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const changeDebounceHandler = () => {
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
if (!note || enhancing || versionIdx !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Saving note:', note);
|
||||
|
||||
const res = await updateNoteById(localStorage.token, id, {
|
||||
...note,
|
||||
title: note.title === '' ? $i18n.t('Untitled') : note.title
|
||||
}).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
$: if (note) {
|
||||
changeDebounceHandler();
|
||||
}
|
||||
|
||||
$: if (id) {
|
||||
init();
|
||||
}
|
||||
|
||||
function areContentsEqual(a, b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function insertNoteVersion(note) {
|
||||
const current = {
|
||||
json: note.data.content.json,
|
||||
html: note.data.content.html,
|
||||
md: note.data.content.md
|
||||
};
|
||||
const lastVersion = note.data.versions?.at(-1);
|
||||
|
||||
if (!lastVersion || !areContentsEqual(lastVersion, current)) {
|
||||
note.data.versions = (note.data.versions ?? []).concat(current);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function enhanceNoteHandler() {
|
||||
if (selectedModelId === '') {
|
||||
toast.error($i18n.t('Please select a model.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const model = $models.find((model) => model.id === selectedModelId);
|
||||
if (!model) {
|
||||
selectedModelId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
enhancing = true;
|
||||
|
||||
insertNoteVersion(note);
|
||||
await enhanceCompletionHandler(model);
|
||||
|
||||
enhancing = false;
|
||||
versionIdx = null;
|
||||
}
|
||||
|
||||
function setContentByVersion(versionIdx) {
|
||||
if (!note.data.versions?.length) return;
|
||||
let idx = versionIdx;
|
||||
|
||||
if (idx === null) idx = note.data.versions.length - 1; // latest
|
||||
const v = note.data.versions[idx];
|
||||
|
||||
note.data.content.json = v.json;
|
||||
note.data.content.html = v.html;
|
||||
note.data.content.md = v.md;
|
||||
|
||||
if (versionIdx === null) {
|
||||
const lastVersion = note.data.versions.at(-1);
|
||||
const currentContent = note.data.content;
|
||||
|
||||
if (areContentsEqual(lastVersion, currentContent)) {
|
||||
// remove the last version
|
||||
note.data.versions = note.data.versions.slice(0, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function versionNavigateHandler(direction) {
|
||||
if (!note.data.versions || note.data.versions.length === 0) return;
|
||||
|
||||
if (versionIdx === null) {
|
||||
// Get latest snapshots
|
||||
const lastVersion = note.data.versions.at(-1);
|
||||
const currentContent = note.data.content;
|
||||
|
||||
if (!areContentsEqual(lastVersion, currentContent)) {
|
||||
// If the current content is different from the last version, insert a new version
|
||||
insertNoteVersion(note);
|
||||
versionIdx = note.data.versions.length - 1;
|
||||
} else {
|
||||
versionIdx = note.data.versions.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'prev') {
|
||||
if (versionIdx > 0) versionIdx -= 1;
|
||||
} else if (direction === 'next') {
|
||||
if (versionIdx < note.data.versions.length - 1) versionIdx += 1;
|
||||
else versionIdx = null; // Reset to latest
|
||||
|
||||
if (versionIdx === note.data.versions.length - 1) {
|
||||
// If we reach the latest version, reset to null
|
||||
versionIdx = null;
|
||||
}
|
||||
}
|
||||
|
||||
setContentByVersion(versionIdx);
|
||||
}
|
||||
|
||||
const uploadFileHandler = async (file) => {
|
||||
const tempItemId = uuidv4();
|
||||
const fileItem = {
|
||||
type: 'file',
|
||||
file: '',
|
||||
id: null,
|
||||
url: '',
|
||||
name: file.name,
|
||||
collection_name: '',
|
||||
status: 'uploading',
|
||||
size: file.size,
|
||||
error: '',
|
||||
itemId: tempItemId
|
||||
};
|
||||
|
||||
if (fileItem.size == 0) {
|
||||
toast.error($i18n.t('You cannot upload an empty file.'));
|
||||
return null;
|
||||
}
|
||||
|
||||
files = [...files, fileItem];
|
||||
|
||||
try {
|
||||
// During the file upload, file content is automatically extracted.
|
||||
const uploadedFile = await uploadFile(localStorage.token, file);
|
||||
|
||||
if (uploadedFile) {
|
||||
console.log('File upload completed:', {
|
||||
id: uploadedFile.id,
|
||||
name: fileItem.name,
|
||||
collection: uploadedFile?.meta?.collection_name
|
||||
});
|
||||
|
||||
if (uploadedFile.error) {
|
||||
console.warn('File upload warning:', uploadedFile.error);
|
||||
toast.warning(uploadedFile.error);
|
||||
}
|
||||
|
||||
fileItem.status = 'uploaded';
|
||||
fileItem.file = uploadedFile;
|
||||
fileItem.id = uploadedFile.id;
|
||||
fileItem.collection_name =
|
||||
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
|
||||
|
||||
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
||||
|
||||
files = files;
|
||||
} else {
|
||||
files = files.filter((item) => item?.itemId !== tempItemId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
files = files.filter((item) => item?.itemId !== tempItemId);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
note.data.files = files;
|
||||
} else {
|
||||
note.data.files = null;
|
||||
}
|
||||
};
|
||||
|
||||
const inputFilesHandler = async (inputFiles) => {
|
||||
console.log('Input files handler called with:', inputFiles);
|
||||
inputFiles.forEach((file) => {
|
||||
console.log('Processing file:', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
extension: file.name.split('.').at(-1)
|
||||
});
|
||||
|
||||
if (
|
||||
($config?.file?.max_size ?? null) !== null &&
|
||||
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
|
||||
) {
|
||||
console.log('File exceeds max size limit:', {
|
||||
fileSize: file.size,
|
||||
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
|
||||
});
|
||||
toast.error(
|
||||
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
|
||||
maxSize: $config?.file?.max_size
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
|
||||
) {
|
||||
let reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
let imageUrl = event.target.result;
|
||||
|
||||
if ($settings?.imageCompression ?? false) {
|
||||
const width = $settings?.imageCompressionSize?.width ?? null;
|
||||
const height = $settings?.imageCompressionSize?.height ?? null;
|
||||
|
||||
if (width || height) {
|
||||
imageUrl = await compressImage(imageUrl, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
type: 'image',
|
||||
url: `${imageUrl}`
|
||||
}
|
||||
];
|
||||
note.data.files = files;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
uploadFileHandler(file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downloadHandler = async (type) => {
|
||||
console.log('downloadHandler', type);
|
||||
if (type === 'md') {
|
||||
const blob = new Blob([note.data.content.md], { type: 'text/markdown' });
|
||||
saveAs(blob, `${note.title}.md`);
|
||||
} else if (type === 'pdf') {
|
||||
await downloadPdf(note);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPdf = async (note) => {
|
||||
try {
|
||||
// Define a fixed virtual screen size
|
||||
const virtualWidth = 1024; // Fixed width (adjust as needed)
|
||||
const virtualHeight = 1400; // Fixed height (adjust as needed)
|
||||
|
||||
// STEP 1. Get a DOM node to render
|
||||
const html = note.data?.content?.html ?? '';
|
||||
let node;
|
||||
if (html instanceof HTMLElement) {
|
||||
node = html;
|
||||
} else {
|
||||
// If it's HTML string, render to a temporary hidden element
|
||||
node = document.createElement('div');
|
||||
node.innerHTML = html;
|
||||
document.body.appendChild(node);
|
||||
}
|
||||
|
||||
// Render to canvas with predefined width
|
||||
const canvas = await html2canvas(node, {
|
||||
useCORS: true,
|
||||
scale: 2, // Keep at 1x to avoid unexpected enlargements
|
||||
width: virtualWidth, // Set fixed virtual screen width
|
||||
windowWidth: virtualWidth, // Ensure consistent rendering
|
||||
windowHeight: virtualHeight
|
||||
});
|
||||
|
||||
// Remove hidden node if needed
|
||||
if (!(html instanceof HTMLElement)) {
|
||||
document.body.removeChild(node);
|
||||
}
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
|
||||
// A4 page settings
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const pageHeight = 297; // A4 height in mm
|
||||
|
||||
// Maintain aspect ratio
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
// Handle additional pages
|
||||
while (heightLeft > 0) {
|
||||
position -= pageHeight;
|
||||
pdf.addPage();
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
pdf.save(`${note.title}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF', error);
|
||||
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNoteHandler = async (id) => {
|
||||
const res = await deleteNoteById(localStorage.token, id).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Note deleted successfully'));
|
||||
goto('/notes');
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to delete note'));
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = document.getElementById('note-content-container');
|
||||
|
||||
if (element) {
|
||||
element.scrollTop = element?.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const enhanceCompletionHandler = async (model) => {
|
||||
let enhancedContent = {
|
||||
json: null,
|
||||
html: '',
|
||||
md: ''
|
||||
};
|
||||
|
||||
const systemPrompt = `Enhance existing notes using additional context provided from audio transcription or uploaded file content. Your task is to make the notes more useful and comprehensive by incorporating relevant information from the provided context.
|
||||
|
||||
Input will be provided within <notes> and <context> XML tags, providing a structure for the existing notes and context respectively.
|
||||
|
||||
# Output Format
|
||||
|
||||
Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, and emphasis to improve clarity and presentation. Ensure that all integrated content from the context is accurately reflected. Return only the markdown formatted note.
|
||||
`;
|
||||
|
||||
const [res, controller] = await chatCompletion(
|
||||
localStorage.token,
|
||||
{
|
||||
model: model.id,
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
`<notes>${note.data.content.md}</notes>` +
|
||||
(files && files.length > 0
|
||||
? `\n<context>${files.map((file) => `${file.name}: ${file?.file?.data?.content ?? 'Could not extract content'}\n`).join('')}</context>`
|
||||
: '')
|
||||
}
|
||||
]
|
||||
},
|
||||
`${WEBUI_BASE_URL}/api`
|
||||
);
|
||||
|
||||
await tick();
|
||||
|
||||
streaming = true;
|
||||
|
||||
if (res && res.ok) {
|
||||
const reader = res.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(splitStream('\n'))
|
||||
.getReader();
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
let lines = value.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line !== '') {
|
||||
console.log(line);
|
||||
if (line === 'data: [DONE]') {
|
||||
console.log(line);
|
||||
} else {
|
||||
let data = JSON.parse(line.replace(/^data: /, ''));
|
||||
console.log(data);
|
||||
|
||||
if (data.choices && data.choices.length > 0) {
|
||||
const choice = data.choices[0];
|
||||
if (choice.delta && choice.delta.content) {
|
||||
enhancedContent.md += choice.delta.content;
|
||||
enhancedContent.html = marked.parse(enhancedContent.md);
|
||||
|
||||
note.data.content.md = enhancedContent.md;
|
||||
note.data.content.html = enhancedContent.html;
|
||||
note.data.content.json = null;
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streaming = false;
|
||||
};
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if a file is being dragged.
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
dragged = true;
|
||||
} else {
|
||||
dragged = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
const onDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
console.log(e);
|
||||
|
||||
if (e.dataTransfer?.files) {
|
||||
const inputFiles = Array.from(e.dataTransfer?.files);
|
||||
if (inputFiles && inputFiles.length > 0) {
|
||||
console.log(inputFiles);
|
||||
inputFilesHandler(inputFiles);
|
||||
}
|
||||
}
|
||||
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
|
||||
if ($settings?.models) {
|
||||
selectedModelId = $settings?.models[0];
|
||||
} else if ($config?.default_models) {
|
||||
selectedModelId = $config?.default_models.split(',')[0];
|
||||
} else {
|
||||
selectedModelId = '';
|
||||
}
|
||||
|
||||
const dropzoneElement = document.getElementById('note-editor');
|
||||
|
||||
dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.addEventListener('drop', onDrop);
|
||||
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('destroy');
|
||||
const dropzoneElement = document.getElementById('note-editor');
|
||||
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement?.removeEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.removeEventListener('drop', onDrop);
|
||||
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<FilesOverlay show={dragged} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete note?')}
|
||||
on:confirm={() => {
|
||||
deleteNoteHandler(note.id);
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{note.title}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<div class="relative flex-1 w-full h-full flex justify-center" id="note-editor">
|
||||
<Sidebar bind:show={showSettings} className=" bg-white dark:bg-gray-900" width="300px">
|
||||
<div class="flex flex-col px-5 py-3 text-sm">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class=" font-medium text-base">Settings</div>
|
||||
|
||||
<div class=" translate-x-1.5">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
showSettings = !showSettings;
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="size-3" strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<div>
|
||||
<div class=" text-xs font-medium mb-1">Model</div>
|
||||
|
||||
<div class="w-full">
|
||||
<select
|
||||
class="w-full bg-transparent text-sm outline-hidden"
|
||||
bind:value={selectedModelId}
|
||||
>
|
||||
{#each $models as model}
|
||||
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
|
||||
{#if loading}
|
||||
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
||||
<div class="m-auto">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}">
|
||||
<div class="shrink-0 w-full flex justify-between items-center px-4.5 mb-1.5">
|
||||
<div class="w-full flex items-center">
|
||||
<input
|
||||
class="w-full text-2xl font-medium bg-transparent outline-hidden"
|
||||
type="text"
|
||||
bind:value={note.title}
|
||||
placeholder={$i18n.t('Title')}
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2 translate-x-1">
|
||||
{#if note.data?.versions?.length > 0}
|
||||
<div>
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
versionNavigateHandler('prev');
|
||||
}}
|
||||
disabled={(versionIdx === null && note.data.versions.length === 0) ||
|
||||
versionIdx === 0}
|
||||
>
|
||||
<ArrowUturnLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
versionNavigateHandler('next');
|
||||
}}
|
||||
disabled={versionIdx >= note.data.versions.length || versionIdx === null}
|
||||
>
|
||||
<ArrowUturnRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onCopyToClipboard={async () => {
|
||||
const res = await copyToClipboard(note.data.content.md).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Copied to clipboard'));
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</NoteMenu>
|
||||
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
showSettings = !showSettings;
|
||||
}}
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 px-3.5">
|
||||
<div class="flex gap-1 items-center text-xs font-medium text-gray-500 dark:text-gray-500">
|
||||
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
|
||||
<Calendar className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<span>{dayjs(note.created_at / 1000000).calendar()}</span>
|
||||
</button>
|
||||
|
||||
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
|
||||
<Users className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<span> You </span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" flex-1 w-full h-full overflow-auto px-4 pb-20 relative"
|
||||
id="note-content-container"
|
||||
>
|
||||
{#if enhancing}
|
||||
<div
|
||||
class="w-full h-full fixed top-0 left-0 {streaming
|
||||
? ''
|
||||
: ' backdrop-blur-xs bg-white/10 dark:bg-gray-900/10'} flex items-center justify-center z-10 cursor-not-allowed"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if files && files.length > 0}
|
||||
<div class="mb-3.5 mt-1.5 w-full flex gap-1 flex-wrap z-40">
|
||||
{#each files as file, fileIdx}
|
||||
<div class="w-fit">
|
||||
{#if file.type === 'image'}
|
||||
<Image
|
||||
src={file.url}
|
||||
imageClassName=" max-h-96 rounded-lg"
|
||||
dismissible={true}
|
||||
onDismiss={() => {
|
||||
files = files.filter((item, idx) => idx !== fileIdx);
|
||||
note.data.files = files.length > 0 ? files : null;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<FileItem
|
||||
item={file}
|
||||
dismissible={true}
|
||||
url={file.url}
|
||||
name={file.name}
|
||||
type={file.type}
|
||||
size={file?.size}
|
||||
loading={file.status === 'uploading'}
|
||||
on:dismiss={() => {
|
||||
files = files.filter((item) => item?.id !== file.id);
|
||||
note.data.files = files.length > 0 ? files : null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RichTextInput
|
||||
className="input-prose-sm px-0.5"
|
||||
bind:value={note.data.content.json}
|
||||
placeholder={$i18n.t('Write something...')}
|
||||
html={note.data?.content?.html}
|
||||
json={true}
|
||||
editable={versionIdx === null && !enhancing}
|
||||
onChange={(content) => {
|
||||
note.data.content.html = content.html;
|
||||
note.data.content.md = content.md;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute z-20 bottom-0 right-0 p-5 max-w-full {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)]'
|
||||
: ''} w-full flex justify-end"
|
||||
>
|
||||
<div class="flex gap-1 justify-between w-full max-w-full">
|
||||
{#if recording}
|
||||
<div class="flex-1 w-full">
|
||||
<VoiceRecording
|
||||
bind:recording
|
||||
className="p-1 w-full max-w-full"
|
||||
transcribe={false}
|
||||
displayMedia={displayMediaRecord}
|
||||
onCancel={() => {
|
||||
recording = false;
|
||||
displayMediaRecord = false;
|
||||
}}
|
||||
onConfirm={(data) => {
|
||||
if (data?.file) {
|
||||
uploadFileHandler(data?.file);
|
||||
}
|
||||
|
||||
recording = false;
|
||||
displayMediaRecord = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<RecordMenu
|
||||
onRecord={async () => {
|
||||
displayMediaRecord = false;
|
||||
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
|
||||
error: err
|
||||
})
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
onCaptureAudio={async () => {
|
||||
displayMediaRecord = true;
|
||||
|
||||
recording = true;
|
||||
}}
|
||||
onUpload={async () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'audio/*';
|
||||
input.multiple = false;
|
||||
input.click();
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const files = e.target.files;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
await uploadFileHandler(files[0]);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Tooltip content={$i18n.t('Record')} placement="top">
|
||||
<button
|
||||
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
|
||||
type="button"
|
||||
>
|
||||
<MicSolid className="size-4.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</RecordMenu>
|
||||
|
||||
<div
|
||||
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850 dark:bg-gray-850 transition shadow-xl"
|
||||
>
|
||||
<!-- <Tooltip content={$i18n.t('My Notes')} placement="top">
|
||||
<button
|
||||
class="p-2 size-8.5 flex justify-center items-center {selectedVersion === 'note'
|
||||
? 'bg-gray-100 dark:bg-gray-800 '
|
||||
: ' hover:bg-gray-50 dark:hover:bg-gray-800'} rounded-full transition shrink-0"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedVersion = 'note';
|
||||
versionToggleHandler();
|
||||
}}
|
||||
>
|
||||
<Bars3BottomLeft />
|
||||
</button>
|
||||
</Tooltip> -->
|
||||
|
||||
<Tooltip content={$i18n.t('Enhance')} placement="top">
|
||||
<button
|
||||
class="{enhancing
|
||||
? 'p-2'
|
||||
: 'p-2.5'} flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
|
||||
on:click={() => {
|
||||
enhanceNoteHandler();
|
||||
}}
|
||||
disabled={enhancing}
|
||||
type="button"
|
||||
>
|
||||
{#if enhancing}
|
||||
<Spinner className="size-5" />
|
||||
{:else}
|
||||
<SparklesSolid />
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas-pro';
|
||||
|
||||
import dayjs from '$lib/dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
async function loadLocale(locales) {
|
||||
for (const locale of locales) {
|
||||
try {
|
||||
dayjs.locale(locale);
|
||||
break; // Stop after successfully loading the first available locale
|
||||
} catch (error) {
|
||||
console.error(`Could not load locale '${locale}':`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assuming $i18n.languages is an array of language codes
|
||||
$: loadLocale($i18n.languages);
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, getContext, onDestroy } from 'svelte';
|
||||
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
|
||||
|
||||
import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes';
|
||||
import { capitalizeFirstLetter } from '$lib/utils';
|
||||
|
||||
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
||||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Search from '../icons/Search.svelte';
|
||||
import Plus from '../icons/Plus.svelte';
|
||||
import ChevronRight from '../icons/ChevronRight.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import NoteMenu from './Notes/NoteMenu.svelte';
|
||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
let loaded = false;
|
||||
|
||||
let importFiles = '';
|
||||
let query = '';
|
||||
|
||||
let notes = [];
|
||||
let selectedNote = null;
|
||||
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
const init = async () => {
|
||||
notes = await getNotes(localStorage.token);
|
||||
};
|
||||
|
||||
const createNoteHandler = async () => {
|
||||
const res = await createNewNote(localStorage.token, {
|
||||
title: $i18n.t('New Note'),
|
||||
data: {
|
||||
content: {
|
||||
json: null,
|
||||
html: '',
|
||||
md: ''
|
||||
}
|
||||
},
|
||||
meta: null,
|
||||
access_control: null
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
goto(`/notes/${res.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadHandler = async (type) => {
|
||||
console.log('downloadHandler', type);
|
||||
console.log('selectedNote', selectedNote);
|
||||
if (type === 'md') {
|
||||
const blob = new Blob([selectedNote.data.content.md], { type: 'text/markdown' });
|
||||
saveAs(blob, `${selectedNote.title}.md`);
|
||||
} else if (type === 'pdf') {
|
||||
await downloadPdf(selectedNote);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPdf = async (note) => {
|
||||
try {
|
||||
// Define a fixed virtual screen size
|
||||
const virtualWidth = 1024; // Fixed width (adjust as needed)
|
||||
const virtualHeight = 1400; // Fixed height (adjust as needed)
|
||||
|
||||
// STEP 1. Get a DOM node to render
|
||||
const html = note.data?.content?.html ?? '';
|
||||
let node;
|
||||
if (html instanceof HTMLElement) {
|
||||
node = html;
|
||||
} else {
|
||||
// If it's HTML string, render to a temporary hidden element
|
||||
node = document.createElement('div');
|
||||
node.innerHTML = html;
|
||||
document.body.appendChild(node);
|
||||
}
|
||||
|
||||
// Render to canvas with predefined width
|
||||
const canvas = await html2canvas(node, {
|
||||
useCORS: true,
|
||||
scale: 2, // Keep at 1x to avoid unexpected enlargements
|
||||
width: virtualWidth, // Set fixed virtual screen width
|
||||
windowWidth: virtualWidth, // Ensure consistent rendering
|
||||
windowHeight: virtualHeight
|
||||
});
|
||||
|
||||
// Remove hidden node if needed
|
||||
if (!(html instanceof HTMLElement)) {
|
||||
document.body.removeChild(node);
|
||||
}
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
|
||||
// A4 page settings
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const pageHeight = 297; // A4 height in mm
|
||||
|
||||
// Maintain aspect ratio
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
// Handle additional pages
|
||||
while (heightLeft > 0) {
|
||||
position -= pageHeight;
|
||||
pdf.addPage();
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
pdf.save(`${note.title}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF', error);
|
||||
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNoteHandler = async (id) => {
|
||||
const res = await deleteNoteById(localStorage.token, id).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
init();
|
||||
}
|
||||
};
|
||||
|
||||
const inputFilesHandler = async (inputFiles) => {
|
||||
// Check if all the file is a markdown file and extract name and content
|
||||
|
||||
for (const file of inputFiles) {
|
||||
if (file.type !== 'text/markdown') {
|
||||
toast.error($i18n.t('Only markdown files are allowed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const content = event.target.result;
|
||||
let name = file.name.replace(/\.md$/, '');
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
toast.error($i18n.t('Invalid file content'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new note with the content
|
||||
const res = await createNewNote(localStorage.token, {
|
||||
title: name,
|
||||
data: {
|
||||
content: {
|
||||
json: null,
|
||||
html: marked.parse(content ?? ''),
|
||||
md: content
|
||||
}
|
||||
},
|
||||
meta: null,
|
||||
access_control: null
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
init();
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
let dragged = false;
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if a file is being dragged.
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
dragged = true;
|
||||
} else {
|
||||
dragged = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
const onDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
console.log(e);
|
||||
|
||||
if (e.dataTransfer?.files) {
|
||||
const inputFiles = Array.from(e.dataTransfer?.files);
|
||||
if (inputFiles && inputFiles.length > 0) {
|
||||
console.log(inputFiles);
|
||||
inputFilesHandler(inputFiles);
|
||||
}
|
||||
}
|
||||
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('destroy');
|
||||
const dropzoneElement = document.getElementById('notes-container');
|
||||
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement?.removeEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.removeEventListener('drop', onDrop);
|
||||
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await init();
|
||||
loaded = true;
|
||||
|
||||
const dropzoneElement = document.getElementById('notes-container');
|
||||
|
||||
dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.addEventListener('drop', onDrop);
|
||||
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Notes')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<FilesOverlay show={dragged} />
|
||||
|
||||
<div id="notes-container" class="w-full min-h-full">
|
||||
{#if loaded}
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete note?')}
|
||||
on:confirm={() => {
|
||||
deleteNoteHandler(selectedNote.id);
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<div class="px-4.5 @container h-full pt-2">
|
||||
{#if Object.keys(notes).length > 0}
|
||||
<div class="pb-10">
|
||||
{#each Object.keys(notes) as timeRange}
|
||||
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5">
|
||||
{$i18n.t(timeRange)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 bg-gray-50 dark:bg-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
|
||||
>
|
||||
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class=" flex items-center gap-2 self-center mb-1 justify-between">
|
||||
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
|
||||
|
||||
<div>
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
selectedNote = note;
|
||||
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onDelete={() => {
|
||||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18"
|
||||
>
|
||||
{#if note.data?.content?.md}
|
||||
{note.data?.content?.md}
|
||||
{:else}
|
||||
{$i18n.t('No content')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
|
||||
<div>
|
||||
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class="shrink-0 text-gray-500">
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||
<div class="pb-20 text-center">
|
||||
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No Notes')}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
|
||||
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
|
||||
<div class="flex gap-0.5 justify-end w-full">
|
||||
<Tooltip content={$i18n.t('Create Note')}>
|
||||
<button
|
||||
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
createNoteHandler();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4.5" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<!-- <button
|
||||
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
|
||||
>
|
||||
<SparklesSolid className="size-4" />
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- {#if $user?.role === 'admin'}
|
||||
<div class=" flex justify-end w-full mb-3">
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
id="notes-import-input"
|
||||
bind:files={importFiles}
|
||||
type="file"
|
||||
accept=".md"
|
||||
hidden
|
||||
on:change={() => {
|
||||
console.log(importFiles);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
console.log(event.target.result);
|
||||
};
|
||||
|
||||
reader.readAsText(importFiles[0]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
||||
on:click={() => {
|
||||
const notesImportInputElement = document.getElementById('notes-import-input');
|
||||
if (notesImportInputElement) {
|
||||
notesImportInputElement.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
|
||||
|
||||
<div class=" self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if} -->
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
import { showSettings, activeUserIds, USAGE_POOL, mobile, showSidebar, user } from '$lib/stores';
|
||||
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let className = 'max-w-[180px]';
|
||||
|
||||
export let onDownload = (type) => {};
|
||||
export let onDelete = () => {};
|
||||
export let onCopyToClipboard = () => {};
|
||||
|
||||
export let onChange = () => {};
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root
|
||||
bind:open={show}
|
||||
onOpenChange={(state) => {
|
||||
onChange(state);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
<slot />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<slot name="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||
sideOffset={6}
|
||||
side="bottom"
|
||||
align="start"
|
||||
transition={(e) => fade(e, { duration: 100 })}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
>
|
||||
<Download strokeWidth="2" />
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
transition={flyAndScale}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
onDownload('md');
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.md)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
onDownload('pdf');
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
>
|
||||
<Share strokeWidth="2" />
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Share')}</div>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
transition={flyAndScale}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
onCopyToClipboard();
|
||||
}}
|
||||
>
|
||||
<DocumentDuplicate strokeWidth="2" />
|
||||
<div class="flex items-center">{$i18n.t('Copy to clipboard')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<GarbageBin strokeWidth="2" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</slot>
|
||||
</DropdownMenu.Root>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
|
||||
import { showSettings, activeUserIds, USAGE_POOL, mobile, showSidebar, user } from '$lib/stores';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
import Mic from '../icons/Mic.svelte';
|
||||
import CursorArrowRays from '../icons/CursorArrowRays.svelte';
|
||||
import DocumentArrowUp from '../icons/DocumentArrowUp.svelte';
|
||||
import CloudArrowUp from '../icons/CloudArrowUp.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let className = 'max-w-[160px]';
|
||||
|
||||
export let onRecord = () => {};
|
||||
export let onCaptureAudio = () => {};
|
||||
export let onUpload = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root
|
||||
bind:open={show}
|
||||
onOpenChange={(state) => {
|
||||
dispatch('change', state);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
<slot />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<slot name="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||
sideOffset={8}
|
||||
side="bottom"
|
||||
align="start"
|
||||
transition={(e) => fade(e, { duration: 100 })}
|
||||
>
|
||||
<button
|
||||
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
on:click={async () => {
|
||||
onRecord();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<Mic className="size-5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('Record')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
onCaptureAudio();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<CursorArrowRays className="size-5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('Capture Audio')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
onUpload();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<CloudArrowUp className="size-5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('Upload Audio')}</div>
|
||||
</button>
|
||||
</DropdownMenu.Content>
|
||||
</slot>
|
||||
</DropdownMenu.Root>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Sparkles from '../icons/Sparkles.svelte';
|
||||
import SparklesSolid from '../icons/SparklesSolid.svelte';
|
||||
import Mic from '../icons/Mic.svelte';
|
||||
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let name = '';
|
||||
let content = '';
|
||||
|
||||
let voiceInput = false;
|
||||
let loading = false;
|
||||
</script>
|
||||
|
||||
<div class="relative flex-1 w-full h-full flex justify-center overflow-auto px-5">
|
||||
{#if loading}
|
||||
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
||||
<div class="m-auto">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" w-full flex flex-col gap-2 {loading ? 'opacity-20' : ''}">
|
||||
<div class="shrink-0 w-full flex justify-between items-center">
|
||||
<div class="w-full">
|
||||
<input
|
||||
class="w-full text-2xl font-medium bg-transparent outline-hidden"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Title')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex-1 w-full h-full">
|
||||
<RichTextInput
|
||||
className="input-prose-sm"
|
||||
bind:value={content}
|
||||
placeholder={$i18n.t('Write something...')}
|
||||
preserveBreaks={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
|
||||
<div class="flex gap-0.5 justify-end w-full">
|
||||
{#if voiceInput}
|
||||
<div class="flex-1 w-full">
|
||||
<VoiceRecording
|
||||
bind:recording={voiceInput}
|
||||
className="p-1 w-full max-w-full"
|
||||
on:cancel={() => {
|
||||
voiceInput = false;
|
||||
}}
|
||||
on:confirm={(e) => {
|
||||
const { text, filename } = e.detail;
|
||||
|
||||
// url is hostname + /cache/audio/transcription/ + filename
|
||||
const url = `${window.location.origin}/cache/audio/transcription/${filename}`;
|
||||
|
||||
// Open in new tab
|
||||
|
||||
if (content.trim() !== '') {
|
||||
content = `${content}\n\n${text}\n\nRecording: ${url}\n\n`;
|
||||
} else {
|
||||
content = `${content}${text}\n\nRecording: ${url}\n\n`;
|
||||
}
|
||||
|
||||
voiceInput = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Tooltip content={$i18n.t('Voice Input')}>
|
||||
<button
|
||||
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
|
||||
error: err
|
||||
})
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
voiceInput = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<!-- <button
|
||||
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
|
||||
>
|
||||
<SparklesSolid className="size-4" />
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Knowledge')} | {$WEBUI_NAME}
|
||||
{$i18n.t('Knowledge')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
import { page } from '$app/stores';
|
||||
import { mobile, showSidebar, knowledge as _knowledge, config, user } from '$lib/stores';
|
||||
|
||||
import { updateFileDataContentById, uploadFile, deleteFileById } from '$lib/apis/files';
|
||||
import {
|
||||
updateFileDataContentById,
|
||||
uploadFile,
|
||||
deleteFileById,
|
||||
getFileById
|
||||
} from '$lib/apis/files';
|
||||
import {
|
||||
addFileToKnowledgeById,
|
||||
getKnowledgeById,
|
||||
@@ -84,12 +89,15 @@
|
||||
|
||||
let selectedFile = null;
|
||||
let selectedFileId = null;
|
||||
let selectedFileContent = '';
|
||||
|
||||
// Add cache object
|
||||
let fileContentCache = new Map();
|
||||
|
||||
$: if (selectedFileId) {
|
||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
||||
if (file) {
|
||||
file.data = file.data ?? { content: '' };
|
||||
selectedFile = file;
|
||||
fileSelectHandler(file);
|
||||
} else {
|
||||
selectedFile = null;
|
||||
}
|
||||
@@ -394,7 +402,10 @@
|
||||
|
||||
const updateFileContentHandler = async () => {
|
||||
const fileId = selectedFile.id;
|
||||
const content = selectedFile.data.content;
|
||||
const content = selectedFileContent;
|
||||
|
||||
// Clear the cache for this file since we're updating it
|
||||
fileContentCache.delete(fileId);
|
||||
|
||||
const res = updateFileDataContentById(localStorage.token, fileId, content).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
@@ -450,6 +461,29 @@
|
||||
}
|
||||
};
|
||||
|
||||
const fileSelectHandler = async (file) => {
|
||||
try {
|
||||
selectedFile = file;
|
||||
|
||||
// Check cache first
|
||||
if (fileContentCache.has(file.id)) {
|
||||
selectedFileContent = fileContentCache.get(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getFileById(localStorage.token, file.id);
|
||||
if (response) {
|
||||
selectedFileContent = response.data.content;
|
||||
// Cache the content
|
||||
fileContentCache.set(file.id, response.data.content);
|
||||
} else {
|
||||
toast.error($i18n.t('No content found in file.'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Failed to load file content.'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -728,7 +762,7 @@
|
||||
{#key selectedFile.id}
|
||||
<RichTextInput
|
||||
className="input-prose-sm"
|
||||
bind:value={selectedFile.data.content}
|
||||
bind:value={selectedFileContent}
|
||||
placeholder={$i18n.t('Add content here')}
|
||||
preserveBreaks={true}
|
||||
/>
|
||||
@@ -747,7 +781,7 @@
|
||||
<Drawer
|
||||
className="h-full"
|
||||
show={selectedFileId !== null}
|
||||
on:close={() => {
|
||||
onClose={() => {
|
||||
selectedFileId = null;
|
||||
}}
|
||||
>
|
||||
@@ -786,7 +820,7 @@
|
||||
{#key selectedFile.id}
|
||||
<RichTextInput
|
||||
className="input-prose-sm"
|
||||
bind:value={selectedFile.data.content}
|
||||
bind:value={selectedFileContent}
|
||||
placeholder={$i18n.t('Add content here')}
|
||||
preserveBreaks={true}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Mic from '$lib/components/icons/Mic.svelte';
|
||||
import MicSolid from '$lib/components/icons/MicSolid.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import VoiceRecording from '$lib/components/chat/MessageInput/VoiceRecording.svelte';
|
||||
export let show = false;
|
||||
@@ -85,11 +85,11 @@
|
||||
<VoiceRecording
|
||||
bind:recording={voiceInput}
|
||||
className="p-1"
|
||||
on:cancel={() => {
|
||||
onCancel={() => {
|
||||
voiceInput = false;
|
||||
}}
|
||||
on:confirm={(e) => {
|
||||
const { text, filename } = e.detail;
|
||||
onConfirm={(data) => {
|
||||
const { text, filename } = data;
|
||||
content = `${content}${text} `;
|
||||
|
||||
voiceInput = false;
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Mic className="size-5" />
|
||||
<MicSolid className="size-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Models')} | {$WEBUI_NAME}
|
||||
{$i18n.t('Models')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let system = '';
|
||||
let info = {
|
||||
id: '',
|
||||
base_model_id: null,
|
||||
@@ -155,6 +156,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
info.params.system = system.trim() === '' ? null : system;
|
||||
info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
|
||||
Object.keys(info.params).forEach((key) => {
|
||||
if (info.params[key] === '' || info.params[key] === null) {
|
||||
@@ -201,6 +203,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
system = model?.params?.system ?? '';
|
||||
|
||||
params = { ...params, ...model?.params };
|
||||
params.stop = params?.stop
|
||||
? (typeof params.stop === 'string' ? params.stop.split(',') : (params?.stop ?? [])).join(
|
||||
@@ -553,7 +557,7 @@
|
||||
className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
|
||||
placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
|
||||
rows={4}
|
||||
bind:value={info.params.system}
|
||||
bind:value={system}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Prompts')} | {$WEBUI_NAME}
|
||||
{$i18n.t('Prompts')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import { user } from '$lib/stores';
|
||||
import { slugify } from '$lib/utils';
|
||||
|
||||
export let onSubmit: Function;
|
||||
export let edit = false;
|
||||
@@ -25,8 +26,15 @@
|
||||
|
||||
let showAccessControlModal = false;
|
||||
|
||||
$: if (!edit) {
|
||||
command = title !== '' ? `${title.replace(/\s+/g, '-').toLowerCase()}` : '';
|
||||
let hasManualEdit = false;
|
||||
|
||||
$: if (!edit && !hasManualEdit) {
|
||||
command = title !== '' ? slugify(title) : '';
|
||||
}
|
||||
|
||||
// Track manual edits
|
||||
function handleCommandInput(e: Event) {
|
||||
hasManualEdit = true;
|
||||
}
|
||||
|
||||
const submitHandler = async () => {
|
||||
@@ -64,7 +72,7 @@
|
||||
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
|
||||
content = prompt.content;
|
||||
|
||||
accessControl = prompt?.access_control ?? null;
|
||||
accessControl = prompt?.access_control ?? {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -125,6 +133,7 @@
|
||||
class=" w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Command')}
|
||||
bind:value={command}
|
||||
on:input={handleCommandInput}
|
||||
required
|
||||
disabled={edit}
|
||||
/>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$i18n.t('Tools')} | {$WEBUI_NAME}
|
||||
{$i18n.t('Tools')} • {$WEBUI_NAME}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
|
||||
<ul class=" mt-1 list-disc pl-4 text-xs">
|
||||
<li>
|
||||
{$i18n.t('Tools have a function calling system that allows arbitrary code execution')}.
|
||||
{$i18n.t('Tools have a function calling system that allows arbitrary code execution.')}.
|
||||
</li>
|
||||
<li>{$i18n.t('Do not install tools from sources you do not fully trust.')}</li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user