Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev
This commit is contained in:
+28
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={className}>
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user