This commit is contained in:
Andrew Baek
2024-10-28 14:09:56 +09:00
243 changed files with 20480 additions and 5556 deletions
+28 -2
View File
@@ -34,6 +34,14 @@ math {
@apply rounded-lg;
}
.input-prose {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.input-prose-sm {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm;
}
.markdown-prose {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
@@ -56,7 +64,7 @@ li p {
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: rgba(217, 217, 227, 0.8);
background-color: rgba(236, 236, 236, 0.8);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
@@ -64,7 +72,7 @@ li p {
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(69, 69, 74, 0.8); /* Darker color for dark theme */
background-color: rgba(33, 33, 33, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
}
@@ -179,3 +187,21 @@ input[type='number'] {
.bg-gray-950-90 {
background-color: rgba(var(--color-gray-950, #0d0d0d), 0.9);
}
.ProseMirror {
@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
}
.ProseMirror:focus {
outline: none;
}
.placeholder::after {
content: attr(data-placeholder);
cursor: text;
pointer-events: none;
float: left;
@apply absolute inset-0 z-0 text-gray-500;
}
+25
View File
@@ -180,6 +180,31 @@ export const userSignUp = async (
return res;
};
export const userSignOut = async () => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res;
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
};
export const addUser = async (
token: string,
name: string,
+226 -10
View File
@@ -32,6 +32,46 @@ export const createNewChat = async (token: string, chat: object) => {
return res;
};
export const importChat = async (
token: string,
chat: object,
meta: object | null,
pinned?: boolean,
folderId?: string | null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/import`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
chat: chat,
meta: meta ?? {},
pinned: pinned,
folder_id: folderId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChatList = async (token: string = '', page: number | null = null) => {
let error = null;
const searchParams = new URLSearchParams();
@@ -199,6 +239,40 @@ export const getChatListBySearchText = async (token: string, text: string, page:
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getChatsByFolderId = async (token: string, folderId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/folder/${folderId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
@@ -264,10 +338,10 @@ export const getAllUserChats = async (token: string) => {
return res;
};
export const getAllChatTags = async (token: string) => {
export const getAllTags = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/tags/all`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all/tags`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -295,6 +369,40 @@ export const getAllChatTags = async (token: string) => {
return res;
};
export const getPinnedChatList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/pinned`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getChatListByTagName = async (token: string = '', tagName: string) => {
let error = null;
@@ -396,11 +504,87 @@ export const getChatByShareId = async (token: string, share_id: string) => {
return res;
};
export const getChatPinnedStatusById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/pinned`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const toggleChatPinnedStatusById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/pin`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const cloneChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone`, {
method: 'GET',
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -466,11 +650,46 @@ export const shareChatById = async (token: string, id: string) => {
return res;
};
export const updateChatFolderIdById = async (token: string, id: string, folderId?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/folder`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
folder_id: folderId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const archiveChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/archive`, {
method: 'GET',
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -640,8 +859,7 @@ export const addTagById = async (token: string, id: string, tagName: string) =>
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
tag_name: tagName,
chat_id: id
name: tagName
})
})
.then(async (res) => {
@@ -652,8 +870,7 @@ export const addTagById = async (token: string, id: string, tagName: string) =>
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
});
@@ -676,8 +893,7 @@ export const deleteTagById = async (token: string, id: string, tagName: string)
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
tag_name: tagName,
chat_id: id
name: tagName
})
})
.then(async (res) => {
+246
View File
@@ -0,0 +1,246 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getConfig = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/config`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/config`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...config
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllFeedbacks = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportAllFeedbacks = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createNewFeedback = async (token: string, feedback: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...feedback
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFeedbackById = async (token: string, feedbackId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/${feedbackId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFeedbackById = async (token: string, feedbackId: string, feedback: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/${feedbackId}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...feedback
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteFeedbackById = async (token: string, feedbackId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/${feedbackId}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+269
View File
@@ -0,0 +1,269 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewFolder = async (token: string, name: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: name
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFolders = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFolderById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderNameById = async (token: string, id: string, name: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: name
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderIsExpandedById = async (
token: string,
id: string,
isExpanded: boolean
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/expanded`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
is_expanded: isExpanded
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderParentIdById = async (token: string, id: string, parentId?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/parent`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
parent_id: parentId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type FolderItems = {
chat_ids: string[];
file_ids: string[];
};
export const updateFolderItemsById = async (token: string, id: string, items: FolderItems) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/items`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
items: items
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteFolderById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+74 -2
View File
@@ -208,7 +208,7 @@ export const updateTaskConfig = async (token: string, config: object) => {
export const generateTitle = async (
token: string = '',
model: string,
prompt: string,
messages: string[],
chat_id?: string
) => {
let error = null;
@@ -222,7 +222,7 @@ export const generateTitle = async (
},
body: JSON.stringify({
model: model,
prompt: prompt,
messages: messages,
...(chat_id && { chat_id: chat_id })
})
})
@@ -245,6 +245,78 @@ export const generateTitle = async (
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
};
export const generateTags = async (
token: string = '',
model: string,
messages: string,
chat_id?: string
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/tags/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
messages: messages,
...(chat_id && { chat_id: chat_id })
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
try {
// Step 1: Safely extract the response string
const response = res?.choices[0]?.message?.content ?? '';
// Step 2: Attempt to fix common JSON format issues like single quotes
const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON
// Step 3: Find the relevant JSON block within the response
const jsonStartIndex = sanitizedResponse.indexOf('{');
const jsonEndIndex = sanitizedResponse.lastIndexOf('}');
// Step 4: Check if we found a valid JSON block (with both `{` and `}`)
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1);
// Step 5: Parse the JSON block
const parsed = JSON.parse(jsonResponse);
// Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array
if (parsed && parsed.tags) {
return Array.isArray(parsed.tags) ? parsed.tags : [];
} else {
return [];
}
}
// If no valid JSON block found, return an empty array
return [];
} catch (e) {
// Catch and safely return empty array on any parsing errors
console.error('Failed to parse response: ', e);
return [];
}
};
export const generateEmoji = async (
token: string = '',
model: string,
+6
View File
@@ -7,6 +7,7 @@ type TextStreamUpdate = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
citations?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedModelId?: any;
error?: any;
usage?: ResponseUsage;
};
@@ -71,6 +72,11 @@ async function* openAIStreamToIterator(
continue;
}
if (parsedData.selected_model_id) {
yield { done: false, value: '', selectedModelId: parsedData.selected_model_id };
continue;
}
yield {
done: false,
value: parsedData.choices?.[0]?.delta?.content ?? '',
+5 -2
View File
@@ -2,12 +2,13 @@
import { onMount, getContext } from 'svelte';
import { Confetti } from 'svelte-confetti';
import { WEBUI_NAME, config } from '$lib/stores';
import { WEBUI_NAME, config, settings } from '$lib/stores';
import { WEBUI_VERSION } from '$lib/constants';
import { getChangelog } from '$lib/apis';
import Modal from './common/Modal.svelte';
import { updateUserSettings } from '$lib/apis/users';
const i18n = getContext('i18n');
@@ -104,8 +105,10 @@
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
on:click={() => {
on:click={async () => {
localStorage.version = $config.version;
await settings.set({ ...$settings, ...{ version: $config.version } });
await updateUserSettings(localStorage.token, { ui: $settings });
show = false;
}}
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
+11 -10
View File
@@ -139,7 +139,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-3 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"
@@ -147,9 +147,9 @@
submitHandler();
}}
>
<div class="flex text-center text-sm font-medium rounded-xl bg-transparent/10 p-1 mb-2">
<div class="flex text-center text-sm font-medium rounded-full bg-transparent/10 p-1 mb-2">
<button
class="w-full rounded-lg p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
class="w-full rounded-full p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
type="button"
on:click={() => {
tab = '';
@@ -157,7 +157,9 @@
>
<button
class="w-full rounded-lg p-1 {tab === 'import' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
class="w-full rounded-full p-1 {tab === 'import'
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
type="button"
on:click={() => {
tab = 'import';
@@ -183,7 +185,7 @@
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class="flex flex-col w-full mt-1">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
@@ -198,7 +200,7 @@
</div>
</div>
<hr class=" dark:border-gray-800 my-3 w-full" />
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
@@ -209,13 +211,12 @@
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class="flex flex-col w-full mt-1">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Password')}</div>
<div class="flex-1">
@@ -271,13 +272,13 @@
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg flex flex-row space-x-1 items-center {loading
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 {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Submit')}
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
+677
View File
@@ -0,0 +1,677 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount, getContext } from 'svelte';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import * as ort from 'onnxruntime-web';
import { AutoModel, AutoTokenizer } from '@huggingface/transformers';
const EMBEDDING_MODEL = 'TaylorAI/bge-micro-v2';
let tokenizer = null;
let model = null;
import { models } from '$lib/stores';
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
import FeedbackMenu from './Evaluations/FeedbackMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Badge from '../common/Badge.svelte';
import Pagination from '../common/Pagination.svelte';
import MagnifyingGlass from '../icons/MagnifyingGlass.svelte';
import Share from '../icons/Share.svelte';
import CloudArrowUp from '../icons/CloudArrowUp.svelte';
import { toast } from 'svelte-sonner';
import Spinner from '../common/Spinner.svelte';
import DocumentArrowUpSolid from '../icons/DocumentArrowUpSolid.svelte';
import DocumentArrowDown from '../icons/DocumentArrowDown.svelte';
import ArrowDownTray from '../icons/ArrowDownTray.svelte';
const i18n = getContext('i18n');
let rankedModels = [];
let feedbacks = [];
let query = '';
let page = 1;
let tagEmbeddings = new Map();
let loaded = false;
let loadingLeaderboard = true;
let debounceTimer;
$: paginatedFeedbacks = feedbacks.slice((page - 1) * 10, page * 10);
type Feedback = {
id: string;
data: {
rating: number;
model_id: string;
sibling_model_ids: string[] | null;
reason: string;
comment: string;
tags: string[];
};
user: {
name: string;
profile_image_url: string;
};
updated_at: number;
};
type ModelStats = {
rating: number;
won: number;
lost: number;
};
//////////////////////
//
// Rank models by Elo rating
//
//////////////////////
const rankHandler = async (similarities: Map<string, number> = new Map()) => {
const modelStats = calculateModelStats(feedbacks, similarities);
rankedModels = $models
.filter((m) => m?.owned_by !== 'arena' && (m?.info?.meta?.hidden ?? false) !== true)
.map((model) => {
const stats = modelStats.get(model.id);
return {
...model,
rating: stats ? Math.round(stats.rating) : '-',
stats: {
count: stats ? stats.won + stats.lost : 0,
won: stats ? stats.won.toString() : '-',
lost: stats ? stats.lost.toString() : '-'
}
};
})
.sort((a, b) => {
if (a.rating === '-' && b.rating !== '-') return 1;
if (b.rating === '-' && a.rating !== '-') return -1;
if (a.rating !== '-' && b.rating !== '-') return b.rating - a.rating;
return a.name.localeCompare(b.name);
});
loadingLeaderboard = false;
};
function calculateModelStats(
feedbacks: Feedback[],
similarities: Map<string, number>
): Map<string, ModelStats> {
const stats = new Map<string, ModelStats>();
const K = 32;
function getOrDefaultStats(modelId: string): ModelStats {
return stats.get(modelId) || { rating: 1000, won: 0, lost: 0 };
}
function updateStats(modelId: string, ratingChange: number, outcome: number) {
const currentStats = getOrDefaultStats(modelId);
currentStats.rating += ratingChange;
if (outcome === 1) currentStats.won++;
else if (outcome === 0) currentStats.lost++;
stats.set(modelId, currentStats);
}
function calculateEloChange(
ratingA: number,
ratingB: number,
outcome: number,
similarity: number
): number {
const expectedScore = 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
return K * (outcome - expectedScore) * similarity;
}
feedbacks.forEach((feedback) => {
const modelA = feedback.data.model_id;
const statsA = getOrDefaultStats(modelA);
let outcome: number;
switch (feedback.data.rating.toString()) {
case '1':
outcome = 1;
break;
case '-1':
outcome = 0;
break;
default:
return; // Skip invalid ratings
}
// If the query is empty, set similarity to 1, else get the similarity from the map
const similarity = query !== '' ? similarities.get(feedback.id) || 0 : 1;
const opponents = feedback.data.sibling_model_ids || [];
opponents.forEach((modelB) => {
const statsB = getOrDefaultStats(modelB);
const changeA = calculateEloChange(statsA.rating, statsB.rating, outcome, similarity);
const changeB = calculateEloChange(statsB.rating, statsA.rating, 1 - outcome, similarity);
updateStats(modelA, changeA, outcome);
updateStats(modelB, changeB, 1 - outcome);
});
});
return stats;
}
//////////////////////
//
// Calculate cosine similarity
//
//////////////////////
const cosineSimilarity = (vecA, vecB) => {
// Ensure the lengths of the vectors are the same
if (vecA.length !== vecB.length) {
throw new Error('Vectors must be the same length');
}
// Calculate the dot product
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] ** 2;
normB += vecB[i] ** 2;
}
// Calculate the magnitudes
normA = Math.sqrt(normA);
normB = Math.sqrt(normB);
// Avoid division by zero
if (normA === 0 || normB === 0) {
return 0;
}
// Return the cosine similarity
return dotProduct / (normA * normB);
};
const calculateMaxSimilarity = (queryEmbedding, tagEmbeddings: Map<string, number[]>) => {
let maxSimilarity = 0;
for (const tagEmbedding of tagEmbeddings.values()) {
const similarity = cosineSimilarity(queryEmbedding, tagEmbedding);
maxSimilarity = Math.max(maxSimilarity, similarity);
}
return maxSimilarity;
};
//////////////////////
//
// Embedding functions
//
//////////////////////
const loadEmbeddingModel = async () => {
// Check if the tokenizer and model are already loaded and stored in the window object
if (!window.tokenizer) {
window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
}
if (!window.model) {
window.model = await AutoModel.from_pretrained(EMBEDDING_MODEL);
}
// Use the tokenizer and model from the window object
tokenizer = window.tokenizer;
model = window.model;
// Pre-compute embeddings for all unique tags
const allTags = new Set(feedbacks.flatMap((feedback) => feedback.data.tags || []));
await getTagEmbeddings(Array.from(allTags));
};
const getEmbeddings = async (text: string) => {
const tokens = await tokenizer(text);
const output = await model(tokens);
// Perform mean pooling on the last hidden states
const embeddings = output.last_hidden_state.mean(1);
return embeddings.ort_tensor.data;
};
const getTagEmbeddings = async (tags: string[]) => {
const embeddings = new Map();
for (const tag of tags) {
if (!tagEmbeddings.has(tag)) {
tagEmbeddings.set(tag, await getEmbeddings(tag));
}
embeddings.set(tag, tagEmbeddings.get(tag));
}
return embeddings;
};
const debouncedQueryHandler = async () => {
loadingLeaderboard = true;
if (query.trim() === '') {
rankHandler();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const queryEmbedding = await getEmbeddings(query);
const similarities = new Map<string, number>();
for (const feedback of feedbacks) {
const feedbackTags = feedback.data.tags || [];
const tagEmbeddings = await getTagEmbeddings(feedbackTags);
const maxSimilarity = calculateMaxSimilarity(queryEmbedding, tagEmbeddings);
similarities.set(feedback.id, maxSimilarity);
}
rankHandler(similarities);
}, 1500); // Debounce for 1.5 seconds
};
$: query, debouncedQueryHandler();
//////////////////////
//
// CRUD operations
//
//////////////////////
const deleteFeedbackHandler = async (feedbackId: string) => {
const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => {
toast.error(err);
return null;
});
if (response) {
feedbacks = feedbacks.filter((f) => f.id !== feedbackId);
}
};
const shareHandler = async () => {
toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
// remove snapshot from feedbacks
const feedbacksToShare = feedbacks.map((f) => {
const { snapshot, user, ...rest } = f;
return rest;
});
console.log(feedbacksToShare);
const url = 'https://openwebui.com';
const tab = await window.open(`${url}/leaderboard`, '_blank');
// Define the event handler function
const messageHandler = (event) => {
if (event.origin !== url) return;
if (event.data === 'loaded') {
tab.postMessage(JSON.stringify(feedbacksToShare), '*');
// Remove the event listener after handling the message
window.removeEventListener('message', messageHandler);
}
};
window.addEventListener('message', messageHandler, false);
};
const exportHandler = async () => {
const _feedbacks = await exportAllFeedbacks(localStorage.token).catch((err) => {
toast.error(err);
return null;
});
if (_feedbacks) {
let blob = new Blob([JSON.stringify(_feedbacks)], {
type: 'application/json'
});
saveAs(blob, `feedback-history-export-${Date.now()}.json`);
}
};
onMount(async () => {
feedbacks = await getAllFeedbacks(localStorage.token);
loaded = true;
rankHandler();
});
</script>
{#if loaded}
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
<div class=" gap-1">
{$i18n.t('Leaderboard')}
</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300 mr-1.5"
>{rankedModels.length}</span
>
</div>
<div class=" flex space-x-2">
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<MagnifyingGlass className="size-3" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
on:focus={() => {
loadEmbeddingModel();
}}
/>
</div>
</Tooltip>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5"
>
{#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto">
<Spinner />
</div>
</div>
{/if}
{#if (rankedModels ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No models found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard
? 'opacity-20'
: ''}"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<th scope="col" class="px-3 py-1.5 cursor-pointer select-none w-3">
{$i18n.t('RK')}
</th>
<th scope="col" class="px-3 py-1.5 cursor-pointer select-none">
{$i18n.t('Model')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-fit">
{$i18n.t('Rating')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-5">
{$i18n.t('Won')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-5">
{$i18n.t('Lost')}
</th>
</tr>
</thead>
<tbody class="">
{#each rankedModels as model, modelIdx (model.id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group">
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
<div class=" line-clamp-1">
{model?.rating !== '-' ? modelIdx + 1 : '-'}
</div>
</td>
<td class="px-3 py-1.5 flex flex-col justify-center">
<div class="flex items-center gap-2">
<div class="flex-shrink-0">
<img
src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
<div class="font-medium text-gray-800 dark:text-gray-200 pr-4">
{model.name}
</div>
</div>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white w-max">
{model.rating}
</td>
<td class=" px-3 py-1.5 text-right font-semibold text-green-500">
<div class=" w-10">
{#if model.stats.won === '-'}
-
{:else}
<span class="hidden group-hover:inline"
>{((model.stats.won / model.stats.count) * 100).toFixed(1)}%</span
>
<span class=" group-hover:hidden">{model.stats.won}</span>
{/if}
</div>
</td>
<td class="px-3 py-1.5 text-right font-semibold text-red-500">
<div class=" w-10">
{#if model.stats.lost === '-'}
-
{:else}
<span class="hidden group-hover:inline"
>{((model.stats.lost / model.stats.count) * 100).toFixed(1)}%</span
>
<span class=" group-hover:hidden">{model.stats.lost}</span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class=" text-gray-500 text-xs mt-1.5 w-full flex justify-end">
<div class=" text-right">
<div class="line-clamp-1">
ⓘ {$i18n.t(
'The evaluation leaderboard is based on the Elo rating system and is updated in real-time.'
)}
</div>
{$i18n.t(
'The leaderboard is currently in beta, and we may adjust the rating calculations as we refine the algorithm.'
)}
</div>
</div>
<div class="pb-4"></div>
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Feedback History')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{feedbacks.length}</span>
</div>
<div>
<div>
<Tooltip content={$i18n.t('Export')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
exportHandler();
}}
>
<ArrowDownTray className="size-3" />
</button>
</Tooltip>
</div>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5"
>
{#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<th scope="col" class="px-3 text-right cursor-pointer select-none w-0">
{$i18n.t('User')}
</th>
<th scope="col" class="px-3 pr-1.5 cursor-pointer select-none">
{$i18n.t('Models')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-fit">
{$i18n.t('Result')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0">
{$i18n.t('Updated At')}
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">
{#each paginatedFeedbacks as feedback (feedback.id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class=" py-0.5 text-right font-semibold">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="flex-shrink-0">
<img
src={feedback?.user?.profile_image_url ?? '/user.png'}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
</Tooltip>
</div>
</td>
<td class=" py-1 pl-3 flex flex-col">
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<div class="font-semibold text-gray-600 dark:text-gray-400 flex-1">
{feedback.data?.model_id}
</div>
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
{#if feedback.data.sibling_model_ids.length > 2}
<!-- {$i18n.t('and {{COUNT}} more')} -->
{feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
'and {{COUNT}} more',
{ COUNT: feedback.data.sibling_model_ids.length - 2 }
)}
{:else}
{feedback.data.sibling_model_ids.join(', ')}
{/if}
</div>
</Tooltip>
{:else}
<div
class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
>
{feedback.data?.model_id}
</div>
{/if}
</div>
</div>
</td>
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback.data.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
{:else if feedback.data.rating.toString() === '0'}
<Badge type="muted" content={$i18n.t('Draw')} />
{:else if feedback.data.rating.toString() === '-1'}
<Badge type="error" content={$i18n.t('Lost')} />
{/if}
</div>
</td>
<td class=" px-3 py-1 text-right font-medium">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1 text-right font-semibold">
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal />
</button>
</FeedbackMenu>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if feedbacks.length > 0}
<div class=" flex flex-col justify-end w-full text-right gap-1">
<div class="line-clamp-1 text-gray-500 text-xs">
{$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')}
</div>
<div class="flex space-x-1 ml-auto">
<Tooltip
content={$i18n.t(
'To protect your privacy, only ratings, model IDs, tags, and metadata are shared from your feedback—your chat logs remain private and are not included.'
)}
>
<button
class="flex text-xs items-center px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
shareHandler();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Share to OpenWebUI Community')}
</div>
<div class=" self-center">
<CloudArrowUp className="size-3" strokeWidth="3" />
</div>
</button>
</Tooltip>
</div>
</div>
{/if}
{#if feedbacks.length > 10}
<Pagination bind:page count={feedbacks.length} perPage={10} />
{/if}
<div class="pb-12"></div>
{/if}
@@ -0,0 +1,46 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
let show = false;
</script>
<Dropdown bind:show on:change={(e) => {}}>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[150px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<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={() => {
dispatch('delete');
show = false;
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
+31 -11
View File
@@ -17,6 +17,9 @@
import WebSearch from './Settings/WebSearch.svelte';
import { config } from '$lib/stores';
import { getBackendConfig } from '$lib/apis';
import ChartBar from '../icons/ChartBar.svelte';
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Evaluations from './Settings/Evaluations.svelte';
const i18n = getContext('i18n');
@@ -42,7 +45,7 @@
class="tabs flex flex-row overflow-x-auto space-x-1 max-w-full lg:space-x-0 lg:space-y-1 lg:flex-col lg:flex-none lg:w-44 dark:text-gray-200 text-xs text-left scrollbar-none"
>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'general'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -68,7 +71,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'users'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -92,7 +95,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'connections'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -116,7 +119,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'models'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -142,7 +145,22 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'evaluations'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'evaluations';
}}
>
<div class=" self-center mr-2">
<DocumentChartBar />
</div>
<div class=" self-center">{$i18n.t('Evaluations')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'documents'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -172,7 +190,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'web'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -196,7 +214,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'interface'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -222,7 +240,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'audio'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -249,7 +267,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'images'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -275,7 +293,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'pipelines'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -305,7 +323,7 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'db'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-50 dark:hover:bg-gray-850'}"
@@ -357,6 +375,8 @@
/>
{:else if selectedTab === 'models'}
<Models />
{:else if selectedTab === 'evaluations'}
<Evaluations />
{:else if selectedTab === 'documents'}
<Documents
on:save={async () => {
+98 -7
View File
@@ -38,6 +38,9 @@
let STT_OPENAI_API_KEY = '';
let STT_ENGINE = '';
let STT_MODEL = '';
let STT_WHISPER_MODEL = '';
let STT_WHISPER_MODEL_LOADING = false;
// eslint-disable-next-line no-undef
let voices: SpeechSynthesisVoice[] = [];
@@ -99,18 +102,23 @@
OPENAI_API_BASE_URL: STT_OPENAI_API_BASE_URL,
OPENAI_API_KEY: STT_OPENAI_API_KEY,
ENGINE: STT_ENGINE,
MODEL: STT_MODEL
MODEL: STT_MODEL,
WHISPER_MODEL: STT_WHISPER_MODEL
}
});
if (res) {
saveHandler();
getBackendConfig()
.then(config.set)
.catch(() => {});
config.set(await getBackendConfig());
}
};
const sttModelUpdateHandler = async () => {
STT_WHISPER_MODEL_LOADING = true;
await updateConfigHandler();
STT_WHISPER_MODEL_LOADING = false;
};
onMount(async () => {
const res = await getAudioConfig(localStorage.token);
@@ -134,6 +142,7 @@
STT_ENGINE = res.stt.ENGINE;
STT_MODEL = res.stt.MODEL;
STT_WHISPER_MODEL = res.stt.WHISPER_MODEL;
}
await getVoices();
@@ -201,6 +210,88 @@
</div>
</div>
</div>
{:else if STT_ENGINE === ''}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('STT Model')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('Set whisper model')}
bind:value={STT_WHISPER_MODEL}
/>
</div>
<button
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => {
sttModelUpdateHandler();
}}
disabled={STT_WHISPER_MODEL_LOADING}
>
{#if STT_WHISPER_MODEL_LOADING}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(`Open WebUI uses faster-whisper internally.`)}
<a
class=" hover:underline dark:text-gray-200 text-gray-800"
href="https://github.com/SYSTRAN/faster-whisper"
target="_blank"
>
{$i18n.t(
`Click here to learn more about faster-whisper and see the available models.`
)}
</a>
</div>
</div>
{/if}
</div>
@@ -339,7 +430,7 @@
<datalist id="tts-model-list">
{#each models as model}
<option value={model.id} />
<option value={model.id} class="bg-gray-50 dark:bg-gray-700" />
{/each}
</datalist>
</div>
@@ -380,7 +471,7 @@
<datalist id="tts-model-list">
{#each models as model}
<option value={model.id} />
<option value={model.id} class="bg-gray-50 dark:bg-gray-700" />
{/each}
</datalist>
</div>
@@ -460,7 +551,7 @@
</div>
<div class="flex justify-end text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -439,7 +439,7 @@
<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"
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"
type="submit"
>
{$i18n.t('Save')}
+145 -131
View File
@@ -26,6 +26,9 @@
import ResetVectorDBConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import { text } from '@sveltejs/kit';
import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext('i18n');
@@ -48,6 +51,7 @@
let tikaServerUrl = '';
let showTikaServerUrl = false;
let textSplitter = '';
let chunkSize = 0;
let chunkOverlap = 0;
let pdfExtractImages = true;
@@ -177,6 +181,7 @@
max_count: fileMaxCount === '' ? null : fileMaxCount
},
chunk: {
text_splitter: textSplitter,
chunk_overlap: chunkOverlap,
chunk_size: chunkSize
},
@@ -222,11 +227,13 @@
await setRerankingConfig();
querySettings = await getQuerySettings(localStorage.token);
const res = await getRAGConfig(localStorage.token);
if (res) {
pdfExtractImages = res.pdf_extract_images;
textSplitter = res.chunk.text_splitter;
chunkSize = res.chunk.chunk_size;
chunkOverlap = res.chunk.chunk_overlap;
@@ -535,13 +542,13 @@
<hr class=" dark:border-gray-850" />
<div class="">
<div class="text-sm font-medium">{$i18n.t('Content Extraction')}</div>
<div class="text-sm font-medium mb-1">{$i18n.t('Content Extraction')}</div>
<div class="flex w-full justify-between mt-2">
<div class="flex w-full justify-between">
<div class="self-center text-xs font-medium">{$i18n.t('Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
bind:value={contentExtractionEngine}
on:change={(e) => {
showTikaServerUrl = e.target.value === 'tika';
@@ -554,7 +561,7 @@
</div>
{#if showTikaServerUrl}
<div class="flex w-full mt-2">
<div class="flex w-full mt-1">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
@@ -568,10 +575,137 @@
<hr class=" dark:border-gray-850" />
<div class="">
<div class="text-sm font-medium">{$i18n.t('Files')}</div>
<div class=" ">
<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
<div class=" my-2 flex gap-1.5">
<div class=" flex gap-1.5">
<div class="flex flex-col w-full gap-1">
<div class=" text-xs font-medium w-full">{$i18n.t('Top K')}</div>
<div class="w-full">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" flex flex-col w-full gap-1">
<div class="text-xs font-medium w-full">
{$i18n.t('Minimum Score')}
</div>
<div class="w-full">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
bind:value={querySettings.r}
autocomplete="off"
min="0.0"
title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
/>
</div>
</div>
{/if}
</div>
{#if querySettings.hybrid === true}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
)}
</div>
{/if}
<div class="mt-2">
<div class=" mb-1 text-xs font-medium">{$i18n.t('RAG Template')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={querySettings.template}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
/>
</Tooltip>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div class=" ">
<div class="mb-1 text-sm font-medium">{$i18n.t('Chunk Params')}</div>
<div class="flex w-full justify-between mb-1.5">
<div class="self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
bind:value={textSplitter}
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
</select>
</div>
</div>
<div class=" flex gap-1.5">
<div class=" w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1">{$i18n.t('Chunk Size')}</div>
<div class="self-center">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Chunk Size')}
bind:value={chunkSize}
autocomplete="off"
min="0"
/>
</div>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Overlap')}
</div>
<div class="self-center">
<input
class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Chunk Overlap')}
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div>
</div>
<div class="my-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
<div>
<Switch bind:state={pdfExtractImages} />
</div>
</div>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div class="">
<div class="text-sm font-medium mb-1">{$i18n.t('Files')}</div>
<div class=" flex gap-1.5">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Max Upload Size')}
@@ -623,128 +757,6 @@
<hr class=" dark:border-gray-850" />
<div class=" ">
<div class=" text-sm font-medium">{$i18n.t('Query Params')}</div>
<div class=" flex gap-1">
<div class=" flex w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit">{$i18n.t('Top K')}</div>
<div class="self-center p-3">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium min-w-fit">
{$i18n.t('Minimum Score')}
</div>
<div class="self-center p-3">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
bind:value={querySettings.r}
autocomplete="off"
min="0.0"
title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
/>
</div>
</div>
{/if}
</div>
{#if querySettings.hybrid === true}
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
)}
</div>
<hr class=" dark:border-gray-850 my-3" />
{/if}
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('RAG Template')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
bind:value={querySettings.template}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
class="w-full rounded-lg px-4 py-3 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="4"
/>
</Tooltip>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div class=" ">
<div class=" text-sm font-medium">{$i18n.t('Chunk Params')}</div>
<div class=" my-2 flex gap-1.5">
<div class=" w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1">{$i18n.t('Chunk Size')}</div>
<div class="self-center">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Chunk Size')}
bind:value={chunkSize}
autocomplete="off"
min="0"
/>
</div>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Overlap')}
</div>
<div class="self-center">
<input
class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="number"
placeholder={$i18n.t('Enter Chunk Overlap')}
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div>
</div>
<div class="my-3">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
pdfExtractImages = !pdfExtractImages;
}}>{pdfExtractImages ? $i18n.t('On') : $i18n.t('Off')}</button
>
</div>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div>
<button
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
@@ -794,13 +806,15 @@
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
<div class=" self-center text-sm font-medium">
{$i18n.t('Reset Vector Storage/Knowledge')}
</div>
</button>
</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"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -0,0 +1,158 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { models, user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
const dispatch = createEventDispatcher();
import { getModels } from '$lib/apis';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Model from './Evaluations/Model.svelte';
import ModelModal from './Evaluations/ModelModal.svelte';
import { getConfig, updateConfig } from '$lib/apis/evaluations';
const i18n = getContext('i18n');
let config = null;
let showAddModel = false;
const submitHandler = async () => {
config = await updateConfig(localStorage.token, config).catch((err) => {
toast.error(err);
return null;
});
if (config) {
toast.success('Settings saved successfully');
}
};
const addModelHandler = async (model) => {
config.EVALUATION_ARENA_MODELS.push(model);
config.EVALUATION_ARENA_MODELS = [...config.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(await getModels(localStorage.token));
};
const editModelHandler = async (model, modelIdx) => {
config.EVALUATION_ARENA_MODELS[modelIdx] = model;
config.EVALUATION_ARENA_MODELS = [...config.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(await getModels(localStorage.token));
};
const deleteModelHandler = async (modelIdx) => {
config.EVALUATION_ARENA_MODELS = config.EVALUATION_ARENA_MODELS.filter(
(m, mIdx) => mIdx !== modelIdx
);
await submitHandler();
models.set(await getModels(localStorage.token));
};
onMount(async () => {
if ($user.role === 'admin') {
config = await getConfig(localStorage.token).catch((err) => {
toast.error(err);
return null;
});
}
});
</script>
<ModelModal
bind:show={showAddModel}
on:submit={async (e) => {
addModelHandler(e.detail);
}}
/>
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
submitHandler();
dispatch('save');
}}
>
<div class="overflow-y-scroll scrollbar-hidden h-full">
{#if config !== null}
<div class="">
<div class="text-sm font-medium mb-2">{$i18n.t('General Settings')}</div>
<div class=" mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div>
<Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}>
<Switch bind:state={config.ENABLE_EVALUATION_ARENA_MODELS} />
</Tooltip>
</div>
</div>
{#if config.ENABLE_EVALUATION_ARENA_MODELS}
<hr class=" border-gray-50 dark:border-gray-700/10 my-2" />
<div class="flex justify-between items-center mb-2">
<div class="text-sm font-medium">{$i18n.t('Manage Arena Models')}</div>
<div>
<Tooltip content={$i18n.t('Add Arena Model')}>
<button
class="p-1"
type="button"
on:click={() => {
showAddModel = true;
}}
>
<Plus />
</button>
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-2">
{#if (config?.EVALUATION_ARENA_MODELS ?? []).length > 0}
{#each config.EVALUATION_ARENA_MODELS as model, index}
<Model
{model}
on:edit={(e) => {
editModelHandler(e.detail, index);
}}
on:delete={(e) => {
deleteModelHandler(index);
}}
/>
{/each}
{:else}
<div class=" text-center text-xs text-gray-500">
{$i18n.t(
`Using the default arena model with all models. Click the plus button to add custom models.`
)}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">
<Spinner className="size-6" />
</div>
</div>
{/if}
</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"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
@@ -0,0 +1,63 @@
<script lang="ts">
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ModelModal from './ModelModal.svelte';
export let model;
let showModel = false;
</script>
<ModelModal
bind:show={showModel}
edit={true}
{model}
on:submit={async (e) => {
dispatch('edit', e.detail);
}}
on:delete={async () => {
dispatch('delete');
}}
/>
<div class="py-0.5">
<div class="flex justify-between items-center mb-1">
<div class="flex flex-col flex-1">
<div class="flex gap-2.5 items-center">
<img
src={model.meta.profile_image_url}
alt={model.name}
class="size-8 rounded-full object-cover shrink-0"
/>
<div class="w-full flex flex-col">
<div class="flex items-center gap-1">
<div class="flex-shrink-0 line-clamp-1">
{model.name}
</div>
</div>
<div class="flex items-center gap-1">
<div class=" text-xs w-full text-gray-500 bg-transparent line-clamp-1">
{model?.meta?.description ?? model.id}
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center">
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
showModel = true;
}}
>
<Cog6 />
</button>
</div>
</div>
</div>
@@ -0,0 +1,418 @@
<script>
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import Modal from '$lib/components/common/Modal.svelte';
import { models } from '$lib/stores';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import { toast } from 'svelte-sonner';
export let show = false;
export let edit = false;
export let model = null;
let name = '';
let id = '';
$: if (name) {
generateId();
}
const generateId = () => {
if (!edit) {
id = name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
};
let profileImageUrl = '/favicon.png';
let description = '';
let selectedModelId = '';
let modelIds = [];
let filterMode = 'include';
let imageInputElement;
let loading = false;
const addModelHandler = () => {
if (selectedModelId) {
modelIds = [...modelIds, selectedModelId];
selectedModelId = '';
}
};
const submitHandler = () => {
loading = true;
if (!name || !id) {
loading = false;
toast.error('Name and ID are required, please fill them out');
return;
}
if (!edit) {
if ($models.find((model) => model.name === name)) {
loading = false;
name = '';
toast.error('Model name already exists, please choose a different one');
return;
}
}
const model = {
id: id,
name: name,
meta: {
profile_image_url: profileImageUrl,
description: description || null,
model_ids: modelIds.length > 0 ? modelIds : null,
filter_mode: modelIds.length > 0 ? (filterMode ? filterMode : null) : null
}
};
dispatch('submit', model);
loading = false;
show = false;
name = '';
id = '';
profileImageUrl = '/favicon.png';
description = '';
modelIds = [];
selectedModelId = '';
};
const initModel = () => {
if (model) {
name = model.name;
id = model.id;
profileImageUrl = model.meta.profile_image_url;
description = model.meta.description;
modelIds = model.meta.model_ids || [];
filterMode = model.meta?.filter_mode ?? 'include';
}
};
$: if (show) {
initModel();
}
onMount(() => {
initModel();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Arena Model')}
{:else}
{$i18n.t('Add Arena Model')}
{/if}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="px-1">
<div class="flex justify-center pb-3">
<input
bind:this={imageInputElement}
type="file"
hidden
accept="image/*"
on:change={(e) => {
const files = e.target.files ?? [];
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 250x250
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 250 * aspectRatio;
newHeight = 250;
} else {
newWidth = 250;
newHeight = 250 / aspectRatio;
}
// Set the canvas size
canvas.width = 250;
canvas.height = 250;
// Calculate the position to center the image
const offsetX = (250 - newWidth) / 2;
const offsetY = (250 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
profileImageUrl = compressedSrc;
e.target.files = null;
};
};
if (
files.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(
files[0]['type']
)
) {
reader.readAsDataURL(files[0]);
}
}}
/>
<button
class="relative rounded-full w-fit h-fit shrink-0"
type="button"
on:click={() => {
imageInputElement.click();
}}
>
<img
src={profileImageUrl}
class="size-16 rounded-full object-cover shrink-0"
alt="Profile"
/>
<div
class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
>
<div class="my-auto text-white">
<PencilSolid className="size-4" />
</div>
</div>
</button>
</div>
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={name}
placeholder={$i18n.t('Model Name')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('ID')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={id}
placeholder={$i18n.t('Model ID')}
autocomplete="off"
required
disabled={edit}
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Models')}</div>
<div>
<button
class=" text-xs text-gray-500"
type="button"
on:click={() => {
filterMode = filterMode === 'include' ? 'exclude' : 'include';
}}
>
{#if filterMode === 'include'}
{$i18n.t('Include')}
{:else}
{$i18n.t('Exclude')}
{/if}
</button>
</div>
</div>
{#if modelIds.length > 0}
<div class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{$models.find((model) => model.id === modelId)?.name}
</div>
<div class="flex-shrink-0">
<button
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2">
{$i18n.t('Leave empty to include all models or select specific models')}
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models.filter((m) => m?.owned_by !== 'arena') as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
dispatch('delete', model);
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<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 {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
@@ -137,7 +137,7 @@
<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"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -648,7 +648,7 @@
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg flex flex-row space-x-1 items-center {loading
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 {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
@@ -14,6 +14,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const dispatch = createEventDispatcher();
@@ -23,6 +24,7 @@
TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '',
TITLE_GENERATION_PROMPT_TEMPLATE: '',
TAGS_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_SEARCH_QUERY: true,
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE: ''
};
@@ -60,7 +62,7 @@
>
<div class=" overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div>
<div class=" mb-2.5 text-sm font-medium flex">
<div class=" mb-2.5 text-sm font-medium flex items-center">
<div class=" mr-1">{$i18n.t('Set Task Model')}</div>
<Tooltip
content={$i18n.t(
@@ -73,7 +75,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
class="size-3.5"
>
<path
stroke-linecap="round"
@@ -124,10 +126,22 @@
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
<Textarea
bind:value={taskConfig.TITLE_GENERATION_PROMPT_TEMPLATE}
class="w-full rounded-lg py-3 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="3"
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
/>
</Tooltip>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Tags Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.TAGS_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
/>
</Tooltip>
@@ -151,10 +165,8 @@
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
<Textarea
bind:value={taskConfig.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE}
class="w-full rounded-lg py-3 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="3"
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
@@ -349,7 +361,7 @@
<div class="flex justify-end text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -763,7 +763,7 @@
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each $models.filter((m) => !(m?.preset ?? false) && m.owned_by === 'ollama' && (selectedOllamaUrlIdx === null ? true : (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
<option value={model.name} class="bg-gray-50 dark:bg-gray-700"
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name +
' (' +
(model.ollama.size / 1024 ** 3).toFixed(1) +
@@ -546,12 +546,14 @@
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
{#if PIPELINES_LIST !== null && PIPELINES_LIST.length > 0}
<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"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
{/if}
</form>
+23 -114
View File
@@ -24,9 +24,17 @@
}
};
let chatDeletion = true;
let chatEdit = true;
let chatTemporary = true;
onMount(async () => {
permissions = await getUserPermissions(localStorage.token);
chatDeletion = permissions?.chat?.deletion ?? true;
chatEdit = permissions?.chat?.editing ?? true;
chatTemporary = permissions?.chat?.temporary ?? true;
const res = await getModelFilterConfig(localStorage.token);
if (res) {
whitelistEnabled = res.enabled;
@@ -43,7 +51,13 @@
// console.log('submit');
await setDefaultModels(localStorage.token, defaultModelId);
await updateUserPermissions(localStorage.token, permissions);
await updateUserPermissions(localStorage.token, {
chat: {
deletion: chatDeletion,
editing: chatEdit,
temporary: chatTemporary
}
});
await updateModelFilterConfig(localStorage.token, whitelistEnabled, whitelistModels);
saveHandler();
@@ -54,127 +68,22 @@
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('User Permissions')}</div>
<div class=" flex w-full justify-between">
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
permissions.chat.deletion = !(permissions?.chat?.deletion ?? true);
}}
type="button"
>
{#if permissions?.chat?.deletion ?? true}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Allow')}</span>
{: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="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
clip-rule="evenodd"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t("Don't Allow")}</span>
{/if}
</button>
<Switch bind:state={chatDeletion} />
</div>
<div class=" flex w-full justify-between">
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Editing')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
permissions.chat.editing = !(permissions?.chat?.editing ?? true);
}}
type="button"
>
{#if permissions?.chat?.editing ?? true}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Allow')}</span>
{: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="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
clip-rule="evenodd"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t("Don't Allow")}</span>
{/if}
</button>
<Switch bind:state={chatEdit} />
</div>
<div class=" flex w-full justify-between">
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Temporary Chat')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
permissions.chat.temporary = !(permissions?.chat?.temporary ?? true);
}}
type="button"
>
{#if permissions?.chat?.temporary ?? true}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Allow')}</span>
{: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="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
clip-rule="evenodd"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t("Don't Allow")}</span>
{/if}
</button>
<Switch bind:state={chatTemporary} />
</div>
</div>
@@ -210,7 +119,7 @@
<div class=" space-y-1">
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class="flex justify-between items-center text-xs my-3 pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Whitelisting')}</div>
<Switch bind:state={whitelistEnabled} />
@@ -296,7 +205,7 @@
<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"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -310,7 +310,7 @@
</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"
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"
type="submit"
>
{$i18n.t('Save')}
+6 -1
View File
@@ -120,6 +120,11 @@
}
});
if (contents.length === 0) {
showControls.set(false);
showArtifacts.set(false);
}
selectedContentIdx = contents ? contents.length - 1 : 0;
};
@@ -191,7 +196,7 @@
showArtifacts.set(false);
}}
>
<ArrowLeft className="size-3.5" />
<ArrowLeft className="size-3.5 text-gray-900 dark:text-white" />
</button>
</div>
+230 -120
View File
@@ -10,7 +10,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { Unsubscriber, Writable } from 'svelte/store';
import { get, type Unsubscriber, type Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
@@ -20,6 +20,7 @@
config,
type Model,
models,
tags as allTags,
settings,
showSidebar,
WEBUI_NAME,
@@ -46,7 +47,11 @@
import { generateChatCompletion } from '$lib/apis/ollama';
import {
addTagById,
createNewChat,
deleteTagById,
deleteTagsById,
getAllTags,
getChatById,
getChatList,
getTagsById,
@@ -62,7 +67,8 @@
generateTitle,
generateSearchQuery,
chatAction,
generateMoACompletion
generateMoACompletion,
generateTags
} from '$lib/apis';
import Banner from '../common/Banner.svelte';
@@ -78,12 +84,15 @@
let loaded = false;
const eventTarget = new EventTarget();
let controlPane;
let controlPaneComponent;
let stopResponseFlag = false;
let autoScroll = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
let navbarElement;
let showEventConfirmation = false;
let eventConfirmationTitle = '';
let eventConfirmationMessage = '';
@@ -124,7 +133,7 @@
loaded = true;
window.setTimeout(() => scrollToBottom(), 0);
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
@@ -174,10 +183,30 @@
message.statusHistory = [data];
}
} else if (type === 'citation') {
if (message?.citations) {
message.citations.push(data);
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
if (!message?.code_executions) {
message.code_executions = [];
}
const existingCodeExecutionIndex = message.code_executions.findIndex(
(execution) => execution.id === data.id
);
if (existingCodeExecutionIndex !== -1) {
message.code_executions[existingCodeExecutionIndex] = data;
} else {
message.code_executions.push(data);
}
message.code_executions = message.code_executions;
} else {
message.citations = [data];
// Regular citation.
if (message?.citations) {
message.citations.push(data);
} else {
message.citations = [data];
}
}
} else if (type === 'message') {
message.content += data.content;
@@ -243,7 +272,7 @@
if (event.data.type === 'input:prompt') {
console.debug(event.data.text);
const inputElement = document.getElementById('chat-textarea');
const inputElement = document.getElementById('chat-input');
if (inputElement) {
prompt = event.data.text;
@@ -290,14 +319,9 @@
if (controlPane && !$mobile) {
try {
if (value) {
const currentSize = controlPane.getSize();
if (currentSize === 0) {
const size = parseInt(localStorage?.chatControlsSize ?? '30');
controlPane.resize(size ? size : 30);
}
controlPaneComponent.openPane();
} else {
controlPane.resize(0);
controlPane.collapse();
}
} catch (e) {
// ignore
@@ -307,10 +331,11 @@
if (!value) {
showCallOverlay.set(false);
showOverview.set(false);
showArtifacts.set(false);
}
});
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
chats.subscribe(() => {});
@@ -420,22 +445,53 @@
if ($page.url.searchParams.get('models')) {
selectedModels = $page.url.searchParams.get('models')?.split(',');
} else if ($page.url.searchParams.get('model')) {
selectedModels = $page.url.searchParams.get('model')?.split(',');
const urlModels = $page.url.searchParams.get('model')?.split(',');
if (urlModels.length === 1) {
const m = $models.find((m) => m.id === urlModels[0]);
if (!m) {
const modelSelectorButton = document.getElementById('model-selector-0-button');
if (modelSelectorButton) {
modelSelectorButton.click();
await tick();
const modelSelectorInput = document.getElementById('model-search-input');
if (modelSelectorInput) {
modelSelectorInput.focus();
modelSelectorInput.value = urlModels[0];
modelSelectorInput.dispatchEvent(new Event('input'));
}
}
} else {
selectedModels = urlModels;
}
} else {
selectedModels = urlModels;
}
} else if ($settings?.models) {
selectedModels = $settings?.models;
} else if ($config?.default_models) {
console.log($config?.default_models.split(',') ?? '');
selectedModels = $config?.default_models.split(',');
} else {
selectedModels = [''];
}
selectedModels = selectedModels.filter((modelId) => $models.map((m) => m.id).includes(modelId));
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
if ($models.length > 0) {
selectedModels = [$models[0].id];
} else {
selectedModels = [''];
}
}
console.log(selectedModels);
if ($page.url.searchParams.get('youtube')) {
uploadYoutubeTranscription(
`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`
);
}
if ($page.url.searchParams.get('web-search') === 'true') {
webSearchEnabled = true;
}
@@ -480,7 +536,7 @@
settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
}
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
setTimeout(() => chatInput?.focus(), 0);
};
@@ -492,7 +548,10 @@
});
if (chat) {
tags = await getTags();
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
const chatContent = chat.chat;
if (chatContent) {
@@ -736,93 +795,106 @@
//////////////////////////
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
let _responses = [];
console.log('submitPrompt', $chatId);
const messages = createMessagesList(history.currentId);
console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history.currentId);
selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (userPrompt === '') {
toast.error($i18n.t('Please enter a prompt'));
return;
}
if (selectedModels.includes('')) {
toast.error($i18n.t('Model not selected'));
} else if (messages.length != 0 && messages.at(-1).done != true) {
return;
}
if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
console.log('wait');
} else if (messages.length != 0 && messages.at(-1).error) {
return;
}
if (messages.length != 0 && messages.at(-1).error) {
// Error in response
toast.error(
$i18n.t(
`Oops! There was an error in the previous response. Please try again or contact admin.`
)
);
} else if (
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
if (
files.length > 0 &&
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
) {
// Upload not done
toast.error(
$i18n.t(
`Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.`
)
$i18n.t(`Oops! There are files still uploading. Please wait for the upload to complete.`)
);
} else if (
return;
}
if (
($config?.file?.max_count ?? null) !== null &&
files.length + chatFiles.length > $config?.file?.max_count
) {
console.log(chatFiles.length, files.length);
toast.error(
$i18n.t(`You can only chat with a maximum of {{maxCount}} file(s) at a time.`, {
maxCount: $config?.file?.max_count
})
);
} else {
// Reset chat input textarea
const chatTextAreaElement = document.getElementById('chat-textarea');
if (chatTextAreaElement) {
chatTextAreaElement.value = '';
chatTextAreaElement.style.height = '';
}
const _files = JSON.parse(JSON.stringify(files));
chatFiles.push(..._files.filter((item) => ['doc', 'file', 'collection'].includes(item.type)));
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
files = [];
prompt = '';
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
content: userPrompt,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// Wait until history/message have been updated
await tick();
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
return;
}
let _responses = [];
prompt = '';
await tick();
// Reset chat input textarea
const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) {
chatInputContainer.value = '';
chatInputContainer.style.height = '';
}
const _files = JSON.parse(JSON.stringify(files));
chatFiles.push(..._files.filter((item) => ['doc', 'file', 'collection'].includes(item.type)));
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
files = [];
prompt = '';
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
content: userPrompt,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// Wait until history/message have been updated
await tick();
// focus on chat input
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
return _responses;
};
@@ -942,10 +1014,10 @@
}
let _response = null;
if (model?.owned_by === 'openai') {
_response = await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (model) {
if (model?.owned_by === 'ollama') {
_response = await sendPromptOllama(model, prompt, responseMessageId, _chatId);
} else if (model) {
_response = await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
}
_responses.push(_response);
@@ -998,7 +1070,7 @@
// Prepare the base message object
const baseMessage = {
role: message.role,
content: message.content
content: message?.merged?.content ?? message.content
};
// Extract and format image URLs if any exist
@@ -1341,8 +1413,13 @@
const messages = createMessagesList(responseMessageId);
if (messages.length == 2 && messages.at(-1).content !== '' && selectedModels[0] === model.id) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const title = await generateChatTitle(userPrompt);
const title = await generateChatTitle(messages);
await setChatTitle(_chatId, title);
if ($settings?.autoTags ?? true) {
await setChatTags(messages);
}
}
return _response;
@@ -1458,10 +1535,7 @@
content: [
{
type: 'text',
text:
arr.length - 1 !== idx
? message.content
: (message?.raContent ?? message.content)
text: message?.merged?.content ?? message.content
},
...message.files
.filter((file) => file.type === 'image')
@@ -1474,10 +1548,7 @@
]
}
: {
content:
arr.length - 1 !== idx
? message.content
: (message?.raContent ?? message.content)
content: message?.merged?.content ?? message.content
})
})),
seed: params?.seed ?? $settings?.params?.seed ?? undefined,
@@ -1518,7 +1589,7 @@
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
for await (const update of textStream) {
const { value, done, citations, error, usage } = update;
const { value, done, citations, selectedModelId, error, usage } = update;
if (error) {
await handleOpenAIError(error, null, model, responseMessage);
break;
@@ -1538,6 +1609,12 @@
responseMessage.info = { ...usage, openai: true, usage };
}
if (selectedModelId) {
responseMessage.selectedModelId = selectedModelId;
responseMessage.arena = true;
continue;
}
if (citations) {
responseMessage.citations = citations;
// Only remove status if it was initially set
@@ -1655,8 +1732,13 @@
const messages = createMessagesList(responseMessageId);
if (messages.length == 2 && selectedModels[0] === model.id) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const title = await generateChatTitle(userPrompt);
const title = await generateChatTitle(messages);
await setChatTitle(_chatId, title);
if ($settings?.autoTags ?? true) {
await setChatTags(messages);
}
}
return _response;
@@ -1812,21 +1894,21 @@
}
};
const generateChatTitle = async (userPrompt) => {
const generateChatTitle = async (messages) => {
if ($settings?.title?.auto ?? true) {
const title = await generateTitle(
localStorage.token,
selectedModels[0],
userPrompt,
$chatId
).catch((error) => {
console.error(error);
return 'New Chat';
});
const lastMessage = messages.at(-1);
const modelId = selectedModels[0];
const title = await generateTitle(localStorage.token, modelId, messages, $chatId).catch(
(error) => {
console.error(error);
return 'New Chat';
}
);
return title;
} else {
return `${userPrompt}`;
return 'New Chat';
}
};
@@ -1843,6 +1925,40 @@
}
};
const setChatTags = async (messages) => {
if (!$temporaryChatEnabled) {
const currentTags = await getTagsById(localStorage.token, $chatId);
if (currentTags.length > 0) {
const res = await deleteTagsById(localStorage.token, $chatId);
if (res) {
allTags.set(await getAllTags(localStorage.token));
}
}
const lastMessage = messages.at(-1);
const modelId = selectedModels[0];
let generatedTags = await generateTags(localStorage.token, modelId, messages, $chatId).catch(
(error) => {
console.error(error);
return [];
}
);
generatedTags = generatedTags.filter(
(tag) => !currentTags.find((t) => t.id === tag.replaceAll(' ', '_').toLowerCase())
);
console.log(generatedTags);
for (const tag of generatedTags) {
await addTagById(localStorage.token, $chatId, tag);
}
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
}
};
const getWebSearchResults = async (
model: string,
parentId: string,
@@ -1928,12 +2044,6 @@
}
};
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const initChatHandler = async () => {
if (!$temporaryChatEnabled) {
chat = await createNewChat(localStorage.token, {
@@ -2025,6 +2135,7 @@
{/if}
<Navbar
bind:this={navbarElement}
chat={{
id: $chatId,
chat: {
@@ -2161,9 +2272,8 @@
}}
on:submit={async (e) => {
if (e.detail) {
prompt = '';
await tick();
submitPrompt(e.detail);
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>
@@ -2206,9 +2316,8 @@
}}
on:submit={async (e) => {
if (e.detail) {
prompt = '';
await tick();
submitPrompt(e.detail);
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>
@@ -2218,6 +2327,7 @@
</Pane>
<ChatControls
bind:this={controlPaneComponent}
bind:history
bind:chatFiles
bind:params
+56 -13
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import { slide } from 'svelte/transition';
import { Pane, PaneResizer } from 'paneforge';
import { onDestroy, onMount, tick } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview, showArtifacts } from '$lib/stores';
@@ -10,9 +11,9 @@
import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte';
import Overview from './Overview.svelte';
import { Pane, PaneResizer } from 'paneforge';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Artifacts from './Artifacts.svelte';
import { min } from '@floating-ui/utils';
export let history;
export let models = [];
@@ -35,6 +36,16 @@
let largeScreen = false;
let dragged = false;
let minSize = 0;
export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) {
pane.resize(parseInt(localStorage?.chatControlsSize));
} else {
pane.resize(minSize);
}
};
const handleMediaQuery = async (e) => {
if (e.matches) {
largeScreen = true;
@@ -71,6 +82,32 @@
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
// Select the container element you want to observe
const container = document.getElementById('chat-container');
// initialize the minSize based on the container width
minSize = Math.floor((350 / container.clientWidth) * 100);
// Create a new ResizeObserver instance
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
// calculate the percentage of 200px
const percentage = (350 / width) * 100;
// set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage);
if ($showControls) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize);
}
}
}
});
// Start observing the container's size changes
resizeObserver.observe(container);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
});
@@ -163,23 +200,29 @@
</div>
</PaneResizer>
{/if}
<Pane
bind:pane
defaultSize={$showControls
? parseInt(localStorage?.chatControlsSize ?? '30')
? parseInt(localStorage?.chatControlsSize ?? '30')
: 30
: 0}
defaultSize={0}
onResize={(size) => {
if (size === 0) {
showControls.set(false);
} else {
if (!$showControls) {
showControls.set(true);
console.log('size', size, minSize);
if ($showControls && pane.isExpanded()) {
if (size < minSize) {
pane.resize(minSize);
}
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
localStorage.chatControlsSize = size;
}
localStorage.chatControlsSize = size;
}
}}
onCollapse={() => {
showControls.set(false);
}}
collapsible={true}
class="pt-8"
>
{#if $showControls}
@@ -187,7 +230,7 @@
<div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-50 dark:border-gray-800'} rounded-lg z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-50 dark:border-gray-850'} rounded-xl z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
>
{#if $showCallOverlay}
<div class="w-full h-full flex justify-center">
@@ -82,8 +82,8 @@
>
<div>
<div class=" capitalize line-clamp-1" in:fade={{ duration: 200 }}>
{#if models[selectedModelIdx]?.info}
{models[selectedModelIdx]?.info?.name}
{#if models[selectedModelIdx]?.name}
{models[selectedModelIdx]?.name}
{:else}
{$i18n.t('Hello, {{name}}', { name: $user.name })}
{/if}
@@ -30,7 +30,7 @@
<div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5">
{#if chatFiles.length > 0}
<Collapsible title={$i18n.t('Files')} open={true}>
<Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full">
<div class="flex flex-col gap-1 mt-1.5" slot="content">
{#each chatFiles as file, fileIdx}
<FileItem
@@ -56,31 +56,31 @@
</div>
</Collapsible>
<hr class="my-2 border-gray-100 dark:border-gray-800" />
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible title={$i18n.t('Valves')}>
<div class="text-sm mt-1.5" slot="content">
<Collapsible title={$i18n.t('Valves')} buttonClassName="w-full">
<div class="text-sm" slot="content">
<Valves />
</div>
</Collapsible>
<hr class="my-2 border-gray-100 dark:border-gray-800" />
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
<Collapsible title={$i18n.t('System Prompt')} open={true}>
<div class=" mt-1.5" slot="content">
<Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full">
<div class="" slot="content">
<textarea
bind:value={params.system}
class="w-full rounded-lg px-3.5 py-2.5 text-sm dark:text-gray-300 dark:bg-gray-850 border border-gray-100 dark:border-gray-800 outline-none resize-none"
class="w-full text-xs py-1.5 bg-transparent outline-none resize-none"
rows="4"
placeholder={$i18n.t('Enter system prompt')}
/>
</div>
</Collapsible>
<hr class="my-2 border-gray-100 dark:border-gray-800" />
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
<Collapsible title={$i18n.t('Advanced Params')} open={true}>
<Collapsible title={$i18n.t('Advanced Params')} open={true} buttonClassName="w-full">
<div class="text-sm mt-1.5" slot="content">
<div>
<AdvancedParams admin={$user?.role === 'admin'} bind:params />
+378 -179
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
const dispatch = createEventDispatcher();
@@ -29,6 +31,7 @@
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte';
import XMark from '../icons/XMark.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
const i18n = getContext('i18n');
@@ -52,9 +55,10 @@
let recording = false;
let chatTextAreaElement: HTMLTextAreaElement;
let filesInputElement;
let chatInputContainerElement;
let chatInputElement;
let filesInputElement;
let commandsElement;
let inputFiles;
@@ -69,9 +73,10 @@
);
$: if (prompt) {
if (chatTextAreaElement) {
chatTextAreaElement.style.height = '';
chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}
@@ -86,6 +91,7 @@
const uploadFileHandler = async (file) => {
console.log(file);
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
file: '',
@@ -95,10 +101,16 @@
collection_name: '',
status: 'uploading',
size: file.size,
error: ''
error: '',
itemId: tempItemId
};
files = [...files, fileItem];
if (fileItem.size == 0) {
toast.error($i18n.t('You cannot upload an empty file.'));
return null;
}
files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
@@ -121,6 +133,10 @@
const uploadedFile = await uploadFile(localStorage.token, file);
if (uploadedFile) {
if (uploadedFile.error) {
toast.warning(uploadedFile.error);
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
@@ -129,11 +145,11 @@
files = files;
} else {
files = files.filter((item) => item.status !== null);
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(e);
files = files.filter((item) => item.status !== null);
files = files.filter((item) => item?.itemId !== tempItemId);
}
};
@@ -184,7 +200,13 @@
const onDragOver = (e) => {
e.preventDefault();
dragged = true;
// Check if a file is being dragged.
if (e.dataTransfer?.types?.includes('Files')) {
dragged = true;
} else {
dragged = false;
}
};
const onDragLeave = () => {
@@ -200,8 +222,6 @@
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
inputFilesHandler(inputFiles);
} else {
toast.error($i18n.t(`File not found.`));
}
}
@@ -209,7 +229,10 @@
};
onMount(() => {
window.setTimeout(() => chatTextAreaElement?.focus(), 0);
window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}, 0);
window.addEventListener('keydown', handleKeyDown);
@@ -312,7 +335,8 @@
atSelectedModel = data.data;
}
chatTextAreaElement?.focus();
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
}}
/>
</div>
@@ -347,16 +371,16 @@
recording = false;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
}}
on:confirm={async (e) => {
const response = e.detail;
prompt = `${prompt}${response} `;
const { text, filename } = e.detail;
prompt = `${prompt}${text} `;
recording = false;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
if ($settings?.speechAutoSend ?? false) {
dispatch('submit', prompt);
@@ -474,7 +498,9 @@
}}
onClose={async () => {
await tick();
chatTextAreaElement?.focus();
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}}
>
<button
@@ -496,177 +522,348 @@
</InputMenu>
</div>
<textarea
id="chat-textarea"
bind:this={chatTextAreaElement}
class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
{#if $settings?.richTextInput ?? true}
<div
bind:this={chatInputContainerElement}
id="chat-input-container"
class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-[48px] overflow-auto"
>
<RichTextInput
bind:this={chatInputElement}
id="chat-input"
trim={true}
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
on:enter={async (e) => {
if (prompt !== '') {
dispatch('submit', prompt);
}
}}
on:input={async (e) => {
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}}
on:focus={async (e) => {
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}}
on:keypress={(e) => {
e = e.detail.event;
}}
on:keydown={async (e) => {
e = e.detail.event;
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
dispatch('submit', prompt);
}
}
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement = document.getElementById('commands-container');
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
createMessagePair(prompt);
}
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement =
document.getElementById('commands-container');
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
createMessagePair(prompt);
}
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
regenerateButton?.click();
}
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
regenerateButton?.click();
}
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
console.log(userMessageElement);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
console.log(userMessageElement);
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
commandOptionButton?.click();
} else if (e.key === 'Tab') {
const words = findWordIndices(prompt);
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
commandOptionButton?.click();
}
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
if (e.key === 'Escape') {
console.log('Escape');
atSelectedModel = undefined;
}
}}
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
const clipboardData = e.clipboardData || window.clipboardData;
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
if (e.key === 'Escape') {
console.log('Escape');
atSelectedModel = undefined;
}
}}
rows="1"
on:input={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
on:paste={async (e) => {
const clipboardData = e.clipboardData || window.clipboardData;
reader.readAsDataURL(blob);
}
}
}
}}
/>
</div>
{:else}
<textarea
id="chat-input"
bind:this={chatInputElement}
class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
dispatch('submit', prompt);
}
}
}
}}
/>
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement =
document.getElementById('commands-container');
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
createMessagePair(prompt);
}
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
regenerateButton?.click();
}
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
} else if (e.key === 'Tab') {
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}
if (e.key === 'Escape') {
console.log('Escape');
atSelectedModel = undefined;
}
}}
rows="1"
on:input={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
on:paste={async (e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
}
}
}
}}
/>
{/if}
<div class="self-end mb-2 flex space-x-1 mr-1">
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
@@ -796,25 +993,27 @@
{/if}
{:else}
<div class=" flex items-center mb-1.5">
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={() => {
stopResponse();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
<Tooltip content={$i18n.t('Stop')}>
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={() => {
stopResponse();
}}
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
</div>
@@ -25,17 +25,17 @@
};
let command = '';
$: command = (prompt?.trim() ?? '').split(' ')?.at(-1) ?? '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
</script>
{#if ['/', '#', '@'].includes(command?.charAt(0))}
{#if ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2)}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
{:else if command?.charAt(0) === '#'}
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
{command}
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
@@ -55,7 +55,6 @@
files = [
...files,
{
type: e?.detail?.meta?.document ? 'file' : 'collection',
...e.detail,
status: 'processed'
}
@@ -2,6 +2,10 @@
import { toast } from 'svelte-sonner';
import Fuse from 'fuse.js';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { createEventDispatcher, tick, getContext, onMount } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores';
@@ -42,7 +46,7 @@
dispatch('select', item);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
@@ -53,7 +57,7 @@
dispatch('url', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
@@ -64,7 +68,7 @@
dispatch('youtube', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
@@ -72,7 +76,13 @@
};
onMount(() => {
let legacy_documents = $knowledge.filter((item) => item?.meta?.document);
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
@@ -101,12 +111,44 @@
]
: [];
items = [...$knowledge, ...legacy_collections].map((item) => {
return {
let collections = $knowledge
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
type: 'collection'
}));
let collection_files =
$knowledge.length > 0
? [
...$knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description }
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
type: 'file'
}))
]
: [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
fuse = new Fuse(items, {
keys: ['name', 'description']
@@ -117,20 +159,17 @@
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<div
id="commands-container"
class="pl-2 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-medium mt-2">#</div>
</div>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-r-xl bg-white dark:bg-gray-900 dark:text-gray-100"
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden">
{#each filteredItems as item, idx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left {idx === selectedIdx
class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
@@ -141,38 +180,87 @@
on:mousemove={() => {
selectedIdx = idx;
}}
on:focus={() => {}}
>
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1"
>
Document
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded uppercase text-xs font-bold px-1"
>
Collection
</div>
{/if}
<div>
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
>
Document
</div>
{:else if item?.type === 'file'}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
>
File
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
>
Collection
</div>
{/if}
<div class="line-clamp-1">
{item.name}
<div class="line-clamp-1">
{item?.name}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{item?.description}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{item?.description}
</div>
</button>
<!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5">
{#if !item.legacy && (item?.files ?? []).length > 0}
{#each item?.files ?? [] as file, fileIdx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-850 dark:hover:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
console.log(file);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
>
<div>
<div
class=" font-medium text-black dark:text-gray-100 flex items-center gap-1"
>
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
>
File
</div>
<div class="line-clamp-1">
{file?.meta?.name}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(file.updated_at * 1000).fromNow()}
</div>
</div>
</button>
{/each}
{:else}
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t('No files found.')}
</div>
{/if}
</div> -->
{/each}
{#if prompt
@@ -58,7 +58,7 @@
onMount(async () => {
await tick();
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
@@ -68,15 +68,11 @@
{#if filteredItems.length > 0}
<div
id="commands-container"
class="pl-2 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-medium mt-2">@</div>
</div>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredItems as model, modelIdx}
@@ -1,5 +1,5 @@
<script lang="ts">
import { prompts } from '$lib/stores';
import { prompts, user } from '$lib/stores';
import {
findWordIndices,
getUserPosition,
@@ -78,6 +78,12 @@
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (command.content.includes('{{USER_NAME}}')) {
console.log($user);
const name = $user.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (command.content.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
@@ -110,21 +116,20 @@
prompt = text;
const chatInputElement = document.getElementById('chat-textarea');
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
chatInputElement?.focus();
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
await tick();
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
if (chatInputElement) {
chatInputElement.focus();
chatInputElement.dispatchEvent(new Event('input'));
}
};
</script>
@@ -132,17 +137,13 @@
{#if filteredPrompts.length > 0}
<div
id="commands-container"
class="pl-2 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-medium mt-2">/</div>
</div>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
<div class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden">
{#each filteredPrompts as prompt, promptIdx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
@@ -169,7 +170,7 @@
</div>
<div
class=" px-2 pb-1 text-xs text-gray-600 dark:text-gray-100 bg-white dark:bg-gray-900 rounded-br-xl flex items-center space-x-1"
class=" px-2 pt-0.5 pb-1 text-xs text-gray-600 dark:text-gray-100 bg-white dark:bg-gray-900 rounded-b-xl flex items-center space-x-1"
>
<div>
<svg
@@ -61,9 +61,14 @@
<div
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
>
<div class="flex-1 flex items-center gap-2">
<WrenchSolid />
<Tooltip content={tools[toolId]?.description ?? ''} className="flex-1">
<div class="flex-1">
<Tooltip
content={tools[toolId]?.description ?? ''}
placement="top-start"
className="flex flex-1 gap-2 items-center"
>
<WrenchSolid />
<div class=" line-clamp-1">{tools[toolId].name}</div>
</Tooltip>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, tick, getContext } from 'svelte';
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { config, settings } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
@@ -11,6 +11,7 @@
const dispatch = createEventDispatcher();
export let recording = false;
export let className = ' p-2.5 w-full max-w-full';
let loading = false;
let confirmed = false;
@@ -51,7 +52,7 @@
let audioChunks = [];
const MIN_DECIBELS = -45;
const VISUALIZER_BUFFER_LENGTH = 300;
let VISUALIZER_BUFFER_LENGTH = 300;
let visualizerData = Array(VISUALIZER_BUFFER_LENGTH).fill(0);
@@ -141,8 +142,8 @@
});
if (res) {
console.log(res.text);
dispatch('confirm', res.text);
console.log(res);
dispatch('confirm', res);
}
};
@@ -213,7 +214,7 @@
transcription = `${transcription}${transcript}`;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
@@ -269,13 +270,48 @@
await mediaRecorder.stop();
}
clearInterval(durationCounter);
if (stream) {
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
};
let resizeObserver;
let containerWidth;
let maxVisibleItems = 300;
$: maxVisibleItems = Math.floor(containerWidth / 5); // 2px width + 0.5px gap
onMount(() => {
// listen to width changes
resizeObserver = new ResizeObserver(() => {
VISUALIZER_BUFFER_LENGTH = Math.floor(window.innerWidth / 4);
if (visualizerData.length > VISUALIZER_BUFFER_LENGTH) {
visualizerData = visualizerData.slice(visualizerData.length - VISUALIZER_BUFFER_LENGTH);
} else {
visualizerData = Array(VISUALIZER_BUFFER_LENGTH - visualizerData.length)
.fill(0)
.concat(visualizerData);
}
});
resizeObserver.observe(document.body);
});
onDestroy(() => {
// remove resize observer
resizeObserver.disconnect();
});
</script>
<div
bind:clientWidth={containerWidth}
class="{loading
? ' bg-gray-100/50 dark:bg-gray-850/50'
: 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex p-2.5"
: 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex justify-between {className}"
>
<div class="flex items-center mr-1">
<button
@@ -310,146 +346,152 @@
class="flex flex-1 self-center items-center justify-between ml-2 mx-1 overflow-hidden h-6"
dir="rtl"
>
<div class="flex-1 flex items-center gap-0.5 h-6">
<div
class="flex items-center gap-0.5 h-6 w-full max-w-full overflow-hidden overflow-x-hidden flex-wrap"
>
{#each visualizerData.slice().reverse() as rms}
<div
class="w-[2px]
<div class="flex items-center h-full">
<div
class="w-[2px] flex-shrink-0
{loading
? ' bg-gray-500 dark:bg-gray-400 '
: 'bg-indigo-500 dark:bg-indigo-400 '}
? ' bg-gray-500 dark:bg-gray-400 '
: 'bg-indigo-500 dark:bg-indigo-400 '}
inline-block h-full"
style="height: {Math.min(100, Math.max(14, rms * 100))}%;"
/>
style="height: {Math.min(100, Math.max(14, rms * 100))}%;"
/>
</div>
{/each}
</div>
</div>
<div class=" mx-1.5 pr-1 flex justify-center items-center">
<div
class="text-sm
<div class="flex">
<div class=" mx-1.5 pr-1 flex justify-center items-center">
<div
class="text-sm
{loading ? ' text-gray-500 dark:text-gray-400 ' : ' text-indigo-400 '}
font-medium flex-1 mx-auto text-center"
>
{formatSeconds(durationSeconds)}
</div>
</div>
<div class="flex items-center mr-1">
{#if loading}
<div class=" text-gray-500 rounded-full cursor-not-allowed">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
><style>
.spinner_OSmW {
transform-origin: center;
animation: spinner_T6mA 0.75s step-end infinite;
}
@keyframes spinner_T6mA {
8.3% {
transform: rotate(30deg);
}
16.6% {
transform: rotate(60deg);
}
25% {
transform: rotate(90deg);
}
33.3% {
transform: rotate(120deg);
}
41.6% {
transform: rotate(150deg);
}
50% {
transform: rotate(180deg);
}
58.3% {
transform: rotate(210deg);
}
66.6% {
transform: rotate(240deg);
}
75% {
transform: rotate(270deg);
}
83.3% {
transform: rotate(300deg);
}
91.6% {
transform: rotate(330deg);
}
100% {
transform: rotate(360deg);
}
}
</style><g class="spinner_OSmW"
><rect x="11" y="1" width="2" height="5" opacity=".14" /><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(30 12 12)"
opacity=".29"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(60 12 12)"
opacity=".43"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(90 12 12)"
opacity=".57"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(120 12 12)"
opacity=".71"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(150 12 12)"
opacity=".86"
/><rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" /></g
></svg
>
</div>
{:else}
<button
type="button"
class="p-1.5 bg-indigo-500 text-white dark:bg-indigo-500 dark:text-blue-950 rounded-full"
on:click={async () => {
await confirmRecording();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="size-4"
{formatSeconds(durationSeconds)}
</div>
</div>
<div class="flex items-center">
{#if loading}
<div class=" text-gray-500 rounded-full cursor-not-allowed">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
><style>
.spinner_OSmW {
transform-origin: center;
animation: spinner_T6mA 0.75s step-end infinite;
}
@keyframes spinner_T6mA {
8.3% {
transform: rotate(30deg);
}
16.6% {
transform: rotate(60deg);
}
25% {
transform: rotate(90deg);
}
33.3% {
transform: rotate(120deg);
}
41.6% {
transform: rotate(150deg);
}
50% {
transform: rotate(180deg);
}
58.3% {
transform: rotate(210deg);
}
66.6% {
transform: rotate(240deg);
}
75% {
transform: rotate(270deg);
}
83.3% {
transform: rotate(300deg);
}
91.6% {
transform: rotate(330deg);
}
100% {
transform: rotate(360deg);
}
}
</style><g class="spinner_OSmW"
><rect x="11" y="1" width="2" height="5" opacity=".14" /><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(30 12 12)"
opacity=".29"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(60 12 12)"
opacity=".43"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(90 12 12)"
opacity=".57"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(120 12 12)"
opacity=".71"
/><rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(150 12 12)"
opacity=".86"
/><rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" /></g
></svg
>
</div>
{:else}
<button
type="button"
class="p-1.5 bg-indigo-500 text-white dark:bg-indigo-500 dark:text-blue-950 rounded-full"
on:click={async () => {
await confirmRecording();
}}
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
{/if}
</div>
</div>
</div>
+6 -12
View File
@@ -330,20 +330,14 @@
await tick();
const chatInputElement = document.getElementById('chat-textarea');
if (chatInputElement) {
const chatInputContainerElement = document.getElementById('chat-input-container');
if (chatInputContainerElement) {
prompt = p;
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
chatInputElement.focus();
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
chatInputContainerElement.focus();
}
await tick();
+201 -49
View File
@@ -1,67 +1,219 @@
<script lang="ts">
import { getContext } from 'svelte';
import CitationsModal from './CitationsModal.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
const i18n = getContext('i18n');
export let citations = [];
let _citations = [];
$: _citations = citations.reduce((acc, citation) => {
citation.document.forEach((document, index) => {
const metadata = citation.metadata?.[index];
const id = metadata?.source ?? 'N/A';
let source = citation?.source;
if (metadata?.name) {
source = { ...source, name: metadata.name };
}
// Check if ID looks like a URL
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id };
}
const existingSource = acc.find((item) => item.id === id);
if (existingSource) {
existingSource.document.push(document);
existingSource.metadata.push(metadata);
} else {
acc.push({
id: id,
source: source,
document: [document],
metadata: metadata ? [metadata] : []
});
}
});
return acc;
}, []);
let showPercentage = false;
let showRelevance = true;
let showCitationModal = false;
let selectedCitation = null;
let selectedCitation: any = null;
let isCollapsibleOpen = false;
function calculateShowRelevance(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
const inRange = distances.filter((d) => d !== undefined && d >= -1 && d <= 1).length;
const outOfRange = distances.filter((d) => d !== undefined && (d < -1 || d > 1)).length;
if (distances.length === 0) {
return false;
}
if (
(inRange === distances.length - 1 && outOfRange === 1) ||
(outOfRange === distances.length - 1 && inRange === 1)
) {
return false;
}
return true;
}
function shouldShowPercentage(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
return distances.every((d) => d !== undefined && d >= -1 && d <= 1);
}
$: {
_citations = citations.reduce((acc, citation) => {
citation.document.forEach((document, index) => {
const metadata = citation.metadata?.[index];
const distance = citation.distances?.[index];
const id = metadata?.source ?? 'N/A';
let source = citation?.source;
if (metadata?.name) {
source = { ...source, name: metadata.name };
}
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id, ...source, url: id };
}
const existingSource = acc.find((item) => item.id === id);
if (existingSource) {
existingSource.document.push(document);
existingSource.metadata.push(metadata);
if (distance !== undefined) existingSource.distances.push(distance);
} else {
acc.push({
id: id,
source: source,
document: [document],
metadata: metadata ? [metadata] : [],
distances: distance !== undefined ? [distance] : undefined
});
}
});
return acc;
}, []);
showRelevance = calculateShowRelevance(_citations);
showPercentage = shouldShowPercentage(_citations);
}
</script>
<CitationsModal bind:show={showCitationModal} citation={selectedCitation} />
<CitationsModal
bind:show={showCitationModal}
citation={selectedCitation}
{showPercentage}
{showRelevance}
/>
{#if _citations.length > 0}
<div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
{#if _citations.length <= 3}
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1 truncate">
{citation.source.name}
</div>
</button>
</div>
{/each}
{:else}
<Collapsible bind:open={isCollapsibleOpen} className="w-full">
<div
class="flex items-center gap-1 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
<div class="flex-grow flex items-center gap-1 overflow-hidden">
<span class="whitespace-nowrap hidden sm:inline">{$i18n.t('References from')}</span>
<div class="flex items-center">
{#if _citations.length > 1 && _citations
.slice(0, 2)
.reduce((acc, citation) => acc + citation.source.name.length, 0) <= 50}
{#each _citations.slice(0, 2) as citation, idx}
<div class="flex items-center">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96 text-xs font-semibold"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
{#if idx === 0}<span class="mr-1">,</span>
{/if}
</div>
{/each}
{:else}
{#each _citations.slice(0, 1) as citation, idx}
<div class="flex items-center">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96 text-xs font-semibold"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
</div>
{/each}
{/if}
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
<span class="text-gray-600 dark:text-gray-400">
{_citations.length -
(_citations.length > 1 &&
_citations
.slice(0, 2)
.reduce((acc, citation) => acc + citation.source.name.length, 0) <= 50
? 2
: 1)}
</span>
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
<div class="flex-shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</button>
</div>
{/each}
</div>
<div slot="content" class="mt-2">
<div class="flex flex-wrap gap-2">
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
</div>
{/each}
</div>
</div>
</Collapsible>
{/if}
</div>
{/if}
@@ -2,21 +2,44 @@
import { getContext, onMount, tick } from 'svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let show = false;
export let citation;
export let showPercentage = false;
export let showRelevance = true;
let mergedDocuments = [];
function calculatePercentage(distance: number) {
if (distance < 0) return 100;
if (distance > 1) return 0;
return Math.round((1 - distance) * 10000) / 100;
}
function getRelevanceColor(percentage: number) {
if (percentage >= 80)
return 'bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
if (percentage >= 60)
return 'bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200';
if (percentage >= 40)
return 'bg-orange-200 dark:bg-orange-800 text-orange-800 dark:text-orange-200';
return 'bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200';
}
$: if (citation) {
mergedDocuments = citation.document?.map((c, i) => {
return {
source: citation.source,
document: c,
metadata: citation.metadata?.[i]
metadata: citation.metadata?.[i],
distance: citation.distances?.[i]
};
});
if (mergedDocuments.every((doc) => doc.distance !== undefined)) {
mergedDocuments.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
}
}
</script>
@@ -57,27 +80,64 @@
{#if document.source?.name}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="left"
tippyOptions={{ duration: [500, 0], animation: 'perspective' }}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm dark:text-gray-400">
<div class="text-sm dark:text-gray-400 flex items-center gap-2 w-fit">
<a
class="hover:text-gray-500 hover:dark:text-gray-100 underline"
class="hover:text-gray-500 hover:dark:text-gray-100 underline flex-grow"
href={document?.metadata?.file_id
? `/api/v1/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source.name.includes('http')
? document.source.name
: document.source?.url?.includes('http')
? document.source.url
: `#`}
target="_blank"
>
{document?.metadata?.name ?? document.source.name}
</a>
{document?.metadata?.page
? `(${$i18n.t('page')} ${document.metadata.page + 1})`
: ''}
{#if document?.metadata?.page}
<span class="text-xs text-gray-500 dark:text-gray-400">
({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div>
</Tooltip>
{#if showRelevance}
<div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Relevance')}
</div>
{#if document.distance !== undefined}
<Tooltip
className="w-fit"
content={$i18n.t('Semantic distance to query')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm my-1 dark:text-gray-400 flex items-center gap-2 w-fit">
{#if showPercentage}
{@const percentage = calculatePercentage(document.distance)}
<span class={`px-1 rounded font-medium ${getRelevanceColor(percentage)}`}>
{percentage.toFixed(2)}%
</span>
<span class="text-gray-500 dark:text-gray-500">
({document.distance.toFixed(4)})
</span>
{:else}
<span class="text-gray-500 dark:text-gray-500">
{document.distance.toFixed(4)}
</span>
{/if}
</div>
</Tooltip>
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No distance available')}
</div>
{/if}
{/if}
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No source available')}
@@ -85,7 +145,7 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class=" text-sm font-medium dark:text-gray-300">
<div class=" text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Content')}
</div>
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
@@ -18,12 +18,18 @@
const dispatch = createEventDispatcher();
export let id = '';
export let save = false;
export let run = true;
export let token;
export let lang = '';
export let code = '';
export let className = 'my-2';
export let editorClassName = '';
export let stickyButtonsClassName = 'top-8';
let _code = '';
$: if (code) {
updateCode();
@@ -126,7 +132,7 @@
}
},
stderr: (text) => {
console.log('An error occured:', text);
console.log('An error occurred:', text);
if (stderr) {
stderr += `${text}\n`;
} else {
@@ -296,12 +302,13 @@ __builtins__.input = input`);
</script>
<div>
<div class="relative my-2 flex flex-col rounded-lg" dir="ltr">
<div class="relative {className} flex flex-col rounded-lg" dir="ltr">
{#if lang === 'mermaid'}
{#if mermaidHtml}
<SvgPanZoom
className=" border border-gray-50 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
svg={mermaidHtml}
content={_token.text}
/>
{:else}
<pre class="mermaid">{code}</pre>
@@ -312,13 +319,13 @@ __builtins__.input = input`);
</div>
<div
class="sticky top-8 mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
class="sticky {stickyButtonsClassName} mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
>
<div class="flex items-center gap-0.5 translate-y-[1px]">
{#if lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code))}
{#if executing}
<div class="run-code-button bg-none border-none p-1 cursor-not-allowed">Running</div>
{:else}
{:else if run}
<button
class="run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
on:click={async () => {
@@ -347,9 +354,11 @@ __builtins__.input = input`);
</div>
<div
class="language-{lang} rounded-t-lg -mt-8 {executing || stdout || stderr || result
? ''
: 'rounded-b-lg'} overflow-hidden"
class="language-{lang} rounded-t-lg -mt-8 {editorClassName
? editorClassName
: executing || stdout || stderr || result
? ''
: 'rounded-b-lg'} overflow-hidden"
>
<div class=" pt-7 bg-gray-50 dark:bg-gray-850"></div>
<CodeEditor
@@ -0,0 +1,118 @@
<script lang="ts">
import { getContext } from 'svelte';
import CodeBlock from './CodeBlock.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Badge from '$lib/components/common/Badge.svelte';
const i18n = getContext('i18n');
export let show = false;
export let codeExecution = null;
</script>
<Modal size="lg" bind:show>
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center flex flex-col gap-0.5 capitalize">
{#if codeExecution?.result}
<div>
{#if codeExecution.result?.error}
<Badge type="error" content="error" />
{:else if codeExecution.result?.output}
<Badge type="success" content="success" />
{:else}
<Badge type="warning" content="incomplete" />
{/if}
</div>
{/if}
<div class="flex gap-2 items-center">
{#if !codeExecution?.result}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div>
{#if codeExecution?.name}
{$i18n.t('Code execution')}: {codeExecution?.name}
{:else}
{$i18n.t('Code execution')}
{/if}
</div>
</div>
</div>
<button
class="self-center"
on:click={() => {
show = false;
codeExecution = null;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-5">
<div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden"
>
<div class="flex flex-col w-full">
<CodeBlock
id="code-exec-{codeExecution?.id}-code"
lang={codeExecution?.language ?? ''}
code={codeExecution?.code ?? ''}
className=""
editorClassName={codeExecution?.result &&
(codeExecution?.result?.error || codeExecution?.result?.output)
? 'rounded-b-none'
: ''}
stickyButtonsClassName="top-0"
run={false}
/>
</div>
{#if codeExecution?.result && (codeExecution?.result?.error || codeExecution?.result?.output)}
<div class="dark:bg-[#202123] dark:text-white px-4 py-4 rounded-b-lg flex flex-col gap-3">
{#if codeExecution?.result?.error}
<div>
<div class=" text-gray-500 text-xs mb-1">{$i18n.t('ERROR')}</div>
<div class="text-sm">{codeExecution?.result?.error}</div>
</div>
{/if}
{#if codeExecution?.result?.output}
<div>
<div class=" text-gray-500 text-xs mb-1">{$i18n.t('OUTPUT')}</div>
<div class="text-sm">{codeExecution?.result?.output}</div>
</div>
{/if}
</div>
{/if}
{#if codeExecution?.result?.files && codeExecution?.result?.files.length > 0}
<div class="flex flex-col w-full">
<hr class=" dark:border-gray-850 my-2" />
<div class=" text-sm font-medium dark:text-gray-300">
{$i18n.t('Files')}
</div>
<ul class="mt-1 list-disc pl-4 text-xs">
{#each codeExecution?.result?.files as file}
<li>
<a href={file.url} target="_blank">{file.name}</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,80 @@
<script lang="ts">
import CodeExecutionModal from './CodeExecutionModal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
export let codeExecutions = [];
let selectedCodeExecution = null;
let showCodeExecutionModal = false;
$: if (codeExecutions) {
updateSelectedCodeExecution();
}
const updateSelectedCodeExecution = () => {
if (selectedCodeExecution) {
selectedCodeExecution = codeExecutions.find(
(execution) => execution.id === selectedCodeExecution.id
);
}
};
</script>
<CodeExecutionModal bind:show={showCodeExecutionModal} codeExecution={selectedCodeExecution} />
{#if codeExecutions.length > 0}
<div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
{#each codeExecutions as execution (execution.id)}
<div class="flex gap-1 text-xs font-semibold">
<button
class="flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
selectedCodeExecution = execution;
showCodeExecutionModal = true;
}}
>
<div
class="bg-white dark:bg-gray-700 rounded-full size-4 flex items-center justify-center"
>
{#if execution?.result}
{#if execution.result?.error}
<XMark />
{:else if execution.result?.output}
<Check strokeWidth="3" className="size-3" />
{:else}
<EllipsisHorizontal />
{/if}
{:else}
<Spinner className="size-4" />
{/if}
</div>
<div
class="flex-1 mx-2 line-clamp-1 code-execution-name {execution?.result ? '' : 'pulse'}"
>
{execution.name}
</div>
</button>
</div>
{/each}
</div>
{/if}
<style>
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.pulse {
opacity: 1;
animation: pulse 1.5s ease;
}
</style>
@@ -66,6 +66,7 @@
/>
{:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1}
<ResponseMessage
{chatId}
{history}
{messageId}
isLastMessage={messageId === history.currentId}
@@ -16,7 +16,6 @@
import Markdown from './Markdown.svelte';
import Name from './Name.svelte';
import Skeleton from './Skeleton.svelte';
const i18n = getContext('i18n');
export let chatId;
@@ -155,9 +154,8 @@
await tick();
const messageElement = document.getElementById(`message-${messageId}`);
console.log(messageElement);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.scrollIntoView({ block: 'start' });
}
});
</script>
@@ -179,7 +177,7 @@
class=" snap-center w-full max-w-full m-1 border {history.messages[messageId]
?.modelIdx == modelIdx
? `border-gray-100 dark:border-gray-800 border-[1.5px] ${
$mobile ? 'min-w-full' : 'min-w-[32rem]'
$mobile ? 'min-w-full' : 'min-w-80'
}`
: `border-gray-50 dark:border-gray-850 border-dashed ${
$mobile ? 'min-w-full' : 'min-w-80'
@@ -200,6 +198,7 @@
{#key history.currentId}
{#if message}
<ResponseMessage
{chatId}
{history}
messageId={_messageId}
isLastMessage={true}
@@ -236,7 +235,7 @@
{/each}
</div>
{#if !readOnly && isLastMessage}
{#if !readOnly}
{#if !Object.keys(groupedMessageIds).find((modelIdx) => {
const { messageIds } = groupedMessageIds[modelIdx];
const _messageId = messageIds[groupedMessageIdsIdx[modelIdx]];
@@ -271,22 +270,24 @@
{/if}
</div>
<div class=" flex-shrink-0 text-gray-600 dark:text-gray-500 mt-1">
<Tooltip content={$i18n.t('Merge Responses')} placement="bottom">
<button
type="button"
id="merge-response-button"
class="{true
? 'visible'
: 'invisible group-hover:visible'} p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
mergeResponsesHandler();
}}
>
<Merge className=" size-5 " />
</button>
</Tooltip>
</div>
{#if isLastMessage}
<div class=" flex-shrink-0 text-gray-600 dark:text-gray-500 mt-1">
<Tooltip content={$i18n.t('Merge Responses')} placement="bottom">
<button
type="button"
id="merge-response-button"
class="{true
? 'visible'
: 'invisible group-hover:visible'} p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
mergeResponsesHandler();
}}
>
<Merge className=" size-5 " />
</button>
</Tooltip>
</div>
{/if}
</div>
{/if}
{/if}
@@ -2,6 +2,8 @@
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { config, models } from '$lib/stores';
import Tags from '$lib/components/common/Tags.svelte';
const i18n = getContext('i18n');
@@ -10,57 +12,74 @@
export let message;
export let show = false;
let LIKE_REASONS = [];
let DISLIKE_REASONS = [];
let LIKE_REASONS = [
'accurate_information',
'followed_instructions_perfectly',
'showcased_creativity',
'positive_attitude',
'attention_to_detail',
'thorough_explanation',
'other'
];
let DISLIKE_REASONS = [
'dont_like_the_style',
'too_verbose',
'not_helpful',
'not_factually_correct',
'didnt_fully_follow_instructions',
'refused_when_it_shouldnt_have',
'being_lazy',
'other'
];
function loadReasons() {
LIKE_REASONS = [
$i18n.t('Accurate information'),
$i18n.t('Followed instructions perfectly'),
$i18n.t('Showcased creativity'),
$i18n.t('Positive attitude'),
$i18n.t('Attention to detail'),
$i18n.t('Thorough explanation'),
$i18n.t('Other')
];
DISLIKE_REASONS = [
$i18n.t("Don't like the style"),
$i18n.t('Not factually correct'),
$i18n.t("Didn't fully follow instructions"),
$i18n.t("Refused when it shouldn't have"),
$i18n.t('Being lazy'),
$i18n.t('Other')
];
}
let tags = [];
let reasons = [];
let selectedReason = null;
let comment = '';
let selectedModel = null;
$: if (message?.annotation?.rating === 1) {
reasons = LIKE_REASONS;
} else if (message?.annotation?.rating === -1) {
reasons = DISLIKE_REASONS;
}
onMount(() => {
$: if (message) {
init();
}
const init = () => {
selectedReason = message?.annotation?.reason ?? '';
comment = message?.annotation?.comment ?? '';
loadReasons();
tags = (message?.annotation?.tags ?? []).map((tag) => ({
name: tag
}));
};
onMount(() => {
if (message?.arena) {
selectedModel = $models.find((m) => m.id === message.selectedModelId);
toast.success(
$i18n.t('This response was generated by "{{model}}"', {
model: selectedModel ? (selectedModel?.name ?? selectedModel.id) : message.selectedModelId
})
);
}
});
const submitHandler = () => {
console.log('submitHandler');
const saveHandler = () => {
console.log('saveHandler');
// if (!selectedReason) {
// toast.error($i18n.t('Please select a reason'));
// return;
// }
if (!selectedReason) {
toast.error($i18n.t('Please select a reason'));
return;
}
dispatch('submit', {
dispatch('save', {
reason: selectedReason,
comment: comment
comment: comment,
tags: tags.map((tag) => tag.name)
});
toast.success($i18n.t('Thanks for your feedback!'));
@@ -68,8 +87,16 @@
};
</script>
{#if message?.arena}
<div class="text-xs font-medium pt-1.5 -mb-0.5">
{$i18n.t('This response was generated by "{{model}}"', {
model: selectedModel ? (selectedModel?.name ?? selectedModel.id) : message.selectedModelId
})}
</div>
{/if}
<div
class=" my-2.5 rounded-xl px-4 py-3 border dark:border-gray-850"
class=" my-2.5 rounded-xl px-4 py-3 border border-gray-50 dark:border-gray-850"
id="message-feedback-{message.id}"
>
<div class="flex justify-between items-center">
@@ -94,10 +121,10 @@
</div>
{#if reasons.length > 0}
<div class="flex flex-wrap gap-2 text-sm mt-2.5">
<div class="flex flex-wrap gap-1.5 text-sm mt-2.5">
{#each reasons as reason}
<button
class="px-3.5 py-1 border dark:border-gray-850 hover:bg-gray-100 dark:hover:bg-gray-850 {selectedReason ===
class="px-3 py-0.5 border border-gray-50 dark:border-gray-850 hover:bg-gray-100 dark:hover:bg-gray-850 {selectedReason ===
reason
? 'bg-gray-200 dark:bg-gray-800'
: ''} transition rounded-lg"
@@ -105,7 +132,37 @@
selectedReason = reason;
}}
>
{reason}
{#if reason === 'accurate_information'}
{$i18n.t('Accurate information')}
{:else if reason === 'followed_instructions_perfectly'}
{$i18n.t('Followed instructions perfectly')}
{:else if reason === 'showcased_creativity'}
{$i18n.t('Showcased creativity')}
{:else if reason === 'positive_attitude'}
{$i18n.t('Positive attitude')}
{:else if reason === 'attention_to_detail'}
{$i18n.t('Attention to detail')}
{:else if reason === 'thorough_explanation'}
{$i18n.t('Thorough explanation')}
{:else if reason === 'dont_like_the_style'}
{$i18n.t("Don't like the style")}
{:else if reason === 'too_verbose'}
{$i18n.t('Too verbose')}
{:else if reason === 'not_helpful'}
{$i18n.t('Not helpful')}
{:else if reason === 'not_factually_correct'}
{$i18n.t('Not factually correct')}
{:else if reason === 'didnt_fully_follow_instructions'}
{$i18n.t("Didn't fully follow instructions")}
{:else if reason === 'refused_when_it_shouldnt_have'}
{$i18n.t("Refused when it shouldn't have")}
{:else if reason === 'being_lazy'}
{$i18n.t('Being lazy')}
{:else if reason === 'other'}
{$i18n.t('Other')}
{:else}
{reason}
{/if}
</button>
{/each}
</div>
@@ -116,18 +173,34 @@
bind:value={comment}
class="w-full text-sm px-1 py-2 bg-transparent outline-none resize-none rounded-xl"
placeholder={$i18n.t('Feel free to add specific details')}
rows="2"
rows="3"
/>
</div>
<div class="mt-2 flex justify-end">
<div class="mt-2 gap-1.5 flex justify-between">
<div class="flex items-end group">
<Tags
{tags}
on:delete={(e) => {
tags = tags.filter(
(tag) =>
tag.name.replaceAll(' ', '_').toLowerCase() !==
e.detail.replaceAll(' ', '_').toLowerCase()
);
}}
on:add={(e) => {
tags = [...tags, { name: e.detail }];
}}
/>
</div>
<button
class=" bg-emerald-700 text-white text-sm font-medium rounded-lg px-3.5 py-1.5"
class=" bg-emerald-700 hover:bg-emerald-800 transition text-white text-sm font-medium rounded-xl px-3.5 py-1.5"
on:click={() => {
submitHandler();
saveHandler();
}}
>
{$i18n.t('Submit')}
{$i18n.t('Save')}
</button>
</div>
</div>
@@ -19,7 +19,8 @@
extractSentencesForAudio,
cleanText,
getMessageContentParts,
sanitizeResponseContent
sanitizeResponseContent,
createMessagesList
} from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
@@ -35,10 +36,14 @@
import Markdown from './Markdown.svelte';
import Error from './Error.svelte';
import Citations from './Citations.svelte';
import CodeExecutions from './CodeExecutions.svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import ContentRenderer from './ContentRenderer.svelte';
import { createNewFeedback, getFeedbackById, updateFeedbackById } from '$lib/apis/evaluations';
import { getChatById } from '$lib/apis/chats';
import { generateTags } from '$lib/apis';
interface MessageType {
id: string;
@@ -64,6 +69,17 @@
done: boolean;
error?: boolean | { content: string };
citations?: string[];
code_executions?: {
uuid: string;
name: string;
code: string;
language?: string;
result?: {
error?: string;
output?: string;
files?: { name: string; url: string }[];
};
}[];
info?: {
openai?: boolean;
prompt_tokens?: number;
@@ -80,6 +96,7 @@
annotation?: { type: string; rating: number };
}
export let chatId = '';
export let history;
export let messageId;
@@ -318,6 +335,114 @@
generatingImage = false;
};
let feedbackLoading = false;
const feedbackHandler = async (
rating: number | null = null,
annotation: object | null = null
) => {
feedbackLoading = true;
console.log('Feedback', rating, annotation);
const updatedMessage = {
...message,
annotation: {
...(message?.annotation ?? {}),
...(rating !== null ? { rating: rating } : {}),
...(annotation ? annotation : {})
}
};
const chat = await getChatById(localStorage.token, chatId).catch((error) => {
toast.error(error);
});
if (!chat) {
return;
}
const messages = createMessagesList(history, message.id);
let feedbackItem = {
type: 'rating',
data: {
...(updatedMessage?.annotation ? updatedMessage.annotation : {}),
model_id: message?.selectedModelId ?? message.model,
...(history.messages[message.parentId].childrenIds.length > 1
? {
sibling_model_ids: history.messages[message.parentId].childrenIds
.filter((id) => id !== message.id)
.map((id) => history.messages[id]?.selectedModelId ?? history.messages[id].model)
}
: {})
},
meta: {
arena: message ? message.arena : false,
model_id: message.model,
message_id: message.id,
message_index: messages.length,
chat_id: chatId
},
snapshot: {
chat: chat
}
};
let feedback = null;
if (message?.feedbackId) {
feedback = await updateFeedbackById(
localStorage.token,
message.feedbackId,
feedbackItem
).catch((error) => {
toast.error(error);
});
} else {
feedback = await createNewFeedback(localStorage.token, feedbackItem).catch((error) => {
toast.error(error);
});
if (feedback) {
updatedMessage.feedbackId = feedback.id;
}
}
console.log(updatedMessage);
dispatch('save', updatedMessage);
await tick();
if (!annotation) {
showRateComment = true;
if (!updatedMessage.annotation?.tags) {
// attempt to generate tags
const tags = await generateTags(localStorage.token, message.model, messages, chatId).catch(
(error) => {
console.error(error);
return [];
}
);
console.log(tags);
if (tags) {
updatedMessage.annotation.tags = tags;
feedbackItem.data.tags = tags;
dispatch('save', updatedMessage);
await updateFeedbackById(
localStorage.token,
updatedMessage.feedbackId,
feedbackItem
).catch((error) => {
toast.error(error);
});
}
}
}
feedbackLoading = false;
};
$: if (!edit) {
(async () => {
await tick();
@@ -516,6 +641,10 @@
{#if message.citations}
<Citations citations={message.citations} />
{/if}
{#if message.code_executions}
<CodeExecutions codeExecutions={message.code_executions} />
{/if}
</div>
{/if}
</div>
@@ -864,12 +993,14 @@
<button
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(message
?.annotation?.rating ?? null) === 1
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
).toString() === '1'
? 'bg-gray-100 dark:bg-gray-800'
: ''} dark:hover:text-white hover:text-black transition"
: ''} dark:hover:text-white hover:text-black transition disabled:cursor-progress disabled:hover:bg-transparent"
disabled={feedbackLoading}
on:click={async () => {
await rateMessage(message.id, 1);
await feedbackHandler(1);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
@@ -885,7 +1016,6 @@
});
});
showRateComment = true;
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
@@ -914,12 +1044,14 @@
<button
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(message
?.annotation?.rating ?? null) === -1
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
).toString() === '-1'
? 'bg-gray-100 dark:bg-gray-800'
: ''} dark:hover:text-white hover:text-black transition"
: ''} dark:hover:text-white hover:text-black transition disabled:cursor-progress disabled:hover:bg-transparent"
disabled={feedbackLoading}
on:click={async () => {
await rateMessage(message.id, -1);
await feedbackHandler(-1);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
@@ -935,7 +1067,6 @@
});
});
showRateComment = true;
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
@@ -1087,15 +1218,13 @@
<RateComment
bind:message
bind:show={showRateComment}
on:submit={(e) => {
dispatch('save', {
...message,
annotation: {
...message.annotation,
comment: e.detail.comment,
reason: e.detail.reason
}
on:save={async (e) => {
await feedbackHandler(null, {
tags: e.detail.tags,
comment: e.detail.comment,
reason: e.detail.reason
});
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
@@ -142,28 +142,30 @@
{#if edit === true}
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
<div class="max-h-96 overflow-auto">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
</div>
<div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
<div>
@@ -40,6 +40,7 @@
<div class="overflow-hidden w-full">
<div class="mr-1 max-w-full">
<Selector
id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')}
items={$models.map((model) => ({
value: model.id,
@@ -25,6 +25,7 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = '';
export let value = '';
export let placeholder = 'Select a model';
export let searchEnabled = true;
@@ -41,6 +42,7 @@
}[] = [];
export let className = 'w-[32rem]';
export let triggerClassName = 'text-lg';
let show = false;
@@ -229,9 +231,13 @@
}}
closeFocus={false}
>
<DropdownMenu.Trigger class="relative w-full font-primary" aria-label={placeholder}>
<DropdownMenu.Trigger
class="relative w-full font-primary"
aria-label={placeholder}
id="model-selector-{id}-button"
>
<div
class="flex w-full text-left px-0.5 outline-none bg-transparent truncate text-lg font-medium placeholder-gray-400 focus:outline-none"
class="flex w-full text-left px-0.5 outline-none bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-none"
>
{#if selectedModel}
{selectedModel.label}
@@ -245,10 +251,10 @@
<DropdownMenu.Content
class=" z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/40 outline-none"
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-none"
transition={flyAndScale}
side={$mobile ? 'bottom' : 'bottom-start'}
sideOffset={4}
sideOffset={3}
>
<slot>
{#if searchEnabled}
@@ -281,7 +287,7 @@
/>
</div>
<hr class="border-gray-100 dark:border-gray-800" />
<hr class="border-gray-50 dark:border-gray-800" />
{/if}
<div class="px-3 my-2 max-h-64 overflow-y-auto scrollbar-hidden group">
@@ -474,10 +480,16 @@
</div>
<div class="flex flex-col self-start">
<div class="line-clamp-1">
Downloading "{model}" {'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="flex-shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
@@ -488,7 +500,7 @@
</div>
</div>
<div class="mr-2 translate-y-0.5">
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
@@ -521,7 +533,7 @@
</div>
{#if showTemporaryChatControl}
<hr class="border-gray-100 dark:border-gray-800" />
<hr class="border-gray-50 dark:border-gray-800" />
<div class="flex items-center mx-2 my-2">
<button
+16 -15
View File
@@ -57,18 +57,19 @@
console.log(prompt);
await tick();
const chatInputElement = document.getElementById('chat-textarea');
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
await tick();
if (chatInputElement) {
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
chatInputElement.focus();
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
chatInputElement.dispatchEvent(new Event('input'));
}
await tick();
@@ -106,7 +107,7 @@
class="w-full text-3xl text-gray-800 dark:text-gray-100 font-medium text-center flex items-center gap-4 font-primary"
>
<div class="w-full flex flex-col justify-center items-center">
<div class="flex flex-col md:flex-row justify-center gap-2 md:gap-3.5 w-fit">
<div class="flex flex-row justify-center gap-3 sm:gap-3.5 w-fit px-5">
<div class="flex flex-shrink-0 justify-center">
<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
{#each models as model, modelIdx}
@@ -127,7 +128,7 @@
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-[2.5rem] rounded-full border-[1px] border-gray-200 dark:border-none"
class=" size-9 sm:size-10 rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo"
draggable="false"
/>
@@ -137,9 +138,9 @@
</div>
</div>
<div class=" capitalize line-clamp-1 text-3xl md:text-4xl" in:fade={{ duration: 100 }}>
{#if models[selectedModelIdx]?.info}
{models[selectedModelIdx]?.info?.name}
<div class=" capitalize text-3xl sm:text-4xl line-clamp-1" in:fade={{ duration: 100 }}>
{#if models[selectedModelIdx]?.name}
{models[selectedModelIdx]?.name}
{:else}
{$i18n.t('Hello, {{name}}', { name: $user.name })}
{/if}
+10 -10
View File
@@ -93,23 +93,23 @@
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 100x100
// Calculate the new width and height to fit within 250x250
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 100 * aspectRatio;
newHeight = 100;
newWidth = 250 * aspectRatio;
newHeight = 250;
} else {
newWidth = 100;
newHeight = 100 / aspectRatio;
newWidth = 250;
newHeight = 250 / aspectRatio;
}
// Set the canvas size
canvas.width = 100;
canvas.height = 100;
canvas.width = 250;
canvas.height = 250;
// Calculate the position to center the image
const offsetX = (100 - newWidth) / 2;
const offsetY = (100 - newHeight) / 2;
const offsetX = (250 - newWidth) / 2;
const offsetY = (250 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
@@ -397,7 +397,7 @@
<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"
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"
on:click={async () => {
const res = await submitHandler();
@@ -233,7 +233,7 @@
<div class="flex justify-end text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -323,7 +323,7 @@
<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"
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"
on:click={() => {
saveSettings({
system: system !== '' ? system : undefined,
+234 -114
View File
@@ -19,6 +19,8 @@
// Addons
let titleAutoGenerate = true;
let autoTags = true;
let responseAutoCopy = false;
let widescreenMode = false;
let splitLargeChunks = false;
@@ -28,11 +30,16 @@
// Interface
let defaultModelId = '';
let showUsername = false;
let richTextInput = true;
let landingPageMode = '';
let chatBubble = true;
let chatDirection: 'LTR' | 'RTL' = 'LTR';
// Admin - Show Update Available Toast
let showUpdateToast = true;
let showChangelog = true;
let showEmojiInCall = false;
let voiceInterruption = false;
let hapticFeedback = false;
@@ -47,7 +54,7 @@
saveSettings({ scrollOnBranchChange: scrollOnBranchChange });
};
const togglewidescreenMode = async () => {
const toggleWidescreenMode = async () => {
widescreenMode = !widescreenMode;
saveSettings({ widescreenMode: widescreenMode });
};
@@ -62,6 +69,16 @@
saveSettings({ landingPageMode: landingPageMode });
};
const toggleShowUpdateToast = async () => {
showUpdateToast = !showUpdateToast;
saveSettings({ showUpdateToast: showUpdateToast });
};
const toggleShowChangelog = async () => {
showChangelog = !showChangelog;
saveSettings({ showChangelog: showChangelog });
};
const toggleShowUsername = async () => {
showUsername = !showUsername;
saveSettings({ showUsername: showUsername });
@@ -112,6 +129,16 @@
});
};
const toggleAutoTags = async () => {
autoTags = !autoTags;
saveSettings({ autoTags });
};
const toggleRichTextInput = async () => {
richTextInput = !richTextInput;
saveSettings({ richTextInput });
};
const toggleResponseAutoCopy = async () => {
const permission = await navigator.clipboard
.readText()
@@ -149,13 +176,18 @@
onMount(async () => {
titleAutoGenerate = $settings?.title?.auto ?? true;
autoTags = $settings.autoTags ?? true;
responseAutoCopy = $settings.responseAutoCopy ?? false;
showUsername = $settings.showUsername ?? false;
showUpdateToast = $settings.showUpdateToast ?? true;
showChangelog = $settings.showChangelog ?? true;
showEmojiInCall = $settings.showEmojiInCall ?? false;
voiceInterruption = $settings.voiceInterruption ?? false;
richTextInput = $settings.richTextInput ?? true;
landingPageMode = $settings.landingPageMode ?? '';
chatBubble = $settings.chatBubble ?? true;
widescreenMode = $settings.widescreenMode ?? false;
@@ -307,7 +339,7 @@
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
togglewidescreenMode();
toggleWidescreenMode();
}}
type="button"
>
@@ -338,6 +370,205 @@
</div>
</div>
{#if $user.role === 'admin'}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Toast notifications for new updates')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleShowUpdateToast();
}}
type="button"
>
{#if showUpdateToast === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t(`Show "What's New" modal on login`)}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleShowChangelog();
}}
type="button"
>
{#if showChangelog === 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}
<div class=" my-1.5 text-sm font-medium">{$i18n.t('Chat')}</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Title Auto-Generation')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleTitleAutoGenerate();
}}
type="button"
>
{#if titleAutoGenerate === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Chat Tags Auto-Generation')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleAutoTags();
}}
type="button"
>
{#if autoTags === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Response AutoCopy to Clipboard')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleResponseAutoCopy();
}}
type="button"
>
{#if responseAutoCopy === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Rich Text Input for Chat')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleRichTextInput();
}}
type="button"
>
{#if richTextInput === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Chat Background Image')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
if (backgroundImageUrl !== null) {
backgroundImageUrl = null;
saveSettings({ backgroundImageUrl });
} else {
filesInputElement.click();
}
}}
type="button"
>
{#if backgroundImageUrl !== null}
<span class="ml-2 self-center">{$i18n.t('Reset')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Upload')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Allow User Location')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleUserLocation();
}}
type="button"
>
{#if userLocation === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Haptic Feedback')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleHapticFeedback();
}}
type="button"
>
{#if hapticFeedback === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
@@ -382,117 +613,6 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Chat Background Image')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
if (backgroundImageUrl !== null) {
backgroundImageUrl = null;
saveSettings({ backgroundImageUrl });
} else {
filesInputElement.click();
}
}}
type="button"
>
{#if backgroundImageUrl !== null}
<span class="ml-2 self-center">{$i18n.t('Reset')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Upload')}</span>
{/if}
</button>
</div>
</div>
<div class=" my-1.5 text-sm font-medium">{$i18n.t('Chat')}</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Title Auto-Generation')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleTitleAutoGenerate();
}}
type="button"
>
{#if titleAutoGenerate === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Response AutoCopy to Clipboard')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleResponseAutoCopy();
}}
type="button"
>
{#if responseAutoCopy === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Allow User Location')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleUserLocation();
}}
type="button"
>
{#if userLocation === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Haptic Feedback')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleHapticFeedback();
}}
type="button"
>
{#if hapticFeedback === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div class=" my-1.5 text-sm font-medium">{$i18n.t('Voice')}</div>
<div>
@@ -539,7 +659,7 @@
<div class="flex justify-end text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
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"
type="submit"
>
{$i18n.t('Save')}
@@ -89,7 +89,7 @@
<div class="flex justify-end text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
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"
type="submit"
>
{$i18n.t('Save')}
+17 -17
View File
@@ -88,15 +88,15 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full p-4 md:space-x-4">
<div class="flex flex-col md:flex-row w-full px-4 pt-2 pb-4 md:space-x-4">
<div
id="settings-tabs-container"
class="tabs flex flex-row overflow-x-auto space-x-1 md:space-x-0 md:space-y-1 md:flex-col flex-1 md:flex-none md:w-40 dark:text-gray-200 text-xs text-left mb-3 md:mb-0"
>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'general'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'general';
@@ -120,9 +120,9 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'interface'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'interface';
@@ -146,9 +146,9 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'personalization'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'personalization';
@@ -161,9 +161,9 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'audio'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'audio';
@@ -188,9 +188,9 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'chats'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'chats';
@@ -214,9 +214,9 @@
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'account'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'account';
@@ -241,9 +241,9 @@
{#if $user.role === 'admin'}
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'admin'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={async () => {
await goto('/admin/settings');
@@ -269,9 +269,9 @@
{/if}
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'about'
? 'bg-gray-200 dark:bg-gray-800'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'about';
@@ -184,7 +184,7 @@
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
⌫
⌫/Delete
</div>
</div>
</div>
+9 -1
View File
@@ -15,6 +15,14 @@
<Modal bind:show size="xs">
<div class="px-4 pt-4 pb-5 w-full flex flex-col justify-center">
<Tags {tags} {deleteTag} {addTag} />
<Tags
{tags}
on:delete={(e) => {
deleteTag(e.detail);
}}
on:add={(e) => {
addTag(e.detail);
}}
/>
</div>
</Modal>
+30 -30
View File
@@ -2,7 +2,7 @@
import {
addTagById,
deleteTagById,
getAllChatTags,
getAllTags,
getChatList,
getChatListByTagName,
getTagsById,
@@ -20,56 +20,48 @@
const dispatch = createEventDispatcher();
import Tags from '../common/Tags.svelte';
import { toast } from 'svelte-sonner';
export let chatId = '';
let tags = [];
const getTags = async () => {
return (
await getTagsById(localStorage.token, chatId).catch(async (error) => {
return [];
})
).filter((tag) => tag.name !== 'pinned');
return await getTagsById(localStorage.token, chatId).catch(async (error) => {
return [];
});
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, chatId, tagName);
tags = await getTags();
const res = await addTagById(localStorage.token, chatId, tagName).catch(async (error) => {
toast.error(error);
return null;
});
if (!res) {
return;
}
tags = await getTags();
await updateChatById(localStorage.token, chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
await _tags.set(await getAllTags(localStorage.token));
dispatch('add', {
name: tagName
});
};
const deleteTag = async (tagName) => {
const res = await deleteTagById(localStorage.token, chatId, tagName);
tags = await getTags();
await updateChatById(localStorage.token, chatId, {
tags: tags
});
await _tags.set(await getAllChatTags(localStorage.token));
if ($_tags.map((t) => t.name).includes(tagName)) {
if (tagName === 'pinned') {
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
} else {
await chats.set(await getChatListByTagName(localStorage.token, tagName));
}
if ($chats.find((chat) => chat.id === chatId)) {
dispatch('close');
}
} else {
// if the tag we deleted is no longer a valid tag, return to main chat list view
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
await scrollPaginationEnabled.set(true);
}
await _tags.set(await getAllTags(localStorage.token));
dispatch('delete', {
name: tagName
});
};
onMount(async () => {
@@ -79,4 +71,12 @@
});
</script>
<Tags {tags} {deleteTag} {addTag} />
<Tags
{tags}
on:delete={(e) => {
deleteTag(e.detail);
}}
on:add={(e) => {
addTag(e.detail);
}}
/>
+2 -1
View File
@@ -6,7 +6,8 @@
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
error: 'bg-red-500/20 text-red-700 dark:text-red-200',
muted: 'bg-gray-500/20 text-gray-700 dark:text-gray-200'
};
</script>
+59 -16
View File
@@ -1,4 +1,9 @@
<script lang="ts">
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
$: dispatch('change', open);
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
@@ -7,39 +12,77 @@
export let open = false;
export let className = '';
export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
export let title = null;
export let grow = false;
export let disabled = false;
export let hide = false;
</script>
<div class={className}>
{#if title !== null}
<button class="w-fit" on:click={() => (open = !open)}>
<div class=" w-fit font-medium transition flex items-center justify-between gap-2">
<div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div class=" w-full font-medium flex items-center justify-between gap-2">
<div class="">
{title}
</div>
<div>
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5 " />
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5 " />
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</button>
</div>
{:else}
<button on:click={() => (open = !open)}>
<div
class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div>
<slot />
</div>
</button>
{/if}
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
{#if grow}
{#if open && !hide}
<div
transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<slot name="content" />
</div>
{/if}
{/if}
</div>
</div>
{/if}
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
</div>
{/if}
{/if}
</div>
+10 -5
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
const i18n = getContext('i18n');
import { flyAndScale } from '$lib/utils/transitions';
const dispatch = createEventDispatcher();
import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
export let title = '';
export let message = '';
@@ -27,6 +26,12 @@
console.log('Escape');
show = false;
}
if (event.key === 'Enter') {
console.log('Enter');
show = false;
dispatch('confirm', inputValue);
}
};
onMount(() => {
@@ -56,7 +61,7 @@
}}
>
<div
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl border border-gray-850"
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();
@@ -0,0 +1,30 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
export let x;
export let y;
let popupElement = null;
onMount(() => {
document.body.appendChild(popupElement);
document.body.style.overflow = 'hidden';
});
onDestroy(() => {
document.body.removeChild(popupElement);
document.body.style.overflow = 'unset';
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={popupElement}
class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none"
>
<div class=" absolute text-white z-[99999]" style="top: {y + 10}px; left: {x + 10}px;">
<slot></slot>
</div>
</div>
+2 -14
View File
@@ -6,23 +6,11 @@
const dispatch = createEventDispatcher();
export let show = false;
export let size = 'md';
export let className = '';
let modalElement = null;
let mounted = false;
const sizeToWidth = (size) => {
if (size === 'xs') {
return 'w-[16rem]';
} else if (size === 'sm') {
return 'w-[30rem]';
} else if (size === 'md') {
return 'w-[48rem]';
} else {
return 'w-[56rem]';
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isTopModal()) {
console.log('Escape');
@@ -76,7 +64,7 @@
}}
>
<div
class=" mt-auto max-w-full w-full bg-gray-50 dark:bg-gray-900 max-h-[100dvh] overflow-y-auto scrollbar-hidden"
class=" mt-auto max-w-full w-full bg-gray-50 dark:bg-gray-900 dark:text-gray-100 {className} max-h-[100dvh] overflow-y-auto scrollbar-hidden"
on:mousedown={(e) => {
e.stopPropagation();
}}
+1 -1
View File
@@ -22,7 +22,7 @@
<slot name="content">
<DropdownMenu.Content
class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-700 z-50 bg-gray-850 text-white"
class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-900 z-50 bg-gray-850 text-white"
sideOffset={8}
side="bottom"
align="start"
+141
View File
@@ -0,0 +1,141 @@
<script>
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Collapsible from './Collapsible.svelte';
export let open = true;
export let id = '';
export let name = '';
export let collapsible = true;
export let className = '';
let folderElement;
let draggedOver = false;
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
draggedOver = true;
};
const onDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (folderElement.contains(e.target)) {
console.log('Dropped on the Button');
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// Iterate over all items in the DataTransferItemList use functional programming
for (const item of Array.from(e.dataTransfer.items)) {
// If dropped items aren't files, reject them
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type === 'application/json') {
console.log('Dropped file is a JSON file!');
// Read the JSON file with FileReader
const reader = new FileReader();
reader.onload = async function (event) {
try {
const fileContent = JSON.parse(event.target.result);
console.log('Parsed JSON Content: ', fileContent);
open = true;
dispatch('import', fileContent);
} catch (error) {
console.error('Error parsing JSON file:', error);
}
};
// Start reading the file
reader.readAsText(file);
} else {
console.error('Only JSON file types are supported.');
}
} else {
open = true;
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
dispatch('drop', data);
}
}
}
draggedOver = false;
}
};
const onDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
draggedOver = false;
};
onMount(() => {
folderElement.addEventListener('dragover', onDragOver);
folderElement.addEventListener('drop', onDrop);
folderElement.addEventListener('dragleave', onDragLeave);
});
onDestroy(() => {
folderElement.addEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave);
});
</script>
<div bind:this={folderElement} class="relative {className}">
{#if draggedOver}
<div
class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(260,85%,65%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
></div>
{/if}
{#if collapsible}
<Collapsible
bind:open
className="w-full "
buttonClassName="w-full"
on:change={(e) => {
dispatch('change', e.detail);
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="w-full">
<button
class="w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
>
<div class="text-gray-300 dark:text-gray-600">
{#if open}
<ChevronDown className=" size-3" strokeWidth="2.5" />
{:else}
<ChevronRight className=" size-3" strokeWidth="2.5" />
{/if}
</div>
<div class="translate-y-[0.5px]">
{name}
</div>
</button>
</div>
<div slot="content" class="w-full">
<slot></slot>
</div>
</Collapsible>
{:else}
<slot></slot>
{/if}
</div>
+7 -3
View File
@@ -6,11 +6,15 @@
export let show = true;
export let size = 'md';
export let className = 'bg-gray-50 dark:bg-gray-900 rounded-2xl';
let modalElement = null;
let mounted = false;
const sizeToWidth = (size) => {
if (size === 'full') {
return 'w-full';
}
if (size === 'xs') {
return 'w-[16rem]';
} else if (size === 'sm') {
@@ -68,9 +72,9 @@
}}
>
<div
class=" m-auto rounded-2xl max-w-full {sizeToWidth(
size
)} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl max-h-[100dvh] overflow-y-auto scrollbar-hidden"
class=" m-auto max-w-full {sizeToWidth(size)} {size !== 'full'
? 'mx-2'
: ''} shadow-3xl max-h-[100dvh] overflow-y-auto scrollbar-hidden {className}"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();
+3 -3
View File
@@ -14,7 +14,7 @@
<Pagination.Root bind:page {count} {perPage} let:pages>
<div class="my-2 flex items-center">
<Pagination.PrevButton
class="mr-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
class="mr-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
>
<ChevronLeft className="size-4" strokeWidth="2" />
</Pagination.PrevButton>
@@ -25,7 +25,7 @@
{:else}
<Pagination.Page
{page}
class="inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-sm font-medium hover:bg-dark-10 active:scale-98 disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent data-[selected]:bg-black data-[selected]:text-gray-100 data-[selected]:hover:bg-black dark:data-[selected]:bg-white dark:data-[selected]:text-gray-900 dark:data-[selected]:hover:bg-white"
class="inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-sm font-medium hover:bg-dark-10 active:scale-98 disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent data-[selected]:bg-gray-50 data-[selected]:text-gray-700 data-[selected]:hover:bg-gray-100 dark:data-[selected]:bg-gray-850 dark:data-[selected]:text-gray-50 dark:data-[selected]:hover:bg-gray-800 transition"
>
{page.value}
</Pagination.Page>
@@ -33,7 +33,7 @@
{/each}
</div>
<Pagination.NextButton
class="ml-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
class="ml-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
>
<ChevronRight className="size-4" strokeWidth="2" />
</Pagination.NextButton>
@@ -0,0 +1,498 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher();
import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
import { undo, redo, history } from 'prosemirror-history';
import {
schema,
defaultMarkdownParser,
MarkdownParser,
defaultMarkdownSerializer
} from 'prosemirror-markdown';
import {
inputRules,
wrappingInputRule,
textblockTypeInputRule,
InputRule
} from 'prosemirror-inputrules'; // Import input rules
import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, chainCommands } from 'prosemirror-commands';
import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model';
export let className = 'input-prose';
export let shiftEnter = false;
export let id = '';
export let value = '';
export let placeholder = 'Type here...';
export let trim = false;
let element: HTMLElement; // Element where ProseMirror will attach
let state;
let view;
// Plugin to add placeholder when the content is empty
function placeholderPlugin(placeholder: string) {
return new Plugin({
props: {
decorations(state) {
const doc = state.doc;
if (
doc.childCount === 1 &&
doc.firstChild.isTextblock &&
doc.firstChild?.textContent === ''
) {
// If there's nothing in the editor, show the placeholder decoration
const decoration = Decoration.node(0, doc.content.size, {
'data-placeholder': placeholder,
class: 'placeholder'
});
return DecorationSet.create(doc, [decoration]);
}
return DecorationSet.empty;
}
}
});
}
function unescapeMarkdown(text: string): string {
return text
.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1') // unescape backslashed characters
.replace(/&amp;/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
// Custom parsing rule that creates proper paragraphs for newlines and empty lines
function markdownToProseMirrorDoc(markdown: string) {
// Split the markdown into lines
const lines = markdown.split('\n\n');
// Create an array to hold our paragraph nodes
const paragraphs = [];
// Process each line
lines.forEach((line) => {
if (line.trim() === '') {
// For empty lines, create an empty paragraph
paragraphs.push(schema.nodes.paragraph.create());
} else {
// For non-empty lines, parse as usual
const doc = defaultMarkdownParser.parse(line);
// Extract the content of the parsed document
doc.content.forEach((node) => {
paragraphs.push(node);
});
}
});
// Create a new document with these paragraphs
return schema.node('doc', null, paragraphs);
}
// Create a custom serializer for paragraphs
// Custom paragraph serializer to preserve newlines for empty paragraphs (empty block).
function serializeParagraph(state, node: Node) {
const content = node.textContent.trim();
// If the paragraph is empty, just add an empty line.
if (content === '') {
state.write('\n\n');
} else {
state.renderInline(node);
state.closeBlock(node);
}
}
const customMarkdownSerializer = new defaultMarkdownSerializer.constructor(
{
...defaultMarkdownSerializer.nodes,
paragraph: (state, node) => {
serializeParagraph(state, node); // Use custom paragraph serialization
}
// Customize other block formats if needed
},
// Copy marks directly from the original serializer (or customize them if necessary)
defaultMarkdownSerializer.marks
);
// Utility function to convert ProseMirror content back to markdown text
function serializeEditorContent(doc) {
const markdown = customMarkdownSerializer.serialize(doc);
if (trim) {
return unescapeMarkdown(markdown).trim();
} else {
return unescapeMarkdown(markdown);
}
}
// ---- Input Rules ----
// Input rule for heading (e.g., # Headings)
function headingRule(schema) {
return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
level: match[1].length
}));
}
// Input rule for bullet list (e.g., `- item`)
function bulletListRule(schema) {
return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
}
// Input rule for ordered list (e.g., `1. item`)
function orderedListRule(schema) {
return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
order: +match[1]
}));
}
// Custom input rules for Bold/Italic (using * or _)
function markInputRule(regexp: RegExp, markType: any) {
return new InputRule(regexp, (state, match, start, end) => {
const { tr } = state;
if (match) {
tr.replaceWith(start, end, schema.text(match[1], [markType.create()]));
}
return tr;
});
}
function boldRule(schema) {
return markInputRule(/(?<=^|\s)\*([^*]+)\*(?=\s|$)/, schema.marks.strong);
}
function italicRule(schema) {
// Using lookbehind and lookahead to prevent the space from being consumed
return markInputRule(/(?<=^|\s)_([^*_]+)_(?=\s|$)/, schema.marks.em);
}
// Initialize Editor State and View
function afterSpacePress(state, dispatch) {
// Get the position right after the space was naturally inserted by the browser.
let { from, to, empty } = state.selection;
if (dispatch && empty) {
let tr = state.tr;
// Check for any active marks at `from - 1` (the space we just inserted)
const storedMarks = state.storedMarks || state.selection.$from.marks();
const hasBold = storedMarks.some((mark) => mark.type === state.schema.marks.strong);
const hasItalic = storedMarks.some((mark) => mark.type === state.schema.marks.em);
// Remove marks from the space character (marks applied to the space character will be marked as false)
if (hasBold) {
tr = tr.removeMark(from - 1, from, state.schema.marks.strong);
}
if (hasItalic) {
tr = tr.removeMark(from - 1, from, state.schema.marks.em);
}
// Dispatch the resulting transaction to update the editor state
dispatch(tr);
}
return true;
}
function toggleMark(markType) {
return (state, dispatch) => {
const { from, to } = state.selection;
if (state.doc.rangeHasMark(from, to, markType)) {
if (dispatch) dispatch(state.tr.removeMark(from, to, markType));
return true;
} else {
if (dispatch) dispatch(state.tr.addMark(from, to, markType.create()));
return true;
}
};
}
function isInList(state) {
const { $from } = state.selection;
return (
$from.parent.type === schema.nodes.paragraph && $from.node(-1).type === schema.nodes.list_item
);
}
function isEmptyListItem(state) {
const { $from } = state.selection;
return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1;
}
function exitList(state, dispatch) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
function findNextTemplate(doc, from = 0) {
const patterns = [
{ start: '[', end: ']' },
{ start: '{{', end: '}}' }
];
let result = null;
doc.nodesBetween(from, doc.content.size, (node, pos) => {
if (result) return false; // Stop if we've found a match
if (node.isText) {
const text = node.text;
let index = Math.max(0, from - pos);
while (index < text.length) {
for (const pattern of patterns) {
if (text.startsWith(pattern.start, index)) {
const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
if (endIndex !== -1) {
result = {
from: pos + index,
to: pos + endIndex + pattern.end.length
};
return false; // Stop searching
}
}
}
index++;
}
}
});
return result;
}
function selectNextTemplate(state, dispatch) {
const { doc, selection } = state;
const from = selection.to;
let template = findNextTemplate(doc, from);
if (!template) {
// If not found, search from the beginning
template = findNextTemplate(doc, 0);
}
if (template) {
if (dispatch) {
const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
dispatch(tr);
}
return true;
}
return false;
}
// Replace tabs with four spaces
function handleTabIndentation(text: string): string {
// Replace each tab character with four spaces
return text.replace(/\t/g, ' ');
}
onMount(() => {
const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
state = EditorState.create({
doc: initialDoc,
schema,
plugins: [
history(),
placeholderPlugin(placeholder),
inputRules({
rules: [
headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
bulletListRule(schema), // Handle `-` or `*` input to start bullet list
orderedListRule(schema), // Handle `1.` input to start ordered list
boldRule(schema), // Bold input rule
italicRule(schema) // Italic input rule
]
}),
keymap({
...baseKeymap,
'Mod-z': undo,
'Mod-y': redo,
Enter: (state, dispatch, view) => {
if (shiftEnter) {
eventDispatch('enter');
return true;
}
return chainCommands(
(state, dispatch, view) => {
if (isEmptyListItem(state)) {
return exitList(state, dispatch);
}
return false;
},
(state, dispatch, view) => {
if (isInList(state)) {
return splitListItem(schema.nodes.list_item)(state, dispatch);
}
return false;
},
baseKeymap.Enter
)(state, dispatch, view);
},
'Shift-Enter': (state, dispatch, view) => {
if (shiftEnter) {
return chainCommands(
(state, dispatch, view) => {
if (isEmptyListItem(state)) {
return exitList(state, dispatch);
}
return false;
},
(state, dispatch, view) => {
if (isInList(state)) {
return splitListItem(schema.nodes.list_item)(state, dispatch);
}
return false;
},
baseKeymap.Enter
)(state, dispatch, view);
} else {
return baseKeymap.Enter(state, dispatch, view);
}
return false;
},
// Prevent default tab navigation and provide indent/outdent behavior inside lists:
Tab: chainCommands((state, dispatch, view) => {
const { $from } = state.selection;
if (isInList(state)) {
return sinkListItem(schema.nodes.list_item)(state, dispatch);
} else {
return selectNextTemplate(state, dispatch);
}
return true; // Prevent Tab from moving the focus
}),
'Shift-Tab': (state, dispatch, view) => {
const { $from } = state.selection;
if (isInList(state)) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
return true; // Prevent Shift-Tab from moving the focus
},
'Mod-b': toggleMark(schema.marks.strong),
'Mod-i': toggleMark(schema.marks.em)
})
]
});
view = new EditorView(element, {
state,
dispatchTransaction(transaction) {
// Update editor state
let newState = view.state.apply(transaction);
view.updateState(newState);
value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
eventDispatch('input', { value });
},
handleDOMEvents: {
focus: (view, event) => {
eventDispatch('focus', { event });
return false;
},
keypress: (view, event) => {
eventDispatch('keypress', { event });
return false;
},
keydown: (view, event) => {
eventDispatch('keydown', { event });
return false;
},
paste: (view, event) => {
if (event.clipboardData) {
// Extract plain text from clipboard and paste it without formatting
const plainText = event.clipboardData.getData('text/plain');
if (plainText) {
const modifiedText = handleTabIndentation(plainText);
console.log(modifiedText);
// Replace the current selection with the plain text content
const tr = view.state.tr.replaceSelectionWith(
view.state.schema.text(modifiedText),
false
);
view.dispatch(tr.scrollIntoView());
event.preventDefault(); // Prevent the default paste behavior
return true;
}
// Check if the pasted content contains image files
const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
file.type.startsWith('image/')
);
// Check for image in dataTransfer items (for cases where files are not available)
const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
item.type.startsWith('image/')
);
if (hasImageFile) {
// If there's an image, dispatch the event to the parent
eventDispatch('paste', { event });
event.preventDefault();
return true;
}
if (hasImageItem) {
// If there's an image item, dispatch the event to the parent
eventDispatch('paste', { event });
event.preventDefault();
return true;
}
}
// For all other cases (text, formatted text, etc.), let ProseMirror handle it
return false;
},
// Handle space input after browser has completed it
keyup: (view, event) => {
if (event.key === ' ' && event.code === 'Space') {
afterSpacePress(view.state, view.dispatch);
}
return false;
}
},
attributes: { id }
});
});
// Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
$: if (view && value !== serializeEditorContent(view.state.doc)) {
const newDoc = markdownToProseMirrorDoc(value || '');
const newState = EditorState.create({
doc: newDoc,
schema,
plugins: view.state.plugins,
selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
});
view.updateState(newState);
if (value !== '') {
// After updating the state, try to find and select the next template
setTimeout(() => {
const templateFound = selectNextTemplate(view.state, view.dispatch);
if (!templateFound) {
// If no template found, set cursor at the end
const endPos = view.state.doc.content.size;
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, endPos)));
}
}, 0);
}
}
// Destroy ProseMirror instance on unmount
onDestroy(() => {
view?.destroy();
});
</script>
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div>
+26 -2
View File
@@ -1,11 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import panzoom from 'panzoom';
import DOMPurify from 'dompurify';
import DocumentDuplicate from '../icons/DocumentDuplicate.svelte';
import { copyToClipboard } from '$lib/utils';
import { toast } from 'svelte-sonner';
import Tooltip from './Tooltip.svelte';
import Clipboard from '../icons/Clipboard.svelte';
export let className = '';
export let svg = '';
export let content = '';
let instance;
@@ -22,8 +30,24 @@
}
</script>
<div bind:this={sceneParentElement} class={className}>
<div bind:this={sceneParentElement} class="relative {className}">
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
{@html svg}
</div>
{#if content}
<div class=" absolute top-1 right-1">
<Tooltip content={$i18n.t('Copy to clipboard')}>
<button
class="p-1.5 rounded-lg border border-gray-100 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={() => {
copyToClipboard(content);
toast.success($i18n.t('Copied to clipboard'));
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
{/if}
</div>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { quadInOut, quintIn } from 'svelte/easing';
import { fade, slide } from 'svelte/transition';
export let show = false;
export let side = 'right';
export let width = '200px';
export let className = '';
</script>
{#if show}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="absolute z-20 top-0 right-0 left-0 bottom-0 bg-white/20 dark:bg-black/5 w-full min-h-full h-full flex justify-center overflow-hidden overscroll-contain"
on:mousedown={() => {
show = false;
}}
transition:fade
/>
<div
class="absolute z-30 shadow-xl {side === 'right' ? 'right-0' : 'left-0'} top-0 bottom-0"
transition:slide={{ easing: quadInOut, axis: side === 'right' ? 'x' : 'y' }}
>
<div class="{className} h-full" style="width: {show ? width : '0px'}">
<slot />
</div>
</div>
{/if}
+1 -1
View File
@@ -12,7 +12,7 @@
await tick();
dispatch('change', e);
}}
class="flex h-5 min-h-5 w-9 shrink-0 cursor-pointer items-center rounded-full px-[3px] transition {state
class="flex h-5 min-h-5 w-9 shrink-0 cursor-pointer items-center rounded-full px-[3px] mx-[1px] transition {state
? ' bg-emerald-600'
: 'bg-gray-200 dark:bg-transparent'} outline outline-1 outline-gray-100 dark:outline-gray-800"
>
+4 -6
View File
@@ -1,28 +1,26 @@
<script lang="ts">
import TagInput from './Tags/TagInput.svelte';
import TagList from './Tags/TagList.svelte';
import { getContext } from 'svelte';
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
export let tags = [];
export let deleteTag: Function;
export let addTag: Function;
</script>
<div class="flex flex-row flex-wrap gap-1 line-clamp-1">
<TagList
{tags}
on:delete={(e) => {
deleteTag(e.detail);
dispatch('delete', e.detail);
}}
/>
<TagInput
label={tags.length == 0 ? $i18n.t('Add Tags') : ''}
on:add={(e) => {
addTag(e.detail);
dispatch('add', e.detail);
}}
/>
</div>
+22 -24
View File
@@ -1,34 +1,32 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '../Badge.svelte';
const dispatch = createEventDispatcher();
export let tags = [];
</script>
{#each tags as tag}
<div
class="px-2 py-[0.5px] gap-0.5 flex justify-between h-fit items-center rounded-full transition border dark:border-gray-800 dark:text-white"
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1">
{tag.name}
</div>
<button
class="h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
<Tooltip content={tag.name}>
<div
class="relative group/tags px-1.5 py-[0.2px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-full bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3 m-auto self-center translate-y-[0.3px] translate-x-[3px]"
>
<path
d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
/>
</svg>
</button>
</div>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="absolute invisible right-0.5 group-hover/tags:visible transition">
<button
class="rounded-full border bg-white dark:bg-gray-700 h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
>
<XMark className="size-3" strokeWidth="2.5" />
</button>
</div>
</div>
</Tooltip>
{/each}
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
export let value = '';
export let placeholder = '';
export let rows = 1;
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-none resize-none h-full';
let textareaElement;
onMount(async () => {
await tick();
if (textareaElement) {
await tick();
setTimeout(adjustHeight, 0);
}
});
$: if (value) {
setTimeout(adjustHeight, 0);
}
const adjustHeight = () => {
if (textareaElement) {
textareaElement.style.height = '';
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
}
};
</script>
<textarea
bind:this={textareaElement}
bind:value
{placeholder}
on:input={adjustHeight}
on:focus={adjustHeight}
class={className}
{rows}
{required}
/>
@@ -0,0 +1,15 @@
<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="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
+10
View File
@@ -0,0 +1,10 @@
<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 24 24" fill="currentColor" class={className}>
<path
d="M18.375 2.25c-1.035 0-1.875.84-1.875 1.875v15.75c0 1.035.84 1.875 1.875 1.875h.75c1.035 0 1.875-.84 1.875-1.875V4.125c0-1.036-.84-1.875-1.875-1.875h-.75ZM9.75 8.625c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-.75a1.875 1.875 0 0 1-1.875-1.875V8.625ZM3 13.125c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v6.75c0 1.035-.84 1.875-1.875 1.875h-.75A1.875 1.875 0 0 1 3 19.875v-6.75Z"
/>
</svg>
-124
View File
@@ -1,124 +0,0 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Star from '$lib/components/icons/Star.svelte';
const i18n = getContext('i18n');
export let pinHandler: Function;
export let shareHandler: Function;
export let cloneChatHandler: Function;
export let archiveChatHandler: Function;
export let renameHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
export let chatId = '';
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<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-md"
on:click={() => {
pinHandler();
}}
>
<Star strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Pin')}</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-md"
on:click={() => {
renameHandler();
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</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-md"
on:click={() => {
cloneChatHandler();
}}
>
<DocumentDuplicate strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Clone')}</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-md"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBox strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Archive')}</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-md"
on:click={() => {
shareHandler();
}}
>
<Share />
<div class="flex items-center">{$i18n.t('Share')}</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-md"
on:click={() => {
deleteHandler();
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<div class="flex p-1">
<Tags
{chatId}
on:close={() => {
show = false;
onClose();
}}
/>
</div>
</DropdownMenu.Content>
</div>
</Dropdown>
@@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'w-4 h-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="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z"
/>
</svg>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'w-4 h-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.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
+19
View File
@@ -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.25m2.25 0H5.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>
@@ -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.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.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>
@@ -0,0 +1,15 @@
<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 24 24" fill="currentColor" class={className}>
<path
fill-rule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 17.25a.75.75 0 0 0-1.5 0V18a.75.75 0 0 0 1.5 0v-.75Zm2.25-3a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3a.75.75 0 0 1 .75-.75Zm3.75-1.5a.75.75 0 0 0-1.5 0V18a.75.75 0 0 0 1.5 0v-5.25Z"
clip-rule="evenodd"
/>
<path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
/>
</svg>
+19
View File
@@ -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.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
+10
View File
@@ -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>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
export let className = 'w-4 h-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="M5 12h14" />
</svg>
@@ -0,0 +1,10 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
<path
d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z"
/>
</svg>
@@ -0,0 +1,13 @@
<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="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z"
/>
</svg>
@@ -0,0 +1,12 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
<path
fill-rule="evenodd"
d="M9 4.5a.75.75 0 0 1 .721.544l.813 2.846a3.75 3.75 0 0 0 2.576 2.576l2.846.813a.75.75 0 0 1 0 1.442l-2.846.813a3.75 3.75 0 0 0-2.576 2.576l-.813 2.846a.75.75 0 0 1-1.442 0l-.813-2.846a3.75 3.75 0 0 0-2.576-2.576l-2.846-.813a.75.75 0 0 1 0-1.442l2.846-.813A3.75 3.75 0 0 0 7.466 7.89l.813-2.846A.75.75 0 0 1 9 4.5ZM18 1.5a.75.75 0 0 1 .728.568l.258 1.036c.236.94.97 1.674 1.91 1.91l1.036.258a.75.75 0 0 1 0 1.456l-1.036.258c-.94.236-1.674.97-1.91 1.91l-.258 1.036a.75.75 0 0 1-1.456 0l-.258-1.036a2.625 2.625 0 0 0-1.91-1.91l-1.036-.258a.75.75 0 0 1 0-1.456l1.036-.258a2.625 2.625 0 0 0 1.91-1.91l.258-1.036A.75.75 0 0 1 18 1.5ZM16.5 15a.75.75 0 0 1 .712.513l.394 1.183c.15.447.5.799.948.948l1.183.395a.75.75 0 0 1 0 1.422l-1.183.395c-.447.15-.799.5-.948.948l-.395 1.183a.75.75 0 0 1-1.422 0l-.395-1.183a1.5 1.5 0 0 0-.948-.948l-1.183-.395a.75.75 0 0 1 0-1.422l1.183-.395c.447-.15.799-.5.948-.948l.395-1.183A.75.75 0 0 1 16.5 15Z"
clip-rule="evenodd"
/>
</svg>
+2 -2
View File
@@ -47,12 +47,12 @@
class=" bg-gradient-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] blur"
></div>
<div class=" flex max-w-full w-full mx-auto px-5 pt-0.5 md:px-[1rem] bg-transparen">
<div class=" flex max-w-full w-full mx-auto px-2 pt-0.5 bg-transparent">
<div class="flex items-center w-full max-w-full">
<div
class="{$showSidebar
? 'md:hidden'
: ''} mr-3 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
: ''} mr-2 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
>
<button
id="sidebar-toggle-button"
+48 -44
View File
@@ -24,6 +24,7 @@
import Clipboard from '$lib/components/icons/Clipboard.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import Cube from '$lib/components/icons/Cube.svelte';
import { getChatById } from '$lib/apis/chats';
const i18n = getContext('i18n');
@@ -81,6 +82,9 @@
};
const downloadJSONExport = async () => {
if (chat.id) {
chat = await getChatById(localStorage.token, chat.id);
}
let blob = new Blob([JSON.stringify([chat])], {
type: 'application/json'
});
@@ -99,7 +103,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="end"
@@ -148,6 +152,30 @@
</DropdownMenu.Item>
{/if}
{#if !$temporaryChatEnabled}
<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"
on:click={() => {
shareHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>
<div class="flex items-center">{$i18n.t('Share')}</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"
id="chat-overview-button"
@@ -174,47 +202,6 @@
<div class="flex items-center">{$i18n.t('Artifacts')}</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"
id="chat-copy-button"
on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => {
console.error(e);
});
if (res) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy')}</div>
</DropdownMenu.Item>
{#if !$temporaryChatEnabled}
<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"
on:click={() => {
shareHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<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"
@@ -237,7 +224,7 @@
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
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}
>
@@ -269,8 +256,25 @@
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<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-copy-button"
on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => {
console.error(e);
});
if (res) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy')}</div>
</DropdownMenu.Item>
{#if !$temporaryChatEnabled}
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<hr class="border-gray-50 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags chatId={chat.id} />
+373 -230
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { goto } from '$app/navigation';
import {
user,
@@ -14,38 +16,39 @@
pinnedChats,
scrollPaginationEnabled,
currentChatPage,
temporaryChatEnabled,
showArtifacts,
showOverview,
showControls
temporaryChatEnabled
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import { updateUserSettings } from '$lib/apis/users';
import {
deleteChatById,
getChatList,
getChatById,
getChatListByTagName,
updateChatById,
getAllChatTags,
archiveChatById,
cloneChatById,
getAllTags,
getChatListBySearchText,
createNewChat
createNewChat,
getPinnedChatList,
toggleChatPinnedStatusById,
getChatPinnedStatusById,
getChatById,
updateChatFolderIdById,
importChat
} from '$lib/apis/chats';
import { WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte';
import UserMenu from './Sidebar/UserMenu.svelte';
import ChatItem from './Sidebar/ChatItem.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
import Loader from '../common/Loader.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
import SearchInput from './Sidebar/SearchInput.svelte';
import Folder from '../common/Folder.svelte';
import Plus from '../icons/Plus.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import Folders from './Sidebar/Folders.svelte';
const BREAKPOINT = 768;
@@ -55,22 +58,105 @@
let shiftKey = false;
let selectedChatId = null;
let deleteChat = null;
let showDeleteConfirm = false;
let showDropdown = false;
let selectedTagName = null;
let showPinnedChat = true;
// Pagination variables
let chatListLoading = false;
let allChatsLoaded = false;
let folders = {};
const initFolders = async () => {
const folderList = await getFolders(localStorage.token).catch((error) => {
toast.error(error);
return [];
});
folders = {};
// First pass: Initialize all folder entries
for (const folder of folderList) {
// Ensure folder is added to folders with its data
folders[folder.id] = { ...(folders[folder.id] || {}), ...folder };
}
// Second pass: Tie child folders to their parents
for (const folder of folderList) {
if (folder.parent_id) {
// Ensure the parent folder is initialized if it doesn't exist
if (!folders[folder.parent_id]) {
folders[folder.parent_id] = {}; // Create a placeholder if not already present
}
// Initialize childrenIds array if it doesn't exist and add the current folder id
folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds
? [...folders[folder.parent_id].childrenIds, folder.id]
: [folder.id];
// Sort the children by updated_at field
folders[folder.parent_id].childrenIds.sort((a, b) => {
return folders[b].updated_at - folders[a].updated_at;
});
}
}
};
const createFolder = async (name = 'Untitled') => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// If a folder with the same name already exists, append a number to the name
let i = 1;
while (
rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
) {
i++;
}
name = `${name} ${i}`;
}
// Add a dummy folder to the list to show the user that the folder is being created
const tempId = uuidv4();
folders = {
...folders,
tempId: {
id: tempId,
name: name,
created_at: Date.now(),
updated_at: Date.now()
}
};
const res = await createNewFolder(localStorage.token, name).catch((error) => {
toast.error(error);
return null;
});
if (res) {
await initFolders();
}
};
const initChatList = async () => {
// Reset pagination variables
tags.set(await getAllTags(localStorage.token));
pinnedChats.set(await getPinnedChatList(localStorage.token));
initFolders();
currentChatPage.set(1);
allChatsLoaded = false;
await chats.set(await getChatList(localStorage.token, $currentChatPage));
if (search) {
await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage));
} else {
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
// Enable pagination
scrollPaginationEnabled.set(true);
@@ -91,7 +177,7 @@
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
await chats.set([...$chats, ...newChatList]);
await chats.set([...($chats ? $chats : []), ...newChatList]);
chatListLoading = false;
};
@@ -101,7 +187,6 @@
const searchDebounceHandler = async () => {
console.log('search', search);
chats.set(null);
selectedTagName = null;
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
@@ -112,31 +197,27 @@
return;
} else {
searchDebounceTimeout = setTimeout(async () => {
allChatsLoaded = false;
currentChatPage.set(1);
await chats.set(await getChatListBySearchText(localStorage.token, search));
if ($chats.length === 0) {
tags.set(await getAllTags(localStorage.token));
}
}, 1000);
}
};
const deleteChatHandler = async (id) => {
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
if ($chatId === id) {
await chatId.set('');
await tick();
goto('/');
const importChatHandler = async (items, pinned = false, folderId = null) => {
console.log('importChatHandler', items, pinned, folderId);
for (const item of items) {
console.log(item);
if (item.chat) {
await importChat(localStorage.token, item.chat, item?.meta ?? {}, pinned, folderId);
}
allChatsLoaded = false;
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
}
initChatList();
};
const inputFilesHandler = async (files) => {
@@ -148,50 +229,58 @@
const content = e.target.result;
try {
const items = JSON.parse(content);
for (const item of items) {
if (item.chat) {
await createNewChat(localStorage.token, item.chat);
}
}
const chatItems = JSON.parse(content);
importChatHandler(chatItems);
} catch {
toast.error($i18n.t(`Invalid file format.`));
}
initChatList();
};
reader.readAsText(file);
}
};
let dragged = false;
const tagEventHandler = async (type, tagName, chatId) => {
console.log(type, tagName, chatId);
if (type === 'delete') {
initChatList();
} else if (type === 'add') {
initChatList();
}
};
let draggedOver = false;
const onDragOver = (e) => {
e.preventDefault();
dragged = true;
// Check if a file is being draggedOver.
if (e.dataTransfer?.types?.includes('Files')) {
draggedOver = true;
} else {
draggedOver = false;
}
};
const onDragLeave = () => {
dragged = false;
draggedOver = false;
};
const onDrop = async (e) => {
e.preventDefault();
console.log(e);
console.log(e); // Log the drop event
// Perform file drop check and handle it accordingly
if (e.dataTransfer?.files) {
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
inputFilesHandler(inputFiles);
} else {
toast.error($i18n.t(`File not found.`));
console.log(inputFiles); // Log the dropped files
inputFilesHandler(inputFiles); // Handle the dropped files
}
}
dragged = false;
draggedOver = false; // Reset draggedOver status after drop
};
let touchstart;
@@ -240,6 +329,8 @@
};
onMount(async () => {
showPinnedChat = localStorage?.showPinnedChat ? localStorage.showPinnedChat === 'true' : true;
mobile.subscribe((e) => {
if ($showSidebar && e) {
showSidebar.set(false);
@@ -255,7 +346,6 @@
localStorage.sidebar = value;
});
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
await initChatList();
window.addEventListener('keydown', onKeyDown);
@@ -295,22 +385,10 @@
<ArchivedChatsModal
bind:show={$showArchivedChats}
on:change={async () => {
await chats.set(await getChatList(localStorage.token));
await initChatList();
}}
/>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
deleteChatHandler(deleteChat.id);
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-semibold">{deleteChat.title}</span>.
</div>
</DeleteConfirmDialog>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if $showSidebar}
@@ -326,32 +404,20 @@
bind:this={navElement}
id="sidebar"
class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar
? 'md:relative w-[260px]'
: '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0
? 'md:relative w-[260px] max-w-[260px]'
: '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0 overflow-x-hidden
"
data-state={$showSidebar}
>
{#if dragged}
<div
class="absolute w-full h-full max-h-full backdrop-blur bg-gray-800/40 flex justify-center z-[999] touch-none pointer-events-none"
>
<div class="m-auto pt-64 flex flex-col justify-center">
<AddFilesPlaceholder
title={$i18n.t('Drop Chat Export')}
content={$i18n.t('Drop a chat export file here to import it.')}
/>
</div>
</div>
{/if}
<div
class="py-2.5 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] z-50 {$showSidebar
class="py-2.5 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden z-50 {$showSidebar
? ''
: 'invisible'}"
>
<div class="px-2.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400">
<a
id="sidebar-new-chat-button"
class="flex flex-1 justify-between rounded-xl px-2 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="flex flex-1 justify-between rounded-lg px-2 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/"
draggable="false"
on:click={async () => {
@@ -395,7 +461,7 @@
</a>
<button
class=" cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class=" cursor-pointer px-2 py-2 flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
@@ -422,7 +488,7 @@
{#if $user?.role === 'admin'}
<div class="px-2.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
class="flex-grow flex space-x-3 rounded-xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="flex-grow flex space-x-3 rounded-lg px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={() => {
selectedChatId = null;
@@ -458,6 +524,31 @@
</div>
{/if}
<div class="relative {$temporaryChatEnabled ? 'opacity-20' : ''}">
{#if $temporaryChatEnabled}
<div class="absolute z-40 w-full h-full flex justify-center"></div>
{/if}
<div class="absolute z-40 right-4 top-1">
<Tooltip content={$i18n.t('New folder')}>
<button
class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
on:click={() => {
createFolder();
}}
>
<Plus />
</button>
</Tooltip>
</div>
<SearchInput
bind:value={search}
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
/>
</div>
<div
class="relative flex flex-col flex-1 overflow-y-auto {$temporaryChatEnabled
? 'opacity-20'
@@ -467,115 +558,164 @@
<div class="absolute z-40 w-full h-full flex justify-center"></div>
{/if}
<div class="px-2 mt-0.5 mb-2 flex justify-center space-x-2">
<div class="flex w-full rounded-xl" id="chat-search">
<div class="self-center pl-3 py-2 rounded-l-xl bg-transparent">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
{#if !search && $pinnedChats.length > 0}
<div class="flex flex-col space-y-1 rounded-xl">
<Folder
className="px-2"
bind:open={showPinnedChat}
on:change={(e) => {
localStorage.setItem('showPinnedChat', e.detail);
console.log(e.detail);
}}
on:import={(e) => {
importChatHandler(e.detail, true);
}}
on:drop={async (e) => {
const { type, id, item } = e.detail;
<input
class="w-full rounded-r-xl py-1.5 pl-2.5 pr-4 text-sm bg-transparent dark:text-gray-300 outline-none"
placeholder={$i18n.t('Search')}
bind:value={search}
on:input={() => {
searchDebounceHandler();
if (type === 'chat') {
let chat = await getChatById(localStorage.token, id).catch((error) => {
return null;
});
if (!chat && item) {
chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
}
if (chat) {
console.log(chat);
if (chat.folder_id) {
const res = await updateChatFolderIdById(
localStorage.token,
chat.id,
null
).catch((error) => {
toast.error(error);
return null;
});
}
if (!chat.pinned) {
const res = await toggleChatPinnedStatusById(localStorage.token, chat.id);
}
initChatList();
}
}
}}
name={$i18n.t('Pinned')}
>
<div
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
>
{#each $pinnedChats as chat, idx}
<ChatItem
className=""
id={chat.id}
title={chat.title}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:change={async () => {
initChatList();
}}
on:tag={(e) => {
const { type, name } = e.detail;
tagEventHandler(type, name, chat.id);
}}
/>
{/each}
</div>
</Folder>
</div>
{/if}
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
{#if !search && folders}
<Folders
{folders}
on:import={(e) => {
const { folderId, items } = e.detail;
importChatHandler(items, false, folderId);
}}
on:update={async (e) => {
initChatList();
}}
on:change={async () => {
initChatList();
}}
/>
</div>
</div>
{/if}
{#if $tags.filter((t) => t.name !== 'pinned').length > 0}
<div class="px-3.5 mb-1 flex gap-0.5 flex-wrap">
<button
class="px-2.5 py-[1px] text-xs transition {selectedTagName === null
? 'bg-gray-100 dark:bg-gray-900'
: ' '} rounded-md font-medium"
on:click={async () => {
selectedTagName = null;
await initChatList();
}}
>
{$i18n.t('all')}
</button>
{#each $tags.filter((t) => t.name !== 'pinned') as tag}
<button
class="px-2.5 py-[1px] text-xs transition {selectedTagName === tag.name
? 'bg-gray-100 dark:bg-gray-900'
: ''} rounded-md font-medium"
on:click={async () => {
selectedTagName = tag.name;
scrollPaginationEnabled.set(false);
let chatIds = await getChatListByTagName(localStorage.token, tag.name);
if (chatIds.length === 0) {
await tags.set(await getAllChatTags(localStorage.token));
<Folder
collapsible={!search}
className="px-2 mt-0.5"
name={$i18n.t('All chats')}
on:import={(e) => {
importChatHandler(e.detail);
}}
on:drop={async (e) => {
const { type, id, item } = e.detail;
// if the tag we deleted is no longer a valid tag, return to main chat list view
await initChatList();
if (type === 'chat') {
let chat = await getChatById(localStorage.token, id).catch((error) => {
return null;
});
if (!chat && item) {
chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
}
if (chat) {
console.log(chat);
if (chat.folder_id) {
const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
(error) => {
toast.error(error);
return null;
}
);
}
await chats.set(chatIds);
chatListLoading = false;
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
{#if !search && $pinnedChats.length > 0}
<div class="pl-2 py-2 flex flex-col space-y-1">
<div class="">
<div class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium pb-1.5">
{$i18n.t('Pinned')}
</div>
if (chat.pinned) {
const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
}
{#each $pinnedChats as chat, idx}
<ChatItem
{chat}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:delete={(e) => {
if ((e?.detail ?? '') === 'shift') {
deleteChatHandler(chat.id);
} else {
deleteChat = chat;
showDeleteConfirm = true;
}
}}
/>
{/each}
</div>
</div>
{/if}
initChatList();
}
} else if (type === 'folder') {
if (folders[id].parent_id === null) {
return;
}
<div class="pl-2 my-2 flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
{#if $chats}
{#each $chats as chat, idx}
{#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)}
<div
class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
? ''
: 'pt-5'} pb-0.5"
>
{$i18n.t(chat.time_range)}
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
await initFolders();
}
}
}}
>
<div class="pt-1.5">
{#if $chats}
{#each $chats as chat, idx}
{#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)}
<div
class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium {idx ===
0
? ''
: 'pt-5'} pb-1.5"
>
{$i18n.t(chat.time_range)}
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
{$i18n.t('Today')}
{$i18n.t('Yesterday')}
{$i18n.t('Previous 7 days')}
@@ -593,56 +733,59 @@
{$i18n.t('November')}
{$i18n.t('December')}
-->
</div>
{/if}
</div>
{/if}
<ChatItem
{chat}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:delete={(e) => {
if ((e?.detail ?? '') === 'shift') {
deleteChatHandler(chat.id);
} else {
deleteChat = chat;
showDeleteConfirm = true;
}
}}
/>
{/each}
<ChatItem
className=""
id={chat.id}
title={chat.title}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
selectedChatId = chat.id;
}}
on:unselect={() => {
selectedChatId = null;
}}
on:change={async () => {
initChatList();
}}
on:tag={(e) => {
const { type, name } = e.detail;
tagEventHandler(type, name, chat.id);
}}
/>
{/each}
{#if $scrollPaginationEnabled && !allChatsLoaded}
<Loader
on:visible={(e) => {
if (!chatListLoading) {
loadMoreChats();
}
}}
>
{#if $scrollPaginationEnabled && !allChatsLoaded}
<Loader
on:visible={(e) => {
if (!chatListLoading) {
loadMoreChats();
}
}}
>
<div
class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
</div>
</Loader>
{/if}
{:else}
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
</div>
</Loader>
{/if}
{:else}
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
{/if}
</div>
{/if}
</Folder>
</div>
</div>
<div class="px-2.5 pb-safe-bottom">
<!-- <hr class=" border-gray-900 mb-1 w-full" /> -->
<div class="px-2">
<div class="flex flex-col font-primary">
{#if $user !== undefined}
<UserMenu
@@ -654,7 +797,7 @@
}}
>
<button
class=" flex rounded-xl py-3 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class=" flex items-center rounded-xl py-2.5 px-2.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
showDropdown = !showDropdown;
}}
@@ -15,7 +15,6 @@
getArchivedChatList
} from '$lib/apis/chats';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let show = false;
@@ -30,7 +29,6 @@
});
chats = await getArchivedChatList(localStorage.token);
dispatch('change');
};
+173 -53
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
import { onMount, getContext, createEventDispatcher, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -10,8 +10,11 @@
archiveChatById,
cloneChatById,
deleteChatById,
getAllTags,
getChatById,
getChatList,
getChatListByTagName,
getPinnedChatList,
updateChatById
} from '$lib/apis/chats';
import {
@@ -21,25 +24,49 @@
mobile,
pinnedChats,
showSidebar,
currentChatPage
currentChatPage,
tags
} from '$lib/stores';
import ChatMenu from './ChatMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
export let className = '';
export let id;
export let title;
export let chat;
export let selected = false;
export let shiftKey = false;
let chat = null;
let mouseOver = false;
let draggable = false;
$: if (mouseOver) {
loadChat();
}
const loadChat = async () => {
if (!chat) {
draggable = false;
chat = await getChatById(localStorage.token, id);
draggable = true;
}
};
let showShareChatModal = false;
let confirmEdit = false;
let chatTitle = chat.title;
let chatTitle = title;
const editChatTitle = async (id, title) => {
if (title === '') {
@@ -55,7 +82,7 @@
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
}
};
@@ -70,29 +97,134 @@
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
}
};
const deleteChatHandler = async (id) => {
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
tags.set(await getAllTags(localStorage.token));
if ($chatId === id) {
await chatId.set('');
await tick();
goto('/');
}
dispatch('change');
}
};
const archiveChatHandler = async (id) => {
await archiveChatById(localStorage.token, id);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
dispatch('change');
};
const focusEdit = async (node: HTMLInputElement) => {
node.focus();
};
let itemElement;
let dragged = false;
let x = 0;
let y = 0;
const dragImage = new Image();
dragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
'text/plain',
JSON.stringify({
type: 'chat',
id: id,
item: chat
})
);
dragged = true;
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
};
const onDrag = (event) => {
event.stopPropagation();
x = event.clientX;
y = event.clientY;
};
const onDragEnd = (event) => {
event.stopPropagation();
itemElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
};
onMount(() => {
if (itemElement) {
// Event listener for when dragging starts
itemElement.addEventListener('dragstart', onDragStart);
// Event listener for when dragging occurs (optional)
itemElement.addEventListener('drag', onDrag);
// Event listener for when dragging ends
itemElement.addEventListener('dragend', onDragEnd);
}
});
onDestroy(() => {
if (itemElement) {
itemElement.removeEventListener('dragstart', onDragStart);
itemElement.removeEventListener('drag', onDrag);
itemElement.removeEventListener('dragend', onDragEnd);
}
});
let showDeleteConfirm = false;
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
<div class=" w-full pr-2 relative group">
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
deleteChatHandler(id);
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-semibold">{title}</span>.
</div>
</DeleteConfirmDialog>
{#if dragged && x && y}
<DragGhost {x} {y}>
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
<div class="flex items-center gap-1">
<Document className=" size-[18px]" strokeWidth="2" />
<div class=" text-xs text-white line-clamp-1">
{title}
</div>
</div>
</div>
</DragGhost>
{/if}
<div bind:this={itemElement} class=" w-full {className} relative group" {draggable}>
{#if confirmEdit}
<div
class=" w-full flex justify-between rounded-xl px-3 py-2 {chat.id === $chatId || confirmEdit
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-200 dark:bg-gray-900'
: selected
? 'bg-gray-100 dark:bg-gray-950'
@@ -106,12 +238,13 @@
</div>
{:else}
<a
class=" w-full flex justify-between rounded-xl px-3 py-2 {chat.id === $chatId || confirmEdit
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? '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"
href="/c/{chat.id}"
href="/c/{id}"
on:click={() => {
dispatch('select');
@@ -120,7 +253,7 @@
}
}}
on:dblclick={() => {
chatTitle = chat.title;
chatTitle = title;
confirmEdit = true;
}}
on:mouseenter={(e) => {
@@ -134,7 +267,7 @@
>
<div class=" flex self-center flex-1 w-full">
<div class=" text-left self-center overflow-hidden w-full h-[20px]">
{chat.title}
{title}
</div>
</div>
</a>
@@ -143,12 +276,14 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="
{chat.id === $chatId || confirmEdit
{id === $chatId || confirmEdit
? 'from-gray-200 dark:from-gray-900'
: selected
? 'from-gray-100 dark:from-gray-950'
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute right-[10px] top-[6px] py-1 pr-2 pl-5 bg-gradient-to-l from-80%
absolute {className === 'pr-2'
? 'right-[8px]'
: 'right-0'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-gradient-to-l from-80%
to-transparent"
on:mouseenter={(e) => {
@@ -159,28 +294,19 @@
}}
>
{#if confirmEdit}
<div class="flex self-center space-x-1.5 z-10">
<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')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
editChatTitle(chat.id, chatTitle);
editChatTitle(id, chatTitle);
confirmEdit = false;
chatTitle = '';
}}
>
<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>
<Check className=" size-3.5" strokeWidth="2.5" />
</button>
</Tooltip>
@@ -192,16 +318,7 @@
chatTitle = '';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-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>
<XMark strokeWidth="2.5" />
</button>
</Tooltip>
</div>
@@ -211,7 +328,7 @@
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
archiveChatHandler(chat.id);
archiveChatHandler(id);
}}
type="button"
>
@@ -223,7 +340,7 @@
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
dispatch('delete', 'shift');
deleteChatHandler(id);
}}
type="button"
>
@@ -234,29 +351,32 @@
{:else}
<div class="flex self-center space-x-1 z-10">
<ChatMenu
chatId={chat.id}
chatId={id}
cloneChatHandler={() => {
cloneChatHandler(chat.id);
cloneChatHandler(id);
}}
shareHandler={() => {
showShareChatModal = true;
}}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
archiveChatHandler(id);
}}
renameHandler={() => {
chatTitle = chat.title;
chatTitle = title;
confirmEdit = true;
}}
deleteHandler={() => {
dispatch('delete');
showDeleteConfirm = true;
}}
onClose={() => {
dispatch('unselect');
}}
on:change={async () => {
await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
dispatch('change');
}}
on:tag={(e) => {
dispatch('tag', e.detail);
}}
>
<button
@@ -279,13 +399,13 @@
</button>
</ChatMenu>
{#if chat.id === $chatId}
{#if id === $chatId}
<!-- Shortcut support using "delete-chat-button" id -->
<button
id="delete-chat-button"
class="hidden"
on:click={() => {
dispatch('delete');
showDeleteConfirm = true;
}}
>
<svg
+142 -19
View File
@@ -3,6 +3,9 @@
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
const dispatch = createEventDispatcher();
import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -15,7 +18,15 @@
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Bookmark from '$lib/components/icons/Bookmark.svelte';
import BookmarkSlash from '$lib/components/icons/BookmarkSlash.svelte';
import { addTagById, deleteTagById, getTagsById } from '$lib/apis/chats';
import {
getChatById,
getChatPinnedStatusById,
toggleChatPinnedStatusById
} from '$lib/apis/chats';
import { chats } from '$lib/stores';
import { createMessagesList } from '$lib/utils';
import { downloadChatAsPDF } from '$lib/apis/utils';
import Download from '$lib/components/icons/Download.svelte';
const i18n = getContext('i18n');
@@ -32,20 +43,76 @@
let pinned = false;
const pinHandler = async () => {
if (pinned) {
await deleteTagById(localStorage.token, chatId, 'pinned');
} else {
await addTagById(localStorage.token, chatId, 'pinned');
}
await toggleChatPinnedStatusById(localStorage.token, chatId);
dispatch('change');
};
const checkPinned = async () => {
pinned = (
await getTagsById(localStorage.token, chatId).catch(async (error) => {
return [];
})
).find((tag) => tag.name === 'pinned');
pinned = await getChatPinnedStatusById(localStorage.token, chatId);
};
const getChatAsText = async (chat) => {
const history = chat.chat.history;
const messages = createMessagesList(history, history.currentId);
const chatText = messages.reduce((a, message, i, arr) => {
return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
}, '');
return chatText.trim();
};
const downloadTxt = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (!chat) {
return;
}
const chatText = await getChatAsText(chat);
let blob = new Blob([chatText], {
type: 'text/plain'
});
saveAs(blob, `chat-${chat.chat.title}.txt`);
};
const downloadPdf = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (!chat) {
return;
}
const history = chat.chat.history;
const messages = createMessagesList(history, history.currentId);
const blob = await downloadChatAsPDF(chat.chat.title, messages);
// Create a URL for the blob
const url = window.URL.createObjectURL(blob);
// Create a link element to trigger the download
const a = document.createElement('a');
a.href = url;
a.download = `chat-${chat.chat.title}.pdf`;
// Append the link to the body and click it programmatically
document.body.appendChild(a);
a.click();
// Remove the link from the body
document.body.removeChild(a);
// Revoke the URL to release memory
window.URL.revokeObjectURL(url);
};
const downloadJSONExport = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (chat) {
let blob = new Blob([JSON.stringify([chat])], {
type: 'application/json'
});
saveAs(blob, `chat-export-${Date.now()}.json`);
}
};
$: if (show) {
@@ -67,14 +134,14 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<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-md"
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={() => {
pinHandler();
}}
@@ -89,7 +156,7 @@
</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-md"
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={() => {
renameHandler();
}}
@@ -99,7 +166,7 @@
</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-md"
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={() => {
cloneChatHandler();
}}
@@ -109,7 +176,7 @@
</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-md"
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={() => {
archiveChatHandler();
}}
@@ -119,7 +186,7 @@
</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-md"
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();
}}
@@ -128,8 +195,48 @@
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
<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={() => {
downloadJSONExport();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</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={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</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={() => {
downloadPdf();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<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-md"
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={() => {
deleteHandler();
}}
@@ -138,11 +245,27 @@
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<hr class="border-gray-50 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags
{chatId}
on:add={(e) => {
dispatch('tag', {
type: 'add',
name: e.detail.name
});
show = false;
}}
on:delete={(e) => {
dispatch('tag', {
type: 'delete',
name: e.detail.name
});
show = false;
}}
on:close={() => {
show = false;
onClose();
@@ -0,0 +1,35 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte';
export let folders = {};
let folderList = [];
// Get the list of folders that have no parent, sorted by name alphabetically
$: folderList = Object.keys(folders)
.filter((key) => folders[key].parent_id === null)
.sort((a, b) =>
folders[a].name.localeCompare(folders[b].name, undefined, {
numeric: true,
sensitivity: 'base'
})
);
</script>
{#each folderList as folderId (folderId)}
<RecursiveFolder
className="px-2"
{folders}
{folderId}
on:import={(e) => {
dispatch('import', e.detail);
}}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
@@ -0,0 +1,70 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
dispatch('close');
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[160px] rounded-lg px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<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={() => {
dispatch('rename');
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</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={() => {
dispatch('export');
}}
>
<Download strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Export')}</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={() => {
dispatch('delete');
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
@@ -0,0 +1,495 @@
<script>
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import DOMPurify from 'dompurify';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import ChevronDown from '../../icons/ChevronDown.svelte';
import ChevronRight from '../../icons/ChevronRight.svelte';
import Collapsible from '../../common/Collapsible.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import {
deleteFolderById,
updateFolderIsExpandedById,
updateFolderNameById,
updateFolderParentIdById
} from '$lib/apis/folders';
import { toast } from 'svelte-sonner';
import {
getChatById,
getChatsByFolderId,
importChat,
updateChatFolderIdById
} from '$lib/apis/chats';
import ChatItem from './ChatItem.svelte';
import FolderMenu from './Folders/FolderMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let open = false;
export let folders;
export let folderId;
export let className = '';
export let parentDragged = false;
let folderElement;
let edit = false;
let draggedOver = false;
let dragged = false;
let name = '';
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (dragged || parentDragged) {
return;
}
draggedOver = true;
};
const onDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
if (dragged || parentDragged) {
return;
}
if (folderElement.contains(e.target)) {
console.log('Dropped on the Button');
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// Iterate over all items in the DataTransferItemList use functional programming
for (const item of Array.from(e.dataTransfer.items)) {
// If dropped items aren't files, reject them
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type === 'application/json') {
console.log('Dropped file is a JSON file!');
// Read the JSON file with FileReader
const reader = new FileReader();
reader.onload = async function (event) {
try {
const fileContent = JSON.parse(event.target.result);
open = true;
dispatch('import', {
folderId: folderId,
items: fileContent
});
} catch (error) {
console.error('Error parsing JSON file:', error);
}
};
// Start reading the file
reader.readAsText(file);
} else {
console.error('Only JSON file types are supported.');
}
console.log(file);
} else {
// Handle the drag-and-drop data for folders or chats (same as before)
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
const { type, id, item } = data;
if (type === 'folder') {
open = true;
if (id === folderId) {
return;
}
// Move the folder
const res = await updateFolderParentIdById(localStorage.token, id, folderId).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
dispatch('update');
}
} else if (type === 'chat') {
open = true;
let chat = await getChatById(localStorage.token, id).catch((error) => {
return null;
});
if (!chat && item) {
chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
}
// Move the chat
const res = await updateChatFolderIdById(localStorage.token, chat.id, folderId).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
dispatch('update');
}
}
}
}
}
draggedOver = false;
}
};
const onDragLeave = (e) => {
e.preventDefault();
if (dragged || parentDragged) {
return;
}
draggedOver = false;
};
const dragImage = new Image();
dragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
let x;
let y;
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
'text/plain',
JSON.stringify({
type: 'folder',
id: folderId
})
);
dragged = true;
folderElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
};
const onDrag = (event) => {
event.stopPropagation();
x = event.clientX;
y = event.clientY;
};
const onDragEnd = (event) => {
event.stopPropagation();
folderElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
};
onMount(() => {
open = folders[folderId].is_expanded;
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
folderElement.addEventListener('drop', onDrop);
folderElement.addEventListener('dragleave', onDragLeave);
// Event listener for when dragging starts
folderElement.addEventListener('dragstart', onDragStart);
// Event listener for when dragging occurs (optional)
folderElement.addEventListener('drag', onDrag);
// Event listener for when dragging ends
folderElement.addEventListener('dragend', onDragEnd);
}
});
onDestroy(() => {
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave);
folderElement.removeEventListener('dragstart', onDragStart);
folderElement.removeEventListener('drag', onDrag);
folderElement.removeEventListener('dragend', onDragEnd);
}
});
let showDeleteConfirm = false;
const deleteHandler = async () => {
const res = await deleteFolderById(localStorage.token, folderId).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Folder deleted successfully'));
dispatch('update');
}
};
const nameUpdateHandler = async () => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty'));
return;
}
if (name === folders[folderId].name) {
edit = false;
return;
}
const currentName = folders[folderId].name;
name = name.trim();
folders[folderId].name = name;
const res = await updateFolderNameById(localStorage.token, folderId, name).catch((error) => {
toast.error(error);
folders[folderId].name = currentName;
return null;
});
if (res) {
folders[folderId].name = name;
toast.success($i18n.t('Folder name updated successfully'));
dispatch('update');
}
};
const isExpandedUpdateHandler = async () => {
const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch(
(error) => {
toast.error(error);
return null;
}
);
};
let isExpandedUpdateTimeout;
const isExpandedUpdateDebounceHandler = (open) => {
clearTimeout(isExpandedUpdateTimeout);
isExpandedUpdateTimeout = setTimeout(() => {
isExpandedUpdateHandler();
}, 500);
};
$: isExpandedUpdateDebounceHandler(open);
const editHandler = async () => {
console.log('Edit');
await tick();
name = folders[folderId].name;
edit = true;
await tick();
// focus on the input
setTimeout(() => {
const input = document.getElementById(`folder-${folderId}-input`);
input.focus();
}, 100);
};
const exportHandler = async () => {
const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(error);
return null;
});
if (!chats) {
return;
}
const blob = new Blob([JSON.stringify(chats)], {
type: 'application/json'
});
saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`);
};
</script>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete folder?')}
on:confirm={() => {
deleteHandler();
}}
>
<div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
{@html DOMPurify.sanitize(
$i18n.t('This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.', {
NAME: folders[folderId].name
})
)}
</div>
</DeleteConfirmDialog>
{#if dragged && x && y}
<DragGhost {x} {y}>
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
<div class="flex items-center gap-1">
<FolderOpen className="size-3.5" strokeWidth="2" />
<div class=" text-xs text-white line-clamp-1">
{folders[folderId].name}
</div>
</div>
</div>
</DragGhost>
{/if}
<div bind:this={folderElement} class="relative {className}" draggable="true">
{#if draggedOver}
<div
class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(260,85%,65%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
></div>
{/if}
<Collapsible
bind:open
className="w-full"
buttonClassName="w-full"
hide={(folders[folderId]?.childrenIds ?? []).length === 0 &&
(folders[folderId].items?.chats ?? []).length === 0}
on:change={(e) => {
dispatch('open', e.detail);
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="w-full group">
<button
id="folder-{folderId}-button"
class="relative w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:dblclick={() => {
editHandler();
}}
>
<div class="text-gray-300 dark:text-gray-600">
{#if open}
<ChevronDown className=" size-3" strokeWidth="2.5" />
{:else}
<ChevronRight className=" size-3" strokeWidth="2.5" />
{/if}
</div>
<div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1">
{#if edit}
<input
id="folder-{folderId}-input"
type="text"
bind:value={name}
on:blur={() => {
nameUpdateHandler();
edit = false;
}}
on:click={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:mousedown={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
nameUpdateHandler();
edit = false;
}
}}
class="w-full h-full bg-transparent text-gray-500 dark:text-gray-500 outline-none"
/>
{:else}
{folders[folderId].name}
{/if}
</div>
<button
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<FolderMenu
on:rename={() => {
editHandler();
}}
on:delete={() => {
showDeleteConfirm = true;
}}
on:export={() => {
exportHandler();
}}
>
<button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}>
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</button>
</FolderMenu>
</button>
</button>
</div>
<div slot="content" class="w-full">
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.chats ?? []).length > 0}
<div
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
>
{#if folders[folderId]?.childrenIds}
{@const children = folders[folderId]?.childrenIds
.map((id) => folders[id])
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: 'base'
})
)}
{#each children as childFolder (`${folderId}-${childFolder.id}`)}
<svelte:self
{folders}
folderId={childFolder.id}
parentDragged={dragged}
on:import={(e) => {
dispatch('import', e.detail);
}}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
{/if}
{#if folders[folderId].items?.chats}
{#each folders[folderId].items.chats as chat (chat.id)}
<ChatItem
id={chat.id}
title={chat.title}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
{/if}
</div>
{/if}
</div>
</Collapsible>
</div>
@@ -0,0 +1,218 @@
<script lang="ts">
import { tags } from '$lib/stores';
import { stringify } from 'postcss';
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
export let placeholder = '';
export let value = '';
let selectedIdx = 0;
let lastWord = '';
$: lastWord = value ? value.split(' ').at(-1) : value;
let focused = false;
let options = [
{
name: 'tag:',
description: $i18n.t('search for tags')
}
];
let filteredOptions = options;
$: filteredOptions = options.filter((option) => {
return option.name.startsWith(lastWord);
});
let filteredTags = [];
$: filteredTags = lastWord.startsWith('tag:')
? [
...$tags,
{
id: 'none',
name: $i18n.t('Untagged')
}
].filter((tag) => {
const tagName = lastWord.slice(4);
if (tagName) {
const tagId = tagName.replace(' ', '_').toLowerCase();
if (tag.id !== tagId) {
return tag.id.startsWith(tagId);
} else {
return false;
}
} else {
return true;
}
})
: [];
const documentClickHandler = (e) => {
const searchContainer = document.getElementById('search-container');
const chatSearch = document.getElementById('chat-search');
if (!searchContainer.contains(e.target) && !chatSearch.contains(e.target)) {
if (e.target.id.startsWith('search-tag-') || e.target.id.startsWith('search-option-')) {
return;
}
focused = false;
}
};
onMount(() => {
document.addEventListener('click', documentClickHandler);
});
onDestroy(() => {
document.removeEventListener('click', documentClickHandler);
});
</script>
<div class="px-2 mb-1 flex justify-center space-x-2 relative z-10" id="search-container">
<div class="flex w-full rounded-xl" id="chat-search">
<div class="self-center pl-3 py-2 rounded-l-xl bg-transparent">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class="w-full rounded-r-xl py-1.5 pl-2.5 pr-4 text-sm bg-transparent dark:text-gray-300 outline-none"
placeholder={placeholder ? placeholder : $i18n.t('Search')}
bind:value
on:input={() => {
dispatch('input');
}}
on:focus={() => {
focused = true;
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
if (filteredTags.length > 0) {
const tagElement = document.getElementById(`search-tag-${selectedIdx}`);
tagElement.click();
return;
}
if (filteredOptions.length > 0) {
const optionElement = document.getElementById(`search-option-${selectedIdx}`);
optionElement.click();
return;
}
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = Math.max(0, selectedIdx - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (filteredTags.length > 0) {
selectedIdx = Math.min(selectedIdx + 1, filteredTags.length - 1);
} else {
selectedIdx = Math.min(selectedIdx + 1, filteredOptions.length - 1);
}
} else {
// if the user types something, reset to the top selection.
selectedIdx = 0;
}
}}
/>
</div>
{#if focused && (filteredOptions.length > 0 || filteredTags.length > 0)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="absolute top-0 mt-8 left-0 right-1 border dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg"
in:fade={{ duration: 50 }}
on:mouseenter={() => {
selectedIdx = null;
}}
on:mouseleave={() => {
selectedIdx = 0;
}}
>
<div class="px-2 py-2 text-xs group">
{#if filteredTags.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Tags</div>
<div class="max-h-60 overflow-auto">
{#each filteredTags as tag, tagIdx}
<button
class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===
tagIdx
? 'bg-gray-100 dark:bg-gray-900'
: ''}"
id="search-tag-{tagIdx}"
on:click|stopPropagation={async () => {
const words = value.split(' ');
words.pop();
words.push(`tag:${tag.id} `);
value = words.join(' ');
dispatch('input');
}}
>
<div
class="dark:text-gray-300 text-gray-700 font-medium line-clamp-1 flex-shrink-0"
>
{tag.name}
</div>
<div class=" text-gray-500 line-clamp-1">
{tag.id}
</div>
</button>
{/each}
</div>
{:else if filteredOptions.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Search options</div>
<div class=" max-h-60 overflow-auto">
{#each filteredOptions as option, optionIdx}
<button
class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===
optionIdx
? 'bg-gray-100 dark:bg-gray-900'
: ''}"
id="search-option-{optionIdx}"
on:click|stopPropagation={async () => {
const words = value.split(' ');
words.pop();
words.push('tag:');
value = words.join(' ');
dispatch('input');
}}
>
<div class="dark:text-gray-300 text-gray-700 font-medium">{option.name}</div>
<div class=" text-gray-500 line-clamp-1">
{option.description}
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More