fix merge conflicts

This commit is contained in:
Taylor Wilsdon
2024-12-16 15:29:49 -05:00
383 changed files with 45034 additions and 23866 deletions
+103 -9
View File
@@ -16,6 +16,12 @@
font-display: swap;
}
@font-face {
font-family: 'InstrumentSerif';
src: url('/assets/fonts/InstrumentSerif-Regular.ttf');
font-display: swap;
}
html {
word-break: break-word;
}
@@ -26,6 +32,10 @@ code {
width: auto;
}
.font-secondary {
font-family: 'InstrumentSerif', sans-serif;
}
math {
margin-top: 1rem;
}
@@ -35,15 +45,15 @@ math {
}
.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;
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 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;
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 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;
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 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;
}
.markdown a {
@@ -189,19 +199,103 @@ input[type='number'] {
}
.ProseMirror {
@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
}
.ProseMirror:focus {
outline: none;
}
.placeholder::after {
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
cursor: text;
float: left;
color: #adb5bd;
pointer-events: none;
float: left;
@apply absolute inset-0 z-0 text-gray-500;
@apply line-clamp-1 absolute;
}
.ai-autocompletion::after {
color: #a0a0a0;
content: attr(data-suggestion);
pointer-events: none;
}
.tiptap > pre > code {
border-radius: 0.4rem;
font-size: 0.85rem;
padding: 0.25em 0.3em;
@apply dark:bg-gray-800 bg-gray-100;
}
.tiptap > pre {
border-radius: 0.5rem;
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
@apply dark:bg-gray-800 bg-gray-100;
}
.tiptap p code {
color: #eb5757;
border-width: 0px;
padding: 3px 8px;
font-size: 0.8em;
font-weight: 600;
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
}
/* Code styling */
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
+6 -3
View File
@@ -2,9 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
+144
View File
@@ -110,6 +110,150 @@ export const getSessionUser = async (token: string) => {
return res;
};
export const ldapUserSignIn = async (user: string, password: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/ldap`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
user: user,
password: password
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getLdapConfig = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateLdapConfig = async (token: string = '', enable_ldap: boolean) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
enable_ldap: enable_ldap
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getLdapServer = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap/server`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateLdapServer = async (token: string = '', body: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap/server`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const userSignIn = async (email: string, password: string) => {
let error = null;
+31 -4
View File
@@ -58,17 +58,44 @@ export const exportConfig = async (token: string) => {
return res;
};
export const setDefaultModels = async (token: string, models: string) => {
export const getModelsConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/models`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setModelsConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
models: models
...config
})
})
.then(async (res) => {
@@ -91,7 +118,7 @@ export const setDefaultModels = async (token: string, models: string) => {
export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/suggestions`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/suggestions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+162
View File
@@ -0,0 +1,162 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewGroup = async (token: string, group: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...group
})
})
.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 getGroups = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/`, {
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 getGroupById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${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 updateGroupById = async (token: string, id: string, group: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...group
})
})
.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 deleteGroupById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/delete`, {
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;
};
+122 -59
View File
@@ -1,9 +1,8 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
export const getModels = async (token: string = '') => {
export const getModels = async (token: string = '', base: boolean = false) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -16,8 +15,8 @@ export const getModels = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
console.log(err);
return null;
});
@@ -26,42 +25,6 @@ export const getModels = async (token: string = '') => {
}
let models = res?.data ?? [];
models = models
.filter((models) => models)
// Sort the models
.sort((a, b) => {
// Check if models have position property
const aHasPosition = a.info?.meta?.position !== undefined;
const bHasPosition = b.info?.meta?.position !== undefined;
// If both a and b have the position property
if (aHasPosition && bHasPosition) {
return a.info.meta.position - b.info.meta.position;
}
// If only a has the position property, it should come first
if (aHasPosition) return -1;
// If only b has the position property, it should come first
if (bHasPosition) return 1;
// Compare case-insensitively by name for models without position property
const lowerA = a.name.toLowerCase();
const lowerB = b.name.toLowerCase();
if (lowerA < lowerB) return -1;
if (lowerA > lowerB) return 1;
// If same case-insensitively, sort by original strings,
// lowercase will come before uppercase due to ASCII values
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0; // They are equal
});
console.log(models);
return models;
};
@@ -147,7 +110,7 @@ export const chatAction = async (token: string, action_id: string, body: ChatAct
export const getTaskConfig = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/config`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/config`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -175,7 +138,7 @@ export const getTaskConfig = async (token: string = '') => {
export const updateTaskConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/config/update`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/config/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -213,7 +176,7 @@ export const generateTitle = async (
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/title/completions`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/title/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -253,7 +216,7 @@ export const generateTags = async (
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/tags/completions`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/tags/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -325,7 +288,7 @@ export const generateEmoji = async (
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/emoji/completions`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/emoji/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -365,15 +328,16 @@ export const generateEmoji = async (
return null;
};
export const generateSearchQuery = async (
export const generateQueries = async (
token: string = '',
model: string,
messages: object[],
prompt: string
prompt: string,
type?: string = 'web_search'
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/query/completions`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/queries/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -383,7 +347,8 @@ export const generateSearchQuery = async (
body: JSON.stringify({
model: model,
messages: messages,
prompt: prompt
prompt: prompt,
type: type
})
})
.then(async (res) => {
@@ -402,7 +367,105 @@ export const generateSearchQuery = async (
throw error;
}
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? prompt;
// Step 1: Safely extract the response string
const response = res?.choices[0]?.message?.content ?? '';
try {
const jsonStartIndex = response.indexOf('{');
const jsonEndIndex = response.lastIndexOf('}');
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonResponse = response.substring(jsonStartIndex, jsonEndIndex + 1);
// Step 5: Parse the JSON block
const parsed = JSON.parse(jsonResponse);
// Step 6: If there's a "queries" key, return the queries array; otherwise, return an empty array
if (parsed && parsed.queries) {
return Array.isArray(parsed.queries) ? parsed.queries : [];
} else {
return [];
}
}
// If no valid JSON block found, return response as is
return [response];
} catch (e) {
// Catch and safely return empty array on any parsing errors
console.error('Failed to parse response: ', e);
return [response];
}
};
export const generateAutoCompletion = async (
token: string = '',
model: string,
prompt: string,
messages?: object[],
type: string = 'search query'
) => {
const controller = new AbortController();
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/auto/completions`, {
signal: controller.signal,
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
prompt: prompt,
...(messages && { messages: messages }),
type: type,
stream: false
})
})
.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;
}
const response = res?.choices[0]?.message?.content ?? '';
try {
const jsonStartIndex = response.indexOf('{');
const jsonEndIndex = response.lastIndexOf('}');
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonResponse = response.substring(jsonStartIndex, jsonEndIndex + 1);
// Step 5: Parse the JSON block
const parsed = JSON.parse(jsonResponse);
// Step 6: If there's a "queries" key, return the queries array; otherwise, return an empty array
if (parsed && parsed.text) {
return parsed.text;
} else {
return '';
}
}
// If no valid JSON block found, return response as is
return response;
} catch (e) {
// Catch and safely return empty array on any parsing errors
console.error('Failed to parse response: ', e);
return response;
}
};
export const generateMoACompletion = async (
@@ -414,7 +477,7 @@ export const generateMoACompletion = async (
const controller = new AbortController();
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/moa/completions`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/tasks/moa/completions`, {
signal: controller.signal,
method: 'POST',
headers: {
@@ -444,7 +507,7 @@ export const generateMoACompletion = async (
export const getPipelinesList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/list`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/list`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -478,7 +541,7 @@ export const uploadPipeline = async (token: string, file: File, urlIdx: string)
formData.append('file', file);
formData.append('urlIdx', urlIdx);
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/upload`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/upload`, {
method: 'POST',
headers: {
...(token && { authorization: `Bearer ${token}` })
@@ -510,7 +573,7 @@ export const uploadPipeline = async (token: string, file: File, urlIdx: string)
export const downloadPipeline = async (token: string, url: string, urlIdx: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/add`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -546,7 +609,7 @@ export const downloadPipeline = async (token: string, url: string, urlIdx: strin
export const deletePipeline = async (token: string, id: string, urlIdx: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/delete`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
@@ -587,7 +650,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -622,7 +685,7 @@ export const getPipelineValves = async (token: string, pipeline_id: string, urlI
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves?${searchParams.toString()}`,
`${WEBUI_BASE_URL}/api/v1/pipelines/${pipeline_id}/valves?${searchParams.toString()}`,
{
method: 'GET',
headers: {
@@ -658,7 +721,7 @@ export const getPipelineValvesSpec = async (token: string, pipeline_id: string,
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/spec?${searchParams.toString()}`,
`${WEBUI_BASE_URL}/api/v1/pipelines/${pipeline_id}/valves/spec?${searchParams.toString()}`,
{
method: 'GET',
headers: {
@@ -699,7 +762,7 @@ export const updatePipelineValves = async (
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/update?${searchParams.toString()}`,
`${WEBUI_BASE_URL}/api/v1/pipelines/${pipeline_id}/valves/update?${searchParams.toString()}`,
{
method: 'POST',
headers: {
+43 -4
View File
@@ -1,6 +1,11 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewKnowledge = async (token: string, name: string, description: string) => {
export const createNewKnowledge = async (
token: string,
name: string,
description: string,
accessControl: null | object
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/create`, {
@@ -12,7 +17,8 @@ export const createNewKnowledge = async (token: string, name: string, descriptio
},
body: JSON.stringify({
name: name,
description: description
description: description,
access_control: accessControl
})
})
.then(async (res) => {
@@ -32,7 +38,7 @@ export const createNewKnowledge = async (token: string, name: string, descriptio
return res;
};
export const getKnowledgeItems = async (token: string = '') => {
export const getKnowledgeBases = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, {
@@ -63,6 +69,37 @@ export const getKnowledgeItems = async (token: string = '') => {
return res;
};
export const getKnowledgeBaseList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, {
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 getKnowledgeById = async (token: string, id: string) => {
let error = null;
@@ -99,6 +136,7 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_control?: null | object;
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@@ -114,7 +152,8 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
body: JSON.stringify({
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined
data: form?.data ? form.data : undefined,
access_control: form.access_control
})
})
.then(async (res) => {
+132 -34
View File
@@ -1,38 +1,9 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const addNewModel = async (token: string, model: object) => {
export const getModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(model)
})
.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 getModelInfos = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -60,13 +31,73 @@ export const getModelInfos = async (token: string = '') => {
return res;
};
export const getBaseModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/base`, {
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;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createNewModel = async (token: string, model: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(model)
})
.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 getModelById = async (token: string, id: string) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -95,13 +126,48 @@ export const getModelById = async (token: string, id: string) => {
return res;
};
export const toggleModelById = async (token: string, id: string) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/toggle?${searchParams.toString()}`, {
method: 'POST',
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;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/update?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update?${searchParams.toString()}`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -137,7 +203,39 @@ export const deleteModelById = async (token: string, id: string) => {
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/delete?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/delete?${searchParams.toString()}`, {
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;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteAllModels = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/delete/all`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
+45 -4
View File
@@ -1,5 +1,40 @@
import { OLLAMA_API_BASE_URL } from '$lib/constants';
export const verifyOllamaConnection = async (
token: string = '',
url: string = '',
key: string = ''
) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `Ollama: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
return res;
};
export const getOllamaConfig = async (token: string = '') => {
let error = null;
@@ -32,7 +67,13 @@ export const getOllamaConfig = async (token: string = '') => {
return res;
};
export const updateOllamaConfig = async (token: string = '', enable_ollama_api: boolean) => {
type OllamaConfig = {
ENABLE_OLLAMA_API: boolean;
OLLAMA_BASE_URLS: string[];
OLLAMA_API_CONFIGS: object;
};
export const updateOllamaConfig = async (token: string = '', config: OllamaConfig) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/config/update`, {
@@ -43,7 +84,7 @@ export const updateOllamaConfig = async (token: string = '', enable_ollama_api:
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
enable_ollama_api: enable_ollama_api
...config
})
})
.then(async (res) => {
@@ -166,10 +207,10 @@ export const getOllamaVersion = async (token: string, urlIdx?: number) => {
return res?.version ?? false;
};
export const getOllamaModels = async (token: string = '') => {
export const getOllamaModels = async (token: string = '', urlIdx: null | number = null) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, {
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags${urlIdx !== null ? `/${urlIdx}` : ''}`, {
method: 'GET',
headers: {
Accept: 'application/json',
+25 -20
View File
@@ -32,7 +32,14 @@ export const getOpenAIConfig = async (token: string = '') => {
return res;
};
export const updateOpenAIConfig = async (token: string = '', enable_openai_api: boolean) => {
type OpenAIConfig = {
ENABLE_OPENAI_API: boolean;
OPENAI_API_BASE_URLS: string[];
OPENAI_API_KEYS: string[];
OPENAI_API_CONFIGS: object;
};
export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfig) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/config/update`, {
@@ -43,7 +50,7 @@ export const updateOpenAIConfig = async (token: string = '', enable_openai_api:
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
enable_openai_api: enable_openai_api
...config
})
})
.then(async (res) => {
@@ -231,41 +238,39 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
return res;
};
export const getOpenAIModelsDirect = async (
base_url: string = 'https://api.openai.com/v1',
api_key: string = ''
export const verifyOpenAIConnection = async (
token: string = '',
url: string = 'https://api.openai.com/v1',
key: string = ''
) => {
let error = null;
const res = await fetch(`${base_url}/models`, {
method: 'GET',
const res = await fetch(`${OPENAI_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${api_key}`
}
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return null;
return [];
});
if (error) {
throw error;
}
const models = Array.isArray(res) ? res : (res?.data ?? null);
return models
.map((model) => ({ id: model.id, name: model.name ?? model.id, external: true }))
.filter((model) => (base_url.includes('openai') ? model.name.includes('gpt') : true))
.sort((a, b) => {
return a.name.localeCompare(b.name);
});
return res;
};
export const generateOpenAIChatCompletion = async (
+45 -19
View File
@@ -1,11 +1,13 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewPrompt = async (
token: string,
command: string,
title: string,
content: string
) => {
type PromptItem = {
command: string;
title: string;
content: string;
access_control: null | object;
};
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/create`, {
@@ -16,9 +18,8 @@ export const createNewPrompt = async (
authorization: `Bearer ${token}`
},
body: JSON.stringify({
command: `/${command}`,
title: title,
content: content
...prompt,
command: `/${prompt.command}`
})
})
.then(async (res) => {
@@ -69,6 +70,37 @@ export const getPrompts = async (token: string = '') => {
return res;
};
export const getPromptList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {
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 getPromptByCommand = async (token: string, command: string) => {
let error = null;
@@ -101,15 +133,10 @@ export const getPromptByCommand = async (token: string, command: string) => {
return res;
};
export const updatePromptByCommand = async (
token: string,
command: string,
title: string,
content: string
) => {
export const updatePromptByCommand = async (token: string, prompt: PromptItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/update`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -117,9 +144,8 @@ export const updatePromptByCommand = async (
authorization: `Bearer ${token}`
},
body: JSON.stringify({
command: `/${command}`,
title: title,
content: content
...prompt,
command: `/${prompt.command}`
})
})
.then(async (res) => {
+1
View File
@@ -40,6 +40,7 @@ type ContentExtractConfigForm = {
type YoutubeConfigForm = {
language: string[];
translation?: string | null;
proxy_url: string;
};
type RAGConfigForm = {
+25 -5
View File
@@ -5,7 +5,7 @@ type TextStreamUpdate = {
done: boolean;
value: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
citations?: any;
sources?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedModelId?: any;
error?: any;
@@ -67,8 +67,8 @@ async function* openAIStreamToIterator(
break;
}
if (parsedData.citations) {
yield { done: false, value: '', citations: parsedData.citations };
if (parsedData.sources) {
yield { done: false, value: '', sources: parsedData.sources };
continue;
}
@@ -77,10 +77,14 @@ async function* openAIStreamToIterator(
continue;
}
if (parsedData.usage) {
yield { done: false, value: '', usage: parsedData.usage };
continue;
}
yield {
done: false,
value: parsedData.choices?.[0]?.delta?.content ?? '',
usage: parsedData.usage
};
} catch (e) {
console.error('Error extracting delta from SSE event:', e);
@@ -98,10 +102,26 @@ async function* streamLargeDeltasAsRandomChunks(
yield textStreamUpdate;
return;
}
if (textStreamUpdate.citations) {
if (textStreamUpdate.error) {
yield textStreamUpdate;
continue;
}
if (textStreamUpdate.sources) {
yield textStreamUpdate;
continue;
}
if (textStreamUpdate.selectedModelId) {
yield textStreamUpdate;
continue;
}
if (textStreamUpdate.usage) {
yield textStreamUpdate;
continue;
}
let content = textStreamUpdate.value;
if (content.length < 5) {
yield { done: false, value: content };
+31
View File
@@ -62,6 +62,37 @@ export const getTools = async (token: string = '') => {
return res;
};
export const getToolList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/list`, {
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 exportTools = async (token: string = '') => {
let error = null;
+31 -4
View File
@@ -1,10 +1,10 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getUserPosition } from '$lib/utils';
export const getUserPermissions = async (token: string) => {
export const getUserGroups = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/permissions/user`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/groups`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -28,10 +28,37 @@ export const getUserPermissions = async (token: string) => {
return res;
};
export const updateUserPermissions = async (token: string, permissions: object) => {
export const getUserDefaultPermissions = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/permissions/user`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserDefaultPermissions = async (token: string, permissions: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+3 -3
View File
@@ -22,7 +22,7 @@
});
</script>
<Modal bind:show>
<Modal bind:show size="lg">
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
<div class="flex justify-between items-start">
<div class="text-xl font-semibold">
@@ -59,7 +59,7 @@
</div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-80 scrollbar-hidden">
<div class=" overflow-y-scroll max-h-96 scrollbar-hidden">
<div class="mb-3">
{#if changelog}
{#each Object.keys(changelog) as version}
@@ -111,7 +111,7 @@
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"
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"
>
<span class="relative">{$i18n.t("Okay, Let's Go!")}</span>
</button>
+78
View File
@@ -0,0 +1,78 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import Marquee from './common/Marquee.svelte';
import SlideShow from './common/SlideShow.svelte';
import ArrowRightCircle from './icons/ArrowRightCircle.svelte';
export let show = true;
export let getStartedHandler = () => {};
</script>
{#if show}
<div class="w-full h-screen max-h-[100dvh] text-white relative">
<div class="fixed m-10 z-50">
<div class="flex space-x-2">
<div class=" self-center">
<img
crossorigin="anonymous"
src="{WEBUI_BASE_URL}/static/favicon.png"
class=" w-6 rounded-full"
alt="logo"
/>
</div>
</div>
</div>
<SlideShow duration={5000} />
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-black to-transparent"
></div>
<div class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-black/50"></div>
<div class="relative bg-transparent w-full min-h-screen flex z-10">
<div class="flex flex-col justify-end w-full items-center pb-10 text-center">
<div class="text-5xl lg:text-7xl font-secondary">
<Marquee
duration={5000}
words={[
$i18n.t('Explore the cosmos'),
$i18n.t('Unlock mysteries'),
$i18n.t('Chart new frontiers'),
$i18n.t('Dive into knowledge'),
$i18n.t('Discover wonders'),
$i18n.t('Ignite curiosity'),
$i18n.t('Forge new paths'),
$i18n.t('Unravel secrets'),
$i18n.t('Pioneer insights'),
$i18n.t('Embark on adventures')
]}
/>
<div class="mt-0.5">{$i18n.t(`wherever you are`)}</div>
</div>
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
<button
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
on:click={() => {
getStartedHandler();
}}
>
<ArrowRightCircle className="size-6" />
</button>
<div class="mt-1.5 font-primary text-base font-medium">{$i18n.t(`Get started`)}</div>
</div>
</div>
</div>
<!-- <div class="absolute bottom-12 left-0 right-0 w-full"></div> -->
</div>
</div>
{/if}
+77 -654
View File
@@ -1,677 +1,100 @@
<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';
<script>
import { getContext, tick, onMount } from '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';
import Leaderboard from './Evaluations/Leaderboard.svelte';
import Feedbacks from './Evaluations/Feedbacks.svelte';
import { getAllFeedbacks } from '$lib/apis/evaluations';
const i18n = getContext('i18n');
let rankedModels = [];
let feedbacks = [];
let query = '';
let page = 1;
let tagEmbeddings = new Map();
let selectedTab = 'leaderboard';
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 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`);
}
};
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));
};
let feedbacks = [];
onMount(async () => {
feedbacks = await getAllFeedbacks(localStorage.token);
loaded = true;
rankHandler();
const containerElement = document.getElementById('users-tabs-container');
if (containerElement) {
containerElement.addEventListener('wheel', function (event) {
if (event.deltaY !== 0) {
// Adjust horizontal scroll position based on vertical scroll
containerElement.scrollLeft += event.deltaY;
}
});
}
});
</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 class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'leaderboard';
}}
>
</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();
}}
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<ArrowDownTray className="size-3" />
</button>
</Tooltip>
</div>
</div>
</div>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm6 5.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-1.5 0v-3.5Zm-2.75 1.5a.75.75 0 0 1 1.5 0v2a.75.75 0 0 1-1.5 0v-2Zm-2 .75a.75.75 0 0 0-.75.75v.5a.75.75 0 0 0 1.5 0v-.5a.75.75 0 0 0-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
</button>
<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"
<button
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
'feedbacks'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'feedbacks';
}}
>
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<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>
<path
fill-rule="evenodd"
d="M5.25 2A2.25 2.25 0 0 0 3 4.25v9a.75.75 0 0 0 1.183.613l1.692-1.195 1.692 1.195a.75.75 0 0 0 .866 0l1.692-1.195 1.693 1.195A.75.75 0 0 0 13 13.25v-9A2.25 2.25 0 0 0 10.75 2h-5.5Zm3.03 3.28a.75.75 0 0 0-1.06-1.06L4.97 6.47a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 0 0 1.06-1.06l-.97-.97h1.315c.76 0 1.375.616 1.375 1.375a.75.75 0 0 0 1.5 0A2.875 2.875 0 0 0 8.625 6.25H7.311l.97-.97Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Feedbacks')}</div>
</button>
</div>
{/if}
{#if feedbacks.length > 10}
<Pagination bind:page count={feedbacks.length} perPage={10} />
{/if}
<div class="pb-12"></div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
{#if selectedTab === 'leaderboard'}
<Leaderboard {feedbacks} />
{:else if selectedTab === 'feedbacks'}
<Feedbacks {feedbacks} />
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,283 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
import FeedbackMenu from './FeedbackMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
export let feedbacks = [];
let page = 1;
$: 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;
};
//////////////////////
//
// 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`);
}
};
</script>
<div class="mt-0.5 mb-2 gap-1 flex 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}
@@ -0,0 +1,410 @@
<script lang="ts">
import * as ort from 'onnxruntime-web';
import { AutoModel, AutoTokenizer } from '@huggingface/transformers';
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
const i18n = getContext('i18n');
const EMBEDDING_MODEL = 'TaylorAI/bge-micro-v2';
let tokenizer = null;
let model = null;
export let feedbacks = [];
let rankedModels = [];
let query = '';
let tagEmbeddings = new Map();
let loadingLeaderboard = true;
let debounceTimer;
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();
onMount(async () => {
rankHandler();
});
</script>
<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>
@@ -5,7 +5,6 @@
import { WEBUI_NAME, config, functions, models } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { goto } from '$app/navigation';
import {
@@ -25,11 +24,14 @@
import FunctionMenu from './Functions/FunctionMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import Switch from '../common/Switch.svelte';
import ValvesModal from './common/ValvesModal.svelte';
import ManifestModal from './common/ManifestModal.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
import ManifestModal from '../workspace/common/ManifestModal.svelte';
import Heart from '../icons/Heart.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
const i18n = getContext('i18n');
@@ -48,12 +50,14 @@
let showDeleteConfirm = false;
let filteredItems = [];
$: filteredItems = $functions.filter(
(f) =>
query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase())
);
$: filteredItems = $functions
.filter(
(f) =>
query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase())
)
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
@@ -94,7 +98,7 @@
id: `${_function.id}_clone`,
name: `${_function.name} (Clone)`
});
goto('/workspace/functions/create');
goto('/admin/functions/create');
}
};
@@ -182,68 +186,46 @@
</title>
</svelte:head>
<div class=" flex w-full space-x-2 mb-2.5">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<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 text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Functions')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition font-medium text-sm flex items-center space-x-1"
href="/workspace/functions/create"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</a>
</div>
</div>
<div class="mb-3.5">
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-base font-medium px-0.5">
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
{$i18n.t('Functions')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-base font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
<span class="text-base font-lg text-gray-500 dark:text-gray-300">{filteredItems.length}</span>
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</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 Functions')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/admin/functions/create"
>
<Plus className="size-3.5" />
</a>
</div>
</div>
</div>
<div class="my-3 mb-5">
{#each filteredItems as func}
<div class="mb-5">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/workspace/functions/edit?id=${encodeURIComponent(func.id)}`}
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
@@ -340,7 +322,7 @@
<FunctionMenu
{func}
editHandler={() => {
goto(`/workspace/functions/edit?id=${encodeURIComponent(func.id)}`);
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
@@ -470,40 +452,27 @@
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
<div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
{/if}
@@ -305,7 +305,7 @@ class Pipe:
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/functions');
goto('/admin/functions');
}}
type="button"
>
@@ -315,13 +315,15 @@ class Pipe:
</div>
<div class="flex-1">
<input
class="w-full text-2xl font-medium bg-transparent outline-none font-primary"
type="text"
placeholder={$i18n.t('Function Name (e.g. My Filter)')}
bind:value={name}
required
/>
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-none font-primary"
type="text"
placeholder={$i18n.t('Function Name')}
bind:value={name}
required
/>
</Tooltip>
</div>
<div>
@@ -329,31 +331,37 @@ class Pipe:
</div>
</div>
<div class=" flex gap-2 px-1">
<div class=" flex gap-2 px-1 items-center">
{#if edit}
<div class="text-sm text-gray-500 flex-shrink-0">
{id}
</div>
{:else}
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Function ID (e.g. my_filter)')}
bind:value={id}
required
disabled={edit}
/>
<Tooltip className="w-full" content={$i18n.t('e.g. my_filter')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Function ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
<input
class="w-full text-sm bg-transparent outline-none"
type="text"
placeholder={$i18n.t(
'Function Description (e.g. A filter to remove profanity from text)'
)}
bind:value={meta.description}
required
/>
<Tooltip
className="w-full self-center items-center flex"
content={$i18n.t('e.g. A filter to remove profanity from text')}
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Function Description')}
bind:value={meta.description}
required
/>
</Tooltip>
</div>
</div>
+39 -70
View File
@@ -2,11 +2,11 @@
import { getContext, tick, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { config } from '$lib/stores';
import { getBackendConfig } from '$lib/apis';
import Database from './Settings/Database.svelte';
import General from './Settings/General.svelte';
import Users from './Settings/Users.svelte';
import Pipelines from './Settings/Pipelines.svelte';
import Audio from './Settings/Audio.svelte';
import Images from './Settings/Images.svelte';
@@ -15,8 +15,7 @@
import Connections from './Settings/Connections.svelte';
import Documents from './Settings/Documents.svelte';
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';
@@ -39,16 +38,16 @@
});
</script>
<div class="flex flex-col lg:flex-row w-full h-full py-2 lg:space-x-4">
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
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"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'general';
}}
@@ -71,34 +70,10 @@
</button>
<button
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'}"
on:click={() => {
selectedTab = 'users';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM3.156 11.763c.16-.629.44-1.21.813-1.72a2.5 2.5 0 0 0-2.725 1.377c-.136.287.102.58.418.58h1.449c.01-.077.025-.156.045-.237ZM12.847 11.763c.02.08.036.16.046.237h1.446c.316 0 .554-.293.417-.579a2.5 2.5 0 0 0-2.722-1.378c.374.51.653 1.09.813 1.72ZM14 7.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM3.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM5 13c-.552 0-1.013-.455-.876-.99a4.002 4.002 0 0 1 7.753 0c.136.535-.324.99-.877.99H5Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Users')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'connections';
}}
@@ -119,10 +94,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'models';
}}
@@ -145,10 +120,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'evaluations';
}}
@@ -160,10 +135,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'documents';
}}
@@ -190,10 +165,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'web';
}}
@@ -214,10 +189,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'interface';
}}
@@ -240,10 +215,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'audio';
}}
@@ -267,10 +242,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'images';
}}
@@ -293,10 +268,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'pipelines';
}}
@@ -323,10 +298,10 @@
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 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'}"
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'db';
}}
@@ -351,7 +326,7 @@
</button>
</div>
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll">
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden">
{#if selectedTab === 'general'}
<General
saveHandler={async () => {
@@ -361,12 +336,6 @@
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'users'}
<Users
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'connections'}
<Connections
on:save={() => {
+44 -2
View File
@@ -181,7 +181,7 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={STT_OPENAI_API_BASE_URL}
required
@@ -322,6 +322,7 @@
}}
>
<option value="">{$i18n.t('Web API')}</option>
<option value="transformers">{$i18n.t('Transformers')} ({$i18n.t('Local')})</option>
<option value="openai">{$i18n.t('OpenAI')}</option>
<option value="elevenlabs">{$i18n.t('ElevenLabs')}</option>
<option value="azure">{$i18n.t('Azure AI Speech')}</option>
@@ -333,7 +334,7 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={TTS_OPENAI_API_BASE_URL}
required
@@ -396,6 +397,47 @@
</div>
</div>
</div>
{:else if TTS_ENGINE === 'transformers'}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('TTS Model')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
list="model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={TTS_MODEL}
placeholder="CMU ARCTIC speaker embedding name"
/>
<datalist id="model-list">
<option value="tts-1" />
</datalist>
</div>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(`Open WebUI uses SpeechT5 and CMU Arctic speaker embeddings.`)}
To learn more about SpeechT5,
<a
class=" hover:underline dark:text-gray-200 text-gray-800"
href="https://github.com/microsoft/SpeechT5"
target="_blank"
>
{$i18n.t(`click here`, {
name: 'SpeechT5'
})}.
</a>
To see the available CMU Arctic speaker embeddings,
<a
class=" hover:underline dark:text-gray-200 text-gray-800"
href="https://huggingface.co/datasets/Matthijs/cmu-arctic-xvectors"
target="_blank"
>
{$i18n.t(`click here`)}.
</a>
</div>
</div>
{:else if TTS_ENGINE === 'openai'}
<div class=" flex gap-2">
<div class="w-full">
@@ -1,31 +1,23 @@
<script lang="ts">
import { models, user } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
const dispatch = createEventDispatcher();
import {
getOllamaConfig,
getOllamaUrls,
getOllamaVersion,
updateOllamaConfig,
updateOllamaUrls
} from '$lib/apis/ollama';
import {
getOpenAIConfig,
getOpenAIKeys,
getOpenAIModels,
getOpenAIUrls,
updateOpenAIConfig,
updateOpenAIKeys,
updateOpenAIUrls
} from '$lib/apis/openai';
import { toast } from 'svelte-sonner';
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
import { getModels as _getModels } from '$lib/apis';
import { models, user } from '$lib/stores';
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 { getModels as _getModels } from '$lib/apis';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import OpenAIConnection from './Connections/OpenAIConnection.svelte';
import AddConnectionModal from './Connections/AddConnectionModal.svelte';
import OllamaConnection from './Connections/OllamaConnection.svelte';
const i18n = getContext('i18n');
@@ -36,395 +28,302 @@
// External
let OLLAMA_BASE_URLS = [''];
let OLLAMA_API_CONFIGS = {};
let OPENAI_API_KEYS = [''];
let OPENAI_API_BASE_URLS = [''];
let OPENAI_API_CONFIGS = {};
let ENABLE_OPENAI_API: null | boolean = null;
let ENABLE_OLLAMA_API: null | boolean = null;
let pipelineUrls = {};
let ENABLE_OPENAI_API = null;
let ENABLE_OLLAMA_API = null;
const verifyOpenAIHandler = async (idx) => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
const res = await getOpenAIModels(localStorage.token, idx).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Server connection verified'));
if (res.pipelines) {
pipelineUrls[OPENAI_API_BASE_URLS[idx]] = true;
}
}
await models.set(await getModels());
};
const verifyOllamaHandler = async (idx) => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) =>
url.replace(/\/$/, '')
);
OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
const res = await getOllamaVersion(localStorage.token, idx).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Server connection verified'));
}
await models.set(await getModels());
};
let showAddOpenAIConnectionModal = false;
let showAddOllamaConnectionModal = false;
const updateOpenAIHandler = async () => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
if (ENABLE_OPENAI_API !== null) {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => OPENAI_API_BASE_URLS.indexOf(url) === urlIdx && url !== ''
).map((url) => url.replace(/\/$/, ''));
// Check if API KEYS length is same than API URLS length
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
// if there are more keys than urls, remove the extra keys
if (OPENAI_API_KEYS.length > OPENAI_API_BASE_URLS.length) {
OPENAI_API_KEYS = OPENAI_API_KEYS.slice(0, OPENAI_API_BASE_URLS.length);
}
// Check if API KEYS length is same than API URLS length
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
// if there are more keys than urls, remove the extra keys
if (OPENAI_API_KEYS.length > OPENAI_API_BASE_URLS.length) {
OPENAI_API_KEYS = OPENAI_API_KEYS.slice(0, OPENAI_API_BASE_URLS.length);
}
// if there are more urls than keys, add empty keys
if (OPENAI_API_KEYS.length < OPENAI_API_BASE_URLS.length) {
const diff = OPENAI_API_BASE_URLS.length - OPENAI_API_KEYS.length;
for (let i = 0; i < diff; i++) {
OPENAI_API_KEYS.push('');
// if there are more urls than keys, add empty keys
if (OPENAI_API_KEYS.length < OPENAI_API_BASE_URLS.length) {
const diff = OPENAI_API_BASE_URLS.length - OPENAI_API_KEYS.length;
for (let i = 0; i < diff; i++) {
OPENAI_API_KEYS.push('');
}
}
}
}
OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
await models.set(await getModels());
};
const updateOllamaUrlsHandler = async () => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) =>
url.replace(/\/$/, '')
);
console.log(OLLAMA_BASE_URLS);
if (OLLAMA_BASE_URLS.length === 0) {
ENABLE_OLLAMA_API = false;
await updateOllamaConfig(localStorage.token, ENABLE_OLLAMA_API);
toast.info($i18n.t('Ollama API disabled'));
} else {
OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
const res = await updateOpenAIConfig(localStorage.token, {
ENABLE_OPENAI_API: ENABLE_OPENAI_API,
OPENAI_API_BASE_URLS: OPENAI_API_BASE_URLS,
OPENAI_API_KEYS: OPENAI_API_KEYS,
OPENAI_API_CONFIGS: OPENAI_API_CONFIGS
}).catch((error) => {
toast.error(error);
return null;
});
if (ollamaVersion) {
toast.success($i18n.t('Server connection verified'));
if (res) {
toast.success($i18n.t('OpenAI API settings updated'));
await models.set(await getModels());
}
}
};
const updateOllamaHandler = async () => {
if (ENABLE_OLLAMA_API !== null) {
// Remove duplicate URLs
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter(
(url, urlIdx) => OLLAMA_BASE_URLS.indexOf(url) === urlIdx && url !== ''
).map((url) => url.replace(/\/$/, ''));
console.log(OLLAMA_BASE_URLS);
if (OLLAMA_BASE_URLS.length === 0) {
ENABLE_OLLAMA_API = false;
toast.info($i18n.t('Ollama API disabled'));
}
const res = await updateOllamaConfig(localStorage.token, {
ENABLE_OLLAMA_API: ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS: OLLAMA_BASE_URLS,
OLLAMA_API_CONFIGS: OLLAMA_API_CONFIGS
}).catch((error) => {
toast.error(error);
});
if (res) {
toast.success($i18n.t('Ollama API settings updated'));
await models.set(await getModels());
}
}
};
const addOpenAIConnectionHandler = async (connection) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
OPENAI_API_CONFIGS[connection.url] = connection.config;
await updateOpenAIHandler();
};
const addOllamaConnectionHandler = async (connection) => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url];
OLLAMA_API_CONFIGS[connection.url] = connection.config;
await updateOllamaHandler();
};
onMount(async () => {
if ($user.role === 'admin') {
let ollamaConfig = {};
let openaiConfig = {};
await Promise.all([
(async () => {
OLLAMA_BASE_URLS = await getOllamaUrls(localStorage.token);
ollamaConfig = await getOllamaConfig(localStorage.token);
})(),
(async () => {
OPENAI_API_BASE_URLS = await getOpenAIUrls(localStorage.token);
})(),
(async () => {
OPENAI_API_KEYS = await getOpenAIKeys(localStorage.token);
openaiConfig = await getOpenAIConfig(localStorage.token);
})()
]);
const ollamaConfig = await getOllamaConfig(localStorage.token);
const openaiConfig = await getOpenAIConfig(localStorage.token);
ENABLE_OPENAI_API = openaiConfig.ENABLE_OPENAI_API;
ENABLE_OLLAMA_API = ollamaConfig.ENABLE_OLLAMA_API;
OPENAI_API_BASE_URLS = openaiConfig.OPENAI_API_BASE_URLS;
OPENAI_API_KEYS = openaiConfig.OPENAI_API_KEYS;
OPENAI_API_CONFIGS = openaiConfig.OPENAI_API_CONFIGS;
OLLAMA_BASE_URLS = ollamaConfig.OLLAMA_BASE_URLS;
OLLAMA_API_CONFIGS = ollamaConfig.OLLAMA_API_CONFIGS;
if (ENABLE_OPENAI_API) {
for (const url of OPENAI_API_BASE_URLS) {
if (!OPENAI_API_CONFIGS[url]) {
OPENAI_API_CONFIGS[url] = {};
}
}
OPENAI_API_BASE_URLS.forEach(async (url, idx) => {
OPENAI_API_CONFIGS[url] = OPENAI_API_CONFIGS[url] || {};
if (!(OPENAI_API_CONFIGS[url]?.enable ?? true)) {
return;
}
const res = await getOpenAIModels(localStorage.token, idx);
if (res.pipelines) {
pipelineUrls[url] = true;
}
});
}
if (ENABLE_OLLAMA_API) {
for (const url of OLLAMA_BASE_URLS) {
if (!OLLAMA_API_CONFIGS[url]) {
OLLAMA_API_CONFIGS[url] = {};
}
}
}
}
});
</script>
<AddConnectionModal
bind:show={showAddOpenAIConnectionModal}
onSubmit={addOpenAIConnectionHandler}
/>
<AddConnectionModal
ollama
bind:show={showAddOllamaConnectionModal}
onSubmit={addOllamaConnectionHandler}
/>
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
updateOpenAIHandler();
updateOllamaUrlsHandler();
updateOllamaHandler();
dispatch('save');
}}
>
<div class="space-y-3 overflow-y-scroll scrollbar-hidden h-full">
<div class=" overflow-y-scroll scrollbar-hidden h-full">
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null}
<div class=" space-y-3">
<div class="my-2">
<div class="mt-2 space-y-2 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_OPENAI_API}
on:change={async () => {
updateOpenAIConfig(localStorage.token, ENABLE_OPENAI_API);
}}
/>
<div class="flex items-center">
<div class="">
<Switch
bind:state={ENABLE_OPENAI_API}
on:change={async () => {
updateOpenAIHandler();
}}
/>
</div>
</div>
</div>
{#if ENABLE_OPENAI_API}
<div class="flex flex-col gap-1">
{#each OPENAI_API_BASE_URLS as url, idx}
<div class="flex w-full gap-2">
<div class="flex-1 relative">
<input
class="w-full rounded-lg py-2 px-4 {pipelineUrls[url]
? 'pr-8'
: ''} text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
/>
<hr class=" border-gray-50 dark:border-gray-850" />
{#if pipelineUrls[url]}
<div class=" absolute top-2.5 right-2.5">
<Tooltip content="Pipelines">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
/>
<path
d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
/>
<path
d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
/>
</svg>
</Tooltip>
</div>
{/if}
</div>
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div>
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={OPENAI_API_KEYS[idx]}
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOpenAIConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[url]}
onSubmit={() => {
updateOpenAIHandler();
}}
onDelete={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
}}
/>
<div class="self-center flex items-center">
{#if idx === 0}
<button
class="px-1"
on:click={() => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, ''];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, ''];
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-1"
on:click={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
<div class="flex">
<Tooltip content="Verify connection" className="self-start mt-0.5">
<button
class="self-center p-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyOpenAIHandler(idx);
}}
type="button"
>
<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="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
<div class=" mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('WebUI will make requests to')}
<span class=" text-gray-200">'{url}/models'</span>
</div>
{/each}
{/each}
</div>
</div>
{/if}
</div>
</div>
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-50 dark:border-gray-850" />
<div class="pr-1.5 space-y-2">
<div class="flex justify-between items-center text-sm">
<div class="pr-1.5 my-2">
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_OLLAMA_API}
on:change={async () => {
updateOllamaConfig(localStorage.token, ENABLE_OLLAMA_API);
if (OLLAMA_BASE_URLS.length === 0) {
OLLAMA_BASE_URLS = [''];
}
updateOllamaHandler();
}}
/>
</div>
</div>
{#if ENABLE_OLLAMA_API}
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-2">
{#each OLLAMA_BASE_URLS as url, idx}
<div class="flex gap-1.5">
<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('Enter URL (e.g. http://localhost:11434)')}
bind:value={url}
/>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="self-center flex items-center">
{#if idx === 0}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, ''];
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
<div class="flex">
<Tooltip content="Verify connection" className="self-start mt-0.5">
<button
class="self-center p-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyOllamaHandler(idx);
}}
type="button"
>
<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="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOllamaConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[url]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
}}
/>
{/each}
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,365 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
import Modal from '$lib/components/common/Modal.svelte';
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 SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let ollama = false;
export let connection = null;
let url = '';
let key = '';
let prefixId = '';
let enable = true;
let modelId = '';
let modelIds = [];
let loading = false;
const verifyOllamaHandler = async () => {
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
});
if (res) {
toast.success($i18n.t('Server connection verified'));
}
};
const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
});
if (res) {
toast.success($i18n.t('Server connection verified'));
}
};
const verifyHandler = () => {
if (ollama) {
verifyOllamaHandler();
} else {
verifyOpenAIHandler();
}
};
const addModelHandler = () => {
if (modelId) {
modelIds = [...modelIds, modelId];
modelId = '';
}
};
const submitHandler = async () => {
loading = true;
if (!ollama && (!url || !key)) {
loading = false;
toast.error('URL and Key are required');
return;
}
const connection = {
url,
key,
config: {
enable: enable,
prefix_id: prefixId,
model_ids: modelIds
}
};
await onSubmit(connection);
loading = false;
show = false;
url = '';
key = '';
prefixId = '';
modelIds = [];
};
const init = () => {
if (connection) {
url = connection.url;
key = connection.key;
enable = connection.config?.enable ?? true;
prefixId = connection.config?.prefix_id ?? '';
modelIds = connection.config?.model_ids ?? [];
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</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 Connection')}
{:else}
{$i18n.t('Add Connection')}
{/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={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('URL')}</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={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
required
/>
</div>
</div>
<Tooltip content="Verify Connection" className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
type="button"
>
<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="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<div class="flex flex-col flex-shrink-0 self-end">
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Key')}</div>
<div class="flex-1">
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={!ollama}
/>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Prefix ID')}</div>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</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('Model IDs')}</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">
{modelId}
</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 px-10">
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{URL}}/api/tags" endpoint', {
URL: url
})}
{:else}
{$i18n.t('Leave empty to include all models from "{{URL}}/models" endpoint', {
URL: url
})}
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex items-center">
<input
class="w-full py-1 text-sm rounded-lg bg-transparent {modelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={modelId}
placeholder={$i18n.t('Add a model ID')}
/>
<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={() => {
onDelete();
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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from './AddConnectionModal.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
export let url = '';
export let idx = 0;
export let config = {};
let showManageModal = false;
let showConfigModal = false;
</script>
<AddConnectionModal
ollama
edit
bind:show={showConfigModal}
connection={{
url,
key: config?.key ?? '',
config: config
}}
{onDelete}
onSubmit={(connection) => {
url = connection.url;
config = { ...connection.config, key: connection.key };
onSubmit(connection);
}}
/>
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
<div class="flex gap-1.5">
<Tooltip
className="w-full relative"
content={$i18n.t(`WebUI will make requests to "{{url}}/api/chat"`, {
url
})}
placement="top-start"
>
{#if !(config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<input
class="w-full text-sm bg-transparent outline-none"
placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
bind:value={url}
/>
</Tooltip>
<div class="flex gap-1">
<Tooltip content={$i18n.t('Manage')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showManageModal = true;
}}
type="button"
>
<Wrench />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}
type="button"
>
<Cog6 />
</button>
</Tooltip>
</div>
</div>
@@ -0,0 +1,107 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from './AddConnectionModal.svelte';
import { connect } from 'socket.io-client';
export let onDelete = () => {};
export let onSubmit = () => {};
export let pipeline = false;
export let url = '';
export let key = '';
export let config = {};
let showConfigModal = false;
</script>
<AddConnectionModal
edit
bind:show={showConfigModal}
connection={{
url,
key,
config
}}
{onDelete}
onSubmit={(connection) => {
url = connection.url;
key = connection.key;
config = connection.config;
onSubmit(connection);
}}
/>
<div class="flex w-full gap-2 items-center">
<Tooltip
className="w-full relative"
content={$i18n.t(`WebUI will make requests to "{{url}}/chat/completions"`, {
url
})}
placement="top-start"
>
{#if !(config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<div class="flex w-full">
<div class="flex-1 relative">
<input
class=" outline-none w-full bg-transparent {pipeline ? 'pr-8' : ''}"
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
/>
{#if pipeline}
<div class=" absolute top-0.5 right-2.5">
<Tooltip content="Pipelines">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
/>
<path
d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
/>
<path
d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
/>
</svg>
</Tooltip>
</div>
{/if}
</div>
<SensitiveInput
inputClassName=" outline-none bg-transparent w-full"
placeholder={$i18n.t('API Key')}
bind:value={key}
/>
</div>
</Tooltip>
<div class="flex gap-1">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}
type="button"
>
<Cog6 />
</button>
</Tooltip>
</div>
</div>
@@ -181,37 +181,6 @@
</div>
</button>
{/if}
<hr class=" dark:border-gray-850 my-1" />
<button
type="button"
class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
downloadLiteLLMConfig(localStorage.token).catch((error) => {
toast.error(error);
});
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM8.75 7.75a.75.75 0 0 0-1.5 0v2.69L6.03 9.22a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06l-1.22 1.22V7.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">
{$i18n.t('Export LiteLLM config.yaml')}
</div>
</button>
</div>
</div>
@@ -19,7 +19,7 @@
} from '$lib/apis/retrieval';
import { knowledge, models } from '$lib/stores';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { uploadDir, deleteAllFiles, deleteFileById } from '$lib/apis/files';
import ResetUploadDirConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -56,8 +56,11 @@
let chunkOverlap = 0;
let pdfExtractImages = true;
let OpenAIKey = '';
let OpenAIUrl = '';
let OpenAIKey = '';
let OllamaUrl = '';
let OllamaKey = '';
let querySettings = {
template: '',
@@ -104,19 +107,15 @@
const res = await updateEmbeddingConfig(localStorage.token, {
embedding_engine: embeddingEngine,
embedding_model: embeddingModel,
...(embeddingEngine === 'openai' || embeddingEngine === 'ollama'
? {
embedding_batch_size: embeddingBatchSize
}
: {}),
...(embeddingEngine === 'openai'
? {
openai_config: {
key: OpenAIKey,
url: OpenAIUrl
}
}
: {})
embedding_batch_size: embeddingBatchSize,
ollama_config: {
key: OllamaKey,
url: OllamaUrl
},
openai_config: {
key: OpenAIKey,
url: OpenAIUrl
}
}).catch(async (error) => {
toast.error(error);
await setEmbeddingConfig();
@@ -206,6 +205,9 @@
OpenAIKey = embeddingConfig.openai_config.key;
OpenAIUrl = embeddingConfig.openai_config.url;
OllamaKey = embeddingConfig.ollama_config.key;
OllamaUrl = embeddingConfig.ollama_config.url;
}
};
@@ -310,9 +312,9 @@
</div>
{#if embeddingEngine === 'openai'}
<div class="my-0.5 flex gap-2">
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={OpenAIUrl}
required
@@ -320,7 +322,23 @@
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
</div>
{:else if embeddingEngine === 'ollama'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={OllamaUrl}
required
/>
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={OllamaKey}
required={false}
/>
</div>
{/if}
{#if embeddingEngine === 'ollama' || embeddingEngine === 'openai'}
<div class="flex mt-0.5 space-x-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
@@ -376,19 +394,12 @@
{#if embeddingEngine === 'ollama'}
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
<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"
bind:value={embeddingModel}
placeholder={$i18n.t('Select a model')}
placeholder={$i18n.t('Set embedding model')}
required
>
{#if !embeddingModel}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each $models.filter((m) => m.id && m.ollama && !(m?.preset ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
/>
</div>
</div>
{:else}
@@ -5,14 +5,14 @@
const dispatch = createEventDispatcher();
import { getModels } from '$lib/apis';
import { getConfig, updateConfig } from '$lib/apis/evaluations';
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';
import ArenaModelModal from './Evaluations/ArenaModelModal.svelte';
const i18n = getContext('i18n');
@@ -27,6 +27,7 @@
if (config) {
toast.success('Settings saved successfully');
models.set(await getModels(localStorage.token));
}
};
@@ -65,7 +66,7 @@
});
</script>
<ModelModal
<ArenaModelModal
bind:show={showAddModel}
on:submit={async (e) => {
addModelHandler(e.detail);
@@ -9,6 +9,7 @@
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
export let show = false;
export let edit = false;
@@ -39,6 +40,8 @@
let modelIds = [];
let filterMode = 'include';
let accessControl = {};
let imageInputElement;
let loading = false;
@@ -74,7 +77,8 @@
profile_image_url: profileImageUrl,
description: description || null,
model_ids: modelIds.length > 0 ? modelIds : null,
filter_mode: modelIds.length > 0 ? (filterMode ? filterMode : null) : null
filter_mode: modelIds.length > 0 ? (filterMode ? filterMode : null) : null,
access_control: accessControl
}
};
@@ -98,6 +102,7 @@
description = model.meta.description;
modelIds = model.meta.model_ids || [];
filterMode = model.meta?.filter_mode ?? 'include';
accessControl = 'access_control' in model.meta ? model.meta.access_control : {};
}
};
@@ -283,6 +288,14 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
</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>
@@ -362,7 +375,7 @@
<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"
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 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);
@@ -374,7 +387,7 @@
{/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
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 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"
@@ -4,13 +4,13 @@
const i18n = getContext('i18n');
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ModelModal from './ModelModal.svelte';
import ArenaModelModal from './ArenaModelModal.svelte';
export let model;
let showModel = false;
</script>
<ModelModal
<ArenaModelModal
bind:show={showModel}
edit={true}
{model}
@@ -1,7 +1,16 @@
<script lang="ts">
import { getBackendConfig, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths';
import {
getAdminConfig,
getLdapConfig,
getLdapServer,
updateAdminConfig,
updateLdapConfig,
updateLdapServer
} from '$lib/apis/auths';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { config } from '$lib/stores';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -13,9 +22,37 @@
let adminConfig = null;
let webhookUrl = '';
// LDAP
let ENABLE_LDAP = false;
let LDAP_SERVER = {
label: '',
host: '',
port: '',
attribute_for_username: 'uid',
app_dn: '',
app_dn_password: '',
search_base: '',
search_filters: '',
use_tls: false,
certificate_path: '',
ciphers: ''
};
const updateLdapServerHandler = async () => {
if (!ENABLE_LDAP) return;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('LDAP server updated'));
}
};
const updateHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
const res = await updateAdminConfig(localStorage.token, adminConfig);
await updateLdapServerHandler();
if (res) {
saveHandler();
@@ -32,8 +69,14 @@
(async () => {
webhookUrl = await getWebhookUrl(localStorage.token);
})(),
(async () => {
LDAP_SERVER = await getLdapServer(localStorage.token);
})()
]);
const ldapConfig = await getLdapConfig(localStorage.token);
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
});
</script>
@@ -69,7 +112,13 @@
</div>
</div>
<hr class=" dark:border-gray-850 my-2" />
<div class=" flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key Auth')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="my-3 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
@@ -91,7 +140,7 @@
<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
</div>
<hr class=" dark:border-gray-850 my-2" />
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
@@ -115,7 +164,7 @@
</div>
</div>
<hr class=" dark:border-gray-850 my-2" />
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
@@ -133,6 +182,196 @@
</div>
</div>
{/if}
<hr class=" border-gray-50 dark:border-gray-850" />
<div class=" space-y-3">
<div class="mt-2 space-y-2 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('LDAP')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_LDAP}
on:change={async () => {
updateLdapConfig(localStorage.token, ENABLE_LDAP);
}}
/>
</div>
</div>
{#if ENABLE_LDAP}
<div class="flex flex-col gap-1">
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Label')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter server label')}
bind:value={LDAP_SERVER.label}
/>
</div>
<div class="w-full"></div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Host')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter server host')}
bind:value={LDAP_SERVER.host}
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Port')}
</div>
<Tooltip
placement="top-start"
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
className="w-full"
>
<input
class="w-full bg-transparent outline-none py-0.5"
type="number"
placeholder={$i18n.t('Enter server port')}
bind:value={LDAP_SERVER.port}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN')}
</div>
<Tooltip
content={$i18n.t('The Application Account DN you bind with for search')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
</Tooltip>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Username')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the username that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: sAMAccountName or uid or userPrincipalName')}
bind:value={LDAP_SERVER.attribute_for_username}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Base')}
</div>
<Tooltip content={$i18n.t('The base to search for users')} placement="top-start">
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
bind:value={LDAP_SERVER.search_base}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Filters')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
bind:value={LDAP_SERVER.search_filters}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<a
class=" text-gray-300 font-medium underline"
href="https://ldap.com/ldap-filters/"
target="_blank"
>
{$i18n.t('Click here for filter guides.')}
</a>
</div>
<div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('TLS')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.use_tls} />
</div>
</div>
{#if LDAP_SERVER.use_tls}
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
{$i18n.t('Certificate Path')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Ciphers')}
</div>
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
<input
class="w-full bg-transparent outline-none py-0.5"
placeholder={$i18n.t('Example: ALL')}
bind:value={LDAP_SERVER.ciphers}
/>
</Tooltip>
</div>
<div class="w-full"></div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
@@ -105,10 +105,15 @@
};
const updateConfigHandler = async () => {
const res = await updateConfig(localStorage.token, config).catch((error) => {
toast.error(error);
return null;
});
const res = await updateConfig(localStorage.token, config)
.catch((error) => {
toast.error(error);
return null;
})
.catch((error) => {
toast.error(error);
return null;
});
if (res) {
config = res;
@@ -566,7 +571,7 @@
<div class="flex gap-2 mb-1">
<input
class="flex-1 w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={config.openai.OPENAI_API_BASE_URL}
required
+306 -246
View File
@@ -24,9 +24,13 @@
TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '',
TITLE_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_AUTOCOMPLETE_GENERATION: true,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
TAGS_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_SEARCH_QUERY: true,
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE: ''
ENABLE_TAGS_GENERATION: true,
ENABLE_SEARCH_QUERY_GENERATION: true,
ENABLE_RETRIEVAL_QUERY_GENERATION: true,
QUERY_GENERATION_PROMPT_TEMPLATE: ''
};
let promptSuggestions = [];
@@ -53,237 +57,207 @@
};
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
updateInterfaceHandler();
dispatch('save');
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div>
<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(
'A task model is used when performing tasks such as generating titles for chats and web search queries'
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5"
{#if taskConfig}
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
updateInterfaceHandler();
dispatch('save');
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div>
<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(
'A task model is used when performing tasks such as generating titles for chats and web search queries'
)}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</Tooltip>
</div>
<div class="flex w-full gap-2">
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('Local Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={taskConfig.TASK_MODEL}
placeholder={$i18n.t('Select a model')}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models.filter((m) => m.owned_by === 'ollama') as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
</option>
{/each}
</select>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</Tooltip>
</div>
<div class="flex w-full gap-2">
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('Local Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={taskConfig.TASK_MODEL}
placeholder={$i18n.t('Select a model')}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models.filter((m) => m.owned_by === 'ollama') as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
</option>
{/each}
</select>
</div>
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('External Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={taskConfig.TASK_MODEL_EXTERNAL}
placeholder={$i18n.t('Select a model')}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
</option>
{/each}
</select>
</div>
</div>
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('External Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={taskConfig.TASK_MODEL_EXTERNAL}
placeholder={$i18n.t('Select a model')}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
</option>
{/each}
</select>
</div>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Title 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.TITLE_GENERATION_PROMPT_TEMPLATE}
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>
</div>
<hr class=" dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Web Search Query Generation')}
</div>
<Switch bind:state={taskConfig.ENABLE_SEARCH_QUERY} />
</div>
{#if taskConfig.ENABLE_SEARCH_QUERY}
<div class="">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Search Query Generation Prompt')}</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Title 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.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE}
bind:value={taskConfig.TITLE_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
{/if}
</div>
<hr class=" dark:border-gray-850 my-3" />
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class=" space-y-3 {banners.length > 0 ? ' mb-3' : ''}">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Banners')}
<div class="my-3 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Autocomplete Generation')}
</div>
<Tooltip content={$i18n.t('Enable autocomplete generation for chat messages')}>
<Switch bind:state={taskConfig.ENABLE_AUTOCOMPLETE_GENERATION} />
</Tooltip>
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<div class="flex flex-col space-y-1">
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div class="flex flex-row flex-1 border rounded-xl dark:border-gray-800">
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-none"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
{#if taskConfig.ENABLE_AUTOCOMPLETE_GENERATION}
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">
{$i18n.t('Autocomplete Generation Input Max Length')}
</div>
<button
class="px-2"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
<Tooltip
content={$i18n.t('Character limit for autocomplete generation input')}
placement="top-start"
>
<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>
</button>
<input
class="w-full outline-none bg-transparent"
bind:value={taskConfig.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}
placeholder={$i18n.t('-1 for no limit, or a positive integer for a specific limit')}
/>
</Tooltip>
</div>
{/each}
</div>
</div>
{/if}
{#if $user.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tags Generation')}
</div>
<Switch bind:state={taskConfig.ENABLE_TAGS_GENERATION} />
</div>
{#if taskConfig.ENABLE_TAGS_GENERATION}
<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>
</div>
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Retrieval Query Generation')}
</div>
<Switch bind:state={taskConfig.ENABLE_RETRIEVAL_QUERY_GENERATION} />
</div>
<div class="my-3 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search Query Generation')}
</div>
<Switch bind:state={taskConfig.ENABLE_SEARCH_QUERY_GENERATION} />
</div>
<div class="">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Query 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.QUERY_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class=" space-y-3 {banners.length > 0 ? ' mb-3' : ''}">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Default Prompt Suggestions')}
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content !== '') {
promptSuggestions = [...promptSuggestions, { content: '', title: ['', ''] }];
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
@@ -299,40 +273,45 @@
</svg>
</button>
</div>
<div class="grid lg:grid-cols-2 flex-col gap-1.5">
{#each promptSuggestions as prompt, promptIdx}
<div
class=" flex border border-gray-100 dark:border-none dark:bg-gray-850 rounded-xl py-1.5"
>
<div class="flex flex-col flex-1 pl-1">
<div class="flex border-b border-gray-100 dark:border-gray-800 w-full">
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Title (e.g. Tell me a fun fact)')}
bind:value={prompt.title[0]}
/>
<div class="flex flex-col space-y-1">
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div class="flex flex-row flex-1 border rounded-xl dark:border-gray-800">
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-none"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900"
>{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Subtitle (e.g. about the Roman Empire)')}
bind:value={prompt.title[1]}
/>
</div>
<textarea
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800 resize-none"
placeholder={$i18n.t('Prompt (e.g. Tell me a fun fact about the Roman Empire)')}
rows="3"
bind:value={prompt.content}
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="px-3"
class="px-2"
type="button"
on:click={() => {
promptSuggestions.splice(promptIdx, 1);
promptSuggestions = promptSuggestions;
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
@@ -349,22 +328,103 @@
</div>
{/each}
</div>
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex justify-end 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>
{#if $user.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Default Prompt Suggestions')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content !== '') {
promptSuggestions = [...promptSuggestions, { content: '', title: ['', ''] }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<div class="grid lg:grid-cols-2 flex-col gap-1.5">
{#each promptSuggestions as prompt, promptIdx}
<div
class=" flex border border-gray-100 dark:border-none dark:bg-gray-850 rounded-xl py-1.5"
>
<div class="flex flex-col flex-1 pl-1">
<div class="flex border-b border-gray-100 dark:border-gray-800 w-full">
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Title (e.g. Tell me a fun fact)')}
bind:value={prompt.title[0]}
/>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Subtitle (e.g. about the Roman Empire)')}
bind:value={prompt.title[1]}
/>
</div>
<textarea
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800 resize-none"
placeholder={$i18n.t('Prompt (e.g. Tell me a fun fact about the Roman Empire)')}
rows="3"
bind:value={prompt.content}
/>
</div>
<button
class="px-3"
type="button"
on:click={() => {
promptSuggestions.splice(promptIdx, 1);
promptSuggestions = promptSuggestions;
}}
>
<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>
</button>
</div>
{/each}
</div>
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex justify-end 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>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models } from '$lib/stores';
import { deleteAllModels } from '$lib/apis/models';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import { getModelsConfig, setModelsConfig } from '$lib/apis/configs';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedModelId = '';
let defaultModelIds = [];
let modelIds = [];
let loading = false;
let showResetModal = false;
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
if (config?.DEFAULT_MODELS) {
defaultModelIds = (config?.DEFAULT_MODELS).split(',').filter((id) => id);
} else {
defaultModelIds = [];
}
const modelOrderList = config.MODEL_ORDER_LIST || [];
const allModelIds = $models.map((model) => model.id);
// Create a Set for quick lookup of ordered IDs
const orderedSet = new Set(modelOrderList);
modelIds = [
// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
};
const submitHandler = async () => {
loading = true;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
MODEL_ORDER_LIST: modelIds
});
if (res) {
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<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">
{$i18n.t('Configure Models')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div>
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
</div>
<ModelList bind:modelIds />
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div>
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div>
</div>
{#if defaultModelIds.length > 0}
<div class="flex flex-col">
{#each defaultModelIds 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={() => {
defaultModelIds = defaultModelIds.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('No models selected')}
</div>
{/if}
<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 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={() => {
if (selectedModelId === '') {
return;
}
if (defaultModelIds.includes(selectedModelId)) {
return;
}
defaultModelIds = [...defaultModelIds, selectedModelId];
selectedModelId = '';
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<Tooltip content={$i18n.t('This will delete all models including custom models')}>
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 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={() => {
showResetModal = true;
}}
>
<!-- {$i18n.t('Delete All Models')} -->
{$i18n.t('Reset All Models')}
</button>
</Tooltip>
<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>
{:else}
<div>
<Spinner />
</div>
{/if}
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,58 @@
<script lang="ts">
import Sortable from 'sortablejs';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
export let modelIds = [];
let sortable = null;
let modelListElement = null;
const positionChangeHandler = () => {
const modelList = Array.from(modelListElement.children).map((child) =>
child.id.replace('model-item-', '')
);
modelIds = modelList;
};
onMount(() => {
sortable = Sortable.create(modelListElement, {
animation: 150,
onUpdate: async (event) => {
positionChangeHandler();
}
});
});
</script>
{#if modelIds.length > 0}
<div class="flex flex-col -translate-x-1" bind:this={modelListElement}>
{#each modelIds as modelId, modelIdx (modelId)}
<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
<Tooltip content={modelId} placement="top-start">
<div class="flex items-center gap-1">
<EllipsisVertical className="size-4 cursor-move" />
<div class=" text-sm flex-1 py-1 rounded-lg">
{#if $models.find((model) => model.id === modelId)}
{$models.find((model) => model.id === modelId).name}
{:else}
{modelId}
{/if}
</div>
</div>
</Tooltip>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2">
{$i18n.t('No models found')}
</div>
{/if}
@@ -1,214 +0,0 @@
<script lang="ts">
import { getBackendConfig, getModelFilterConfig, updateModelFilterConfig } from '$lib/apis';
import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
import { getUserPermissions, updateUserPermissions } from '$lib/apis/users';
import { onMount, getContext } from 'svelte';
import { models, config } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import { setDefaultModels } from '$lib/apis/configs';
const i18n = getContext('i18n');
export let saveHandler: Function;
let defaultModelId = '';
let whitelistEnabled = false;
let whitelistModels = [''];
let permissions = {
chat: {
deletion: true,
edit: true,
temporary: true
}
};
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;
whitelistModels = res.models.length > 0 ? res.models : [''];
}
defaultModelId = $config.default_models ? $config?.default_models.split(',')[0] : '';
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
// console.log('submit');
await setDefaultModels(localStorage.token, defaultModelId);
await updateUserPermissions(localStorage.token, {
chat: {
deletion: chatDeletion,
editing: chatEdit,
temporary: chatTemporary
}
});
await updateModelFilterConfig(localStorage.token, whitelistEnabled, whitelistModels);
saveHandler();
await config.set(await getBackendConfig());
}}
>
<div class=" space-y-3 overflow-y-scroll max-h-full">
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('User Permissions')}</div>
<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>
<Switch bind:state={chatDeletion} />
</div>
<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>
<Switch bind:state={chatEdit} />
</div>
<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>
<Switch bind:state={chatTemporary} />
</div>
</div>
<hr class=" dark:border-gray-850 my-2" />
<div class="mt-2 space-y-3">
<div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">{$i18n.t('Manage Models')}</div>
</div>
</div>
<div class=" space-y-1 mb-3">
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={defaultModelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
<div class=" space-y-1">
<div class="mb-2">
<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} />
</div>
</div>
{#if whitelistEnabled}
<div>
<div class=" space-y-1.5">
{#each whitelistModels as modelId, modelIdx}
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={modelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</div>
{#if modelIdx === 0}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
if (whitelistModels.at(-1) !== '') {
whitelistModels = [...whitelistModels, ''];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
whitelistModels.splice(modelIdx, 1);
whitelistModels = whitelistModels;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
<div class="flex justify-end items-center text-xs mt-1.5 text-right">
<div class=" text-xs font-medium">
{whitelistModels.length}
{$i18n.t('Model(s) Whitelisted')}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</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>
@@ -16,24 +16,29 @@
'searxng',
'google_pse',
'brave',
'kagi',
'mojeek',
'serpstack',
'serper',
'serply',
'searchapi',
'duckduckgo',
'tavily',
'jina'
'jina',
'bing'
];
let youtubeLanguage = 'en';
let youtubeTranslation = null;
let youtubeProxyUrl = '';
const submitHandler = async () => {
const res = await updateRAGConfig(localStorage.token, {
web: webConfig,
youtube: {
language: youtubeLanguage.split(',').map((lang) => lang.trim()),
translation: youtubeTranslation
translation: youtubeTranslation,
proxy_url: youtubeProxyUrl
}
});
};
@@ -46,6 +51,7 @@
youtubeLanguage = res.youtube.language.join(',');
youtubeTranslation = res.youtube.translation;
youtubeProxyUrl = res.youtube.proxy_url;
}
});
</script>
@@ -150,6 +156,28 @@
bind:value={webConfig.search.brave_search_api_key}
/>
</div>
{:else if webConfig.search.engine === 'kagi'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Kagi Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Kagi Search API Key')}
bind:value={webConfig.search.kagi_search_api_key}
/>
</div>
{:else if webConfig.search.engine === 'mojeek'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Mojeek Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Mojeek Search API Key')}
bind:value={webConfig.search.mojeek_search_api_key}
/>
</div>
{:else if webConfig.search.engine === 'serpstack'}
<div>
<div class=" self-center text-xs font-medium mb-1">
@@ -222,6 +250,46 @@
bind:value={webConfig.search.tavily_api_key}
/>
</div>
{:else if webConfig.search.engine === 'jina'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Jina API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Jina API Key')}
bind:value={webConfig.search.jina_api_key}
/>
</div>
{:else if webConfig.search.engine === 'bing'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Endpoint')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter Bing Search V7 Endpoint')}
bind:value={webConfig.search.bing_search_v7_endpoint}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Subscription Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Bing Search V7 Subscription Key')}
bind:value={webConfig.search.bing_search_v7_subscription_key}
/>
</div>
{/if}
</div>
{/if}
@@ -273,12 +341,12 @@
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
webConfig.ssl_verification = !webConfig.ssl_verification;
webConfig.web_loader_ssl_verification = !webConfig.web_loader_ssl_verification;
submitHandler();
}}
type="button"
>
{#if webConfig.ssl_verification === true}
{#if webConfig.web_loader_ssl_verification === false}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
@@ -305,6 +373,21 @@
</div>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Proxy URL')}</div>
<div class=" flex-1 self-center">
<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"
type="text"
placeholder={$i18n.t('Enter proxy URL (e.g. https://user:password@host:port)')}
bind:value={youtubeProxyUrl}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{/if}
</div>
+110
View File
@@ -0,0 +1,110 @@
<script>
import { getContext, tick, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { getUsers } from '$lib/apis/users';
import UserList from './Users/UserList.svelte';
import Groups from './Users/Groups.svelte';
const i18n = getContext('i18n');
let users = [];
let selectedTab = 'overview';
let loaded = false;
$: if (selectedTab) {
getUsersHandler();
}
const getUsersHandler = async () => {
users = await getUsers(localStorage.token);
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
users = await getUsers(localStorage.token);
}
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
if (containerElement) {
containerElement.addEventListener('wheel', function (event) {
if (event.deltaY !== 0) {
// Adjust horizontal scroll position based on vertical scroll
containerElement.scrollLeft += event.deltaY;
}
});
}
});
</script>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
'overview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'overview';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
d="M8.5 4.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0ZM10.9 12.006c.11.542-.348.994-.9.994H2c-.553 0-1.01-.452-.902-.994a5.002 5.002 0 0 1 9.803 0ZM14.002 12h-1.59a2.556 2.556 0 0 0-.04-.29 6.476 6.476 0 0 0-1.167-2.603 3.002 3.002 0 0 1 3.633 1.911c.18.522-.283.982-.836.982ZM12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Overview')}</div>
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
'groups'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'groups';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
d="M8 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM3.156 11.763c.16-.629.44-1.21.813-1.72a2.5 2.5 0 0 0-2.725 1.377c-.136.287.102.58.418.58h1.449c.01-.077.025-.156.045-.237ZM12.847 11.763c.02.08.036.16.046.237h1.446c.316 0 .554-.293.417-.579a2.5 2.5 0 0 0-2.722-1.378c.374.51.653 1.09.813 1.72ZM14 7.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM3.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM5 13c-.552 0-1.013-.455-.876-.99a4.002 4.002 0 0 1 7.753 0c.136.535-.324.99-.877.99H5Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Groups')}</div>
</button>
</div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
{#if selectedTab === 'overview'}
<UserList {users} />
{:else if selectedTab === 'groups'}
<Groups {users} />
{/if}
</div>
</div>
@@ -0,0 +1,237 @@
<script>
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { WEBUI_NAME, config, user, showSidebar, knowledge } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
const i18n = getContext('i18n');
let loaded = false;
export let users = [];
let groups = [];
let filteredGroups;
$: filteredGroups = groups.filter((user) => {
if (search === '') {
return true;
} else {
let name = user.name.toLowerCase();
const query = search.toLowerCase();
return name.includes(query);
}
});
let search = '';
let defaultPermissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
let showCreateGroupModal = false;
let showDefaultPermissionsModal = false;
const setGroups = async () => {
groups = await getGroups(localStorage.token);
};
const addGroupHandler = async (group) => {
const res = await createNewGroup(localStorage.token, group).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group created successfully'));
groups = await getGroups(localStorage.token);
}
};
const updateDefaultPermissionsHandler = async (group) => {
console.log(group.permissions);
const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
toast.success($i18n.t('Default permissions updated successfully'));
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
}
loaded = true;
});
</script>
{#if loaded}
<AddGroupModal bind:show={showCreateGroupModal} onSubmit={addGroupHandler} />
<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('Groups')}
<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">{groups.length}</span>
</div>
<div class="flex gap-1">
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<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 text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
</div>
<div>
<Tooltip content={$i18n.t('Create Group')}>
<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={() => {
showCreateGroupModal = !showCreateGroupModal;
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
<div>
{#if filteredGroups.length === 0}
<div class="flex flex-col items-center justify-center h-40">
<div class=" text-xl font-medium">
{$i18n.t('Organize your users')}
</div>
<div class="mt-1 text-sm dark:text-gray-300">
{$i18n.t('Use groups to group your users and assign permissions.')}
</div>
<div class="mt-3">
<button
class=" px-4 py-1.5 text-sm rounded-full bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition font-medium flex items-center space-x-1"
aria-label={$i18n.t('Create Group')}
on:click={() => {
showCreateGroupModal = true;
}}
>
{$i18n.t('Create Group')}
</button>
</div>
</div>
{:else}
<div>
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
<div class="w-full">Group</div>
<div class="w-full">Users</div>
<div class="w-full"></div>
</div>
<hr class="mt-1.5 border-gray-50 dark:border-gray-850" />
{#each filteredGroups as group}
<div class="my-2">
<GroupItem {group} {users} {setGroups} />
</div>
{/each}
</div>
{/if}
<hr class="mb-2 border-gray-50 dark:border-gray-850" />
<GroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
<button
class="flex items-center justify-between rounded-lg w-full transition pt-1"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
<div class="flex items-center gap-2.5">
<div class="p-1.5 bg-black/5 dark:bg-white/10 rounded-full">
<UsersSolid className="size-4" />
</div>
<div class="text-left">
<div class=" text-sm font-medium">{$i18n.t('Default permissions')}</div>
<div class="flex text-xs mt-0.5">
{$i18n.t('applies to all users with the "user" role')}
</div>
</div>
</div>
<div>
<ChevronRight strokeWidth="2.5" />
</div>
</button>
</div>
{/if}
@@ -0,0 +1,149 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
export let onSubmit: Function = () => {};
export let show = false;
let name = '';
let description = '';
let userIds = [];
let loading = false;
const submitHandler = async () => {
loading = true;
const group = {
name,
description
};
await onSubmit(group);
loading = false;
show = false;
name = '';
description = '';
userIds = [];
};
onMount(() => {
console.log('mounted');
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Add User Group')}
</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={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1 flex flex-col w-full">
<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('Group Name')}
autocomplete="off"
required
/>
</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">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<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('Create')}
{#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>
@@ -0,0 +1,61 @@
<script lang="ts">
import { getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let name = '';
export let color = '';
export let description = '';
</script>
<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('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<!-- <div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>
<div class="flex-1">
<Tooltip content={$i18n.t('Hex Color - Leave empty for default color')} placement="top-start">
<div class="flex gap-0.5">
<div class="text-gray-500">#</div>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={color}
placeholder={$i18n.t('Hex Color')}
autocomplete="off"
/>
</div>
</Tooltip>
</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">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
rows={4}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
@@ -0,0 +1,328 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import Display from './Display.svelte';
import Permissions from './Permissions.svelte';
import Users from './Users.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let users = [];
export let group = null;
export let custom = true;
export let tabs = ['general', 'permissions', 'users'];
let selectedTab = 'general';
let loading = false;
export let name = '';
export let description = '';
export let permissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
export let userIds = [];
const submitHandler = async () => {
loading = true;
const group = {
name,
description,
permissions,
user_ids: userIds
};
await onSubmit(group);
loading = false;
show = false;
};
const init = () => {
if (group) {
name = group.name;
description = group.description;
permissions = group?.permissions ?? {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
userIds = group?.user_ids ?? [];
}
};
$: if (show) {
init();
}
onMount(() => {
console.log(tabs);
selectedTab = tabs[0];
init();
});
</script>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{#if custom}
{#if edit}
{$i18n.t('Edit User Group')}
{:else}
{$i18n.t('Add User Group')}
{/if}
{:else}
{$i18n.t('Edit Default Permissions')}
{/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={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
{#if tabs.includes('general')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'general';
}}
type="button"
>
<div class=" self-center mr-2">
<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="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('General')}</div>
</button>
{/if}
{#if tabs.includes('permissions')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'permissions'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'permissions';
}}
type="button"
>
<div class=" self-center mr-2">
<WrenchSolid />
</div>
<div class=" self-center">{$i18n.t('Permissions')}</div>
</button>
{/if}
{#if tabs.includes('users')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'users'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'users';
}}
type="button"
>
<div class=" self-center mr-2">
<UserPlusSolid />
</div>
<div class=" self-center">{$i18n.t('Users')} ({userIds.length})</div>
</button>
{/if}
</div>
<div
class="flex-1 mt-1 lg:mt-1 lg:h-[22rem] lg:max-h-[22rem] overflow-y-auto scrollbar-hidden"
>
{#if selectedTab == 'general'}
<Display bind:name bind:description />
{:else if selectedTab == 'permissions'}
<Permissions bind:permissions />
{:else if selectedTab == 'users'}
<Users bind:userIds {users} />
{/if}
</div>
</div>
<!-- <div
class=" tabs flex flex-row overflow-x-auto gap-2.5 text-sm font-medium border-b border-b-gray-800 scrollbar-hidden"
>
{#if tabs.includes('display')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'display'
? ' dark:border-white'
: 'border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'display';
}}
type="button"
>
{$i18n.t('Display')}
</button>
{/if}
{#if tabs.includes('permissions')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'permissions'
? ' dark:border-white'
: 'border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'permissions';
}}
type="button"
>
{$i18n.t('Permissions')}
</button>
{/if}
{#if tabs.includes('users')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'users'
? ' dark:border-white'
: ' border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'users';
}}
type="button"
>
{$i18n.t('Users')} ({userIds.length})
</button>
{/if}
</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={() => {
onDelete();
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>
@@ -0,0 +1,84 @@
<script>
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { deleteGroupById, updateGroupById } from '$lib/apis/groups';
import Pencil from '$lib/components/icons/Pencil.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte';
export let users = [];
export let group = {
name: 'Admins',
user_ids: [1, 2, 3]
};
export let setGroups = () => {};
let showEdit = false;
const updateHandler = async (_group) => {
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group updated successfully'));
setGroups();
}
};
const deleteHandler = async () => {
const res = await deleteGroupById(localStorage.token, group.id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group deleted successfully'));
setGroups();
}
};
</script>
<GroupModal
bind:show={showEdit}
edit
{users}
{group}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>
<button
class="flex items-center gap-3 justify-between px-1 text-xs w-full transition"
on:click={() => {
showEdit = true;
}}
>
<div class="flex items-center gap-1.5 w-full font-medium">
<div>
<UserCircleSolid className="size-4" />
</div>
{group.name}
</div>
<div class="flex items-center gap-1.5 w-full font-medium">
{group.user_ids.length}
<div>
<User className="size-3.5" />
</div>
</div>
<div class="w-full flex justify-end">
<div class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition">
<Pencil className="size-3.5" />
</div>
</div>
</button>
@@ -0,0 +1,204 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let permissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
delete: true,
edit: true,
temporary: true,
file_upload: true
}
};
</script>
<div>
<!-- <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Model Permissions')}</div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Filtering')}</div>
<Switch bind:state={permissions.model.filter} />
</div>
</div>
{#if permissions.model.filter}
<div class="mb-2">
<div class=" space-y-1.5">
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Model IDs')}</div>
</div>
{#if model_ids.length > 0}
<div class="flex flex-col">
{#each model_ids as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="flex-shrink-0">
<button
type="button"
on:click={() => {
model_ids = model_ids.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 px-10">
{$i18n.t('No model IDs')}
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-2.5 mb-1 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={() => {
if (selectedModelId && !permissions.model.model_ids.includes(selectedModelId)) {
permissions.model.model_ids = [...permissions.model.model_ids, selectedModelId];
selectedModelId = '';
}
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
{/if}
<div class=" space-y-1 mb-3">
<div class="">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-none py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each permissions.model.filter ? $models.filter( (model) => filterModelIds.includes(model.id) ) : $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" /> -->
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Access')}
</div>
<Switch bind:state={permissions.workspace.models} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Access')}
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Access')}
</div>
<Switch bind:state={permissions.workspace.prompts} />
</div>
<div class=" ">
<Tooltip
className=" flex w-full justify-between my-2 pr-2"
content={$i18n.t(
'Warning: Enabling this will allow users to upload arbitrary code on the server.'
)}
placement="top-start"
>
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Access')}
</div>
<Switch bind:state={permissions.workspace.tools} />
</Tooltip>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
</div>
<Switch bind:state={permissions.chat.file_upload} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
</div>
<Switch bind:state={permissions.chat.edit} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
</div>
</div>
@@ -0,0 +1,122 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte';
export let users = [];
export let userIds = [];
let filteredUsers = [];
$: filteredUsers = users
.filter((user) => {
if (user?.role === 'admin') {
return false;
}
if (query === '') {
return true;
}
return (
user.name.toLowerCase().includes(query.toLowerCase()) ||
user.email.toLowerCase().includes(query.toLowerCase())
);
})
.sort((a, b) => {
const aUserIndex = userIds.indexOf(a.id);
const bUserIndex = userIds.indexOf(b.id);
// Compare based on userIds or fall back to alphabetical order
if (aUserIndex !== -1 && bUserIndex === -1) return -1; // 'a' has valid userId -> prioritize
if (bUserIndex !== -1 && aUserIndex === -1) return 1; // 'b' has valid userId -> prioritize
// Both a and b are either in the userIds array or not, so we'll sort them by their indices
if (aUserIndex !== -1 && bUserIndex !== -1) return aUserIndex - bUserIndex;
// If both are not in the userIds, fallback to alphabetical sorting by name
return a.name.localeCompare(b.name);
});
let query = '';
</script>
<div>
<div class="flex w-full">
<div class="flex flex-1">
<div class=" self-center mr-3">
<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 text-sm pr-4 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
</div>
<div class="mt-3 max-h-[22rem] overflow-y-auto scrollbar-hidden">
<div class="flex flex-col gap-2.5">
{#if filteredUsers.length > 0}
{#each filteredUsers as user, userIdx (user.id)}
<div class="flex flex-row items-center gap-3 w-full text-sm">
<div class="flex items-center">
<Checkbox
state={userIds.includes(user.id) ? 'checked' : 'unchecked'}
on:change={(e) => {
if (e.detail === 'checked') {
userIds = [...userIds, user.id];
} else {
userIds = userIds.filter((id) => id !== user.id);
}
}}
/>
</div>
<div class="flex w-full items-center justify-between">
<Tooltip content={user.email} placement="top-start">
<div class="flex">
<img
class=" rounded-full size-5 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</Tooltip>
{#if userIds.includes(user.id)}
<Badge type="success" content="member" />
{/if}
</div>
</div>
{/each}
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No users were found.')}
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,451 @@
<script>
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { toast } from 'svelte-sonner';
import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
import Pagination from '$lib/components/common/Pagination.svelte';
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte';
import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte';
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import About from '$lib/components/chat/Settings/About.svelte';
const i18n = getContext('i18n');
export let users = [];
let search = '';
let selectedUser = null;
let page = 1;
let showDeleteConfirmDialog = false;
let showAddUserModal = false;
let showUserChatsModal = false;
let showEditUserModal = false;
const updateRoleHandler = async (id, role) => {
const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
toast.error(error);
return null;
});
if (res) {
users = await getUsers(localStorage.token);
}
};
const deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
users = await getUsers(localStorage.token);
}
};
let sortKey = 'created_at'; // default sort key
let sortOrder = 'asc'; // default sort order
function setSortKey(key) {
if (sortKey === key) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortOrder = 'asc';
}
}
let filteredUsers;
$: filteredUsers = users
.filter((user) => {
if (search === '') {
return true;
} else {
let name = user.name.toLowerCase();
const query = search.toLowerCase();
return name.includes(query);
}
})
.sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
})
.slice((page - 1) * 20, page * 20);
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
deleteUserHandler(selectedUser.id);
}}
/>
{#key selectedUser}
<EditUserModal
bind:show={showEditUserModal}
{selectedUser}
sessionUser={$user}
on:save={async () => {
users = await getUsers(localStorage.token);
}}
/>
{/key}
<AddUserModal
bind:show={showAddUserModal}
on:save={async () => {
users = await getUsers(localStorage.token);
}}
/>
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
<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('Users')}
<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">{users.length}</span>
</div>
<div class="flex gap-1">
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<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 text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
</div>
<div>
<Tooltip content={$i18n.t('Add User')}>
<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={() => {
showAddUserModal = !showAddUserModal;
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5">
<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 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('role')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Role')}
{#if sortKey === 'role'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Name')}
{#if sortKey === 'name'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('email')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Email')}
{#if sortKey === 'email'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
{#if sortKey === 'last_active_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if sortKey === 'oauth_sub'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody class="">
{#each filteredUsers as user, userIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
on:click={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}}
>
<Badge
type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'}
content={$i18n.t(user.role)}
/>
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
</Tooltip>
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showDeleteConfirmDialog = true;
selectedUser = user;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class=" text-gray-500 text-xs mt-1.5 text-right">
ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
</div>
<Pagination bind:page count={users.length} />
@@ -4,9 +4,10 @@
import { onMount, getContext } from 'svelte';
import { addUser } from '$lib/apis/auths';
import Modal from '../common/Modal.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import Modal from '$lib/components/common/Modal.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -5,7 +5,8 @@
import { onMount, getContext } from 'svelte';
import { updateUserById } from '$lib/apis/users';
import Modal from '../common/Modal.svelte';
import Modal from '$lib/components/common/Modal.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -5,16 +5,18 @@
const dispatch = createEventDispatcher();
import Modal from '$lib/components/common/Modal.svelte';
import { getChatListByUserId, deleteChatById, getArchivedChatList } from '$lib/apis/chats';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
export let show = false;
export let user;
let chats = [];
let chats = null;
const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
@@ -30,6 +32,8 @@
chats = await getChatListByUserId(localStorage.token, user.id);
}
})();
} else {
chats = null;
}
let sortKey = 'updated_at'; // default sort key
@@ -45,33 +49,32 @@
</script>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t("{{user}}'s Chats", { user: user.name })}
</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 class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t("{{user}}'s Chats", { user: user.name })}
</div>
<hr class=" dark:border-gray-850" />
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 py-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">
<div class="flex flex-col md:flex-row w-full px-5 pt-2 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">
{#if chats}
{#if chats.length > 0}
<div class="text-left text-sm w-full mb-4 max-h-[22rem] overflow-y-scroll">
<div class="relative overflow-x-auto">
@@ -94,19 +97,7 @@
</th>
<th
scope="col"
class="px-3 py-2 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible">▲</span>
{/if}
</th>
<th
scope="col"
class="px-3 py-2 hidden md:flex cursor-pointer select-none"
class="px-3 py-2 hidden md:flex cursor-pointer select-none justify-end"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
@@ -131,19 +122,14 @@
>
<td class="px-3 py-1">
<a href="/s/{chat.id}" target="_blank">
<div class=" underline line-clamp-1">
<div class=" underline line-clamp-1 max-w-96">
{chat.title}
</div>
</a>
</td>
<td class=" px-3 py-1 h-[2.5rem]">
<div class="my-auto">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div>
</td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]">
<div class="my-auto">
<td class=" px-3 py-1 hidden md:flex h-[2.5rem] justify-end">
<div class="my-auto shrink-0">
{dayjs(chat.updated_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div>
</td>
@@ -192,7 +178,9 @@
{$i18n.t('has no conversations.')}
</div>
{/if}
</div>
{:else}
<Spinner />
{/if}
</div>
</div>
</Modal>
+186 -147
View File
@@ -34,7 +34,8 @@
mobile,
showOverview,
chatTitle,
showArtifacts
showArtifacts,
tools
} from '$lib/stores';
import {
convertMessagesToHistory,
@@ -65,7 +66,7 @@
import {
chatCompleted,
generateTitle,
generateSearchQuery,
generateQueries,
chatAction,
generateMoACompletion,
generateTags
@@ -78,6 +79,7 @@
import ChatControls from './ChatControls.svelte';
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import Placeholder from './Placeholder.svelte';
import { getTools } from '$lib/apis/tools';
export let chatIdProp = '';
@@ -141,6 +143,38 @@
})();
}
$: if (selectedModels && chatIdProp !== '') {
saveSessionSelectedModels();
}
const saveSessionSelectedModels = () => {
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
return;
}
sessionStorage.selectedModels = JSON.stringify(selectedModels);
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
};
$: if (selectedModels) {
setToolIds();
}
const setToolIds = async () => {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (selectedModels.length !== 1) {
return;
}
const model = $models.find((m) => m.id === selectedModels[0]);
if (model) {
selectedToolIds = (model?.info?.meta?.toolIds ?? []).filter((id) =>
$tools.find((t) => t.id === id)
);
}
};
const showMessage = async (message) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
@@ -182,7 +216,7 @@
} else {
message.statusHistory = [data];
}
} else if (type === 'citation') {
} else if (type === 'source' || type === 'citation') {
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
if (!message?.code_executions) {
@@ -201,11 +235,11 @@
message.code_executions = message.code_executions;
} else {
// Regular citation.
if (message?.citations) {
message.citations.push(data);
// Regular source.
if (message?.sources) {
message.sources.push(data);
} else {
message.citations = [data];
message.sources = [data];
}
}
} else if (type === 'message') {
@@ -300,6 +334,7 @@
};
onMount(async () => {
console.log('mounted');
window.addEventListener('message', onMessageHandler);
$socket?.on('chat-events', chatEventHandler);
@@ -545,28 +580,6 @@
//////////////////////////
const initNewChat = async () => {
await showControls.set(false);
await showCallOverlay.set(false);
await showOverview.set(false);
await showArtifacts.set(false);
if ($page.url.pathname.includes('/c/')) {
window.history.replaceState(history.state, '', `/`);
}
autoScroll = true;
await chatId.set('');
await chatTitle.set('');
history = {
messages: {},
currentId: null
};
chatFiles = [];
params = {};
if ($page.url.searchParams.get('models')) {
selectedModels = $page.url.searchParams.get('models')?.split(',');
} else if ($page.url.searchParams.get('model')) {
@@ -593,15 +606,21 @@
} 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 {
if (sessionStorage.selectedModels) {
selectedModels = JSON.parse(sessionStorage.selectedModels);
sessionStorage.removeItem('selectedModels');
} else {
if ($settings?.models) {
selectedModels = $settings?.models;
} else if ($config?.default_models) {
console.log($config?.default_models.split(',') ?? '');
selectedModels = $config?.default_models.split(',');
}
}
}
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];
@@ -610,7 +629,27 @@
}
}
console.log(selectedModels);
await showControls.set(false);
await showCallOverlay.set(false);
await showOverview.set(false);
await showArtifacts.set(false);
if ($page.url.pathname.includes('/c/')) {
window.history.replaceState(history.state, '', `/`);
}
autoScroll = true;
await chatId.set('');
await chatTitle.set('');
history = {
messages: {},
currentId: null
};
chatFiles = [];
params = {};
if ($page.url.searchParams.get('youtube')) {
uploadYoutubeTranscription(
@@ -751,7 +790,8 @@
role: m.role,
content: m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp
timestamp: m.timestamp,
...(m.sources ? { sources: m.sources } : {})
})),
chat_id: chatId,
session_id: $socket?.id,
@@ -804,7 +844,8 @@
role: m.role,
content: m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp
timestamp: m.timestamp,
...(m.sources ? { sources: m.sources } : {})
})),
...(event ? { event: event } : {}),
chat_id: chatId,
@@ -923,9 +964,12 @@
console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history.currentId);
selectedModels = selectedModels.map((modelId) =>
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
selectedModels = _selectedModels;
}
if (userPrompt === '') {
toast.error($i18n.t('Please enter a prompt'));
@@ -971,11 +1015,10 @@
await tick();
// Reset chat input textarea
const chatInputContainer = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
if (chatInputContainer) {
chatInputContainer.value = '';
chatInputContainer.style.height = '';
if (chatInputElement) {
chatInputElement.style.height = '';
}
const _files = JSON.parse(JSON.stringify(files));
@@ -1018,6 +1061,7 @@
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
saveSessionSelectedModels();
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
return _responses;
@@ -1139,11 +1183,14 @@
}
let _response = null;
if (model?.owned_by === 'ollama') {
_response = await sendPromptOllama(model, prompt, responseMessageId, _chatId);
} else if (model) {
_response = await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
}
// 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);
if (chatEventEmitter) clearInterval(chatEventEmitter);
@@ -1290,24 +1337,14 @@
$settings?.params?.stream_response ??
params?.stream_response ??
true;
const [res, controller] = await generateChatCompletion(localStorage.token, {
stream: stream,
model: model.id,
messages: messagesBody,
options: {
...{ ...($settings?.params ?? {}), ...params },
stop:
(params?.stop ?? $settings?.params?.stop ?? undefined)
? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map(
(str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined,
num_predict: params?.max_tokens ?? $settings?.params?.max_tokens ?? undefined,
repeat_penalty:
params?.frequency_penalty ?? $settings?.params?.frequency_penalty ?? undefined
},
format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
files: files.length > 0 ? files : undefined,
session_id: $socket?.id,
@@ -1360,8 +1397,8 @@
console.log(line);
let data = JSON.parse(line);
if ('citations' in data) {
responseMessage.citations = data.citations;
if ('sources' in data) {
responseMessage.sources = data.sources;
// Only remove status if it was initially set
if (model?.info?.meta?.knowledge ?? false) {
responseMessage.statusHistory = responseMessage.statusHistory.filter(
@@ -1625,13 +1662,6 @@
{
stream: stream,
model: model.id,
...(stream && (model.info?.meta?.capabilities?.usage ?? false)
? {
stream_options: {
include_usage: true
}
}
: {}),
messages: [
params?.system || $settings.system || (responseMessage?.userContext ?? null)
? {
@@ -1676,23 +1706,36 @@
content: message?.merged?.content ?? message.content
})
})),
seed: params?.seed ?? $settings?.params?.seed ?? undefined,
stop:
(params?.stop ?? $settings?.params?.stop ?? undefined)
? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map(
(str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined,
temperature: params?.temperature ?? $settings?.params?.temperature ?? undefined,
top_p: params?.top_p ?? $settings?.params?.top_p ?? undefined,
frequency_penalty:
params?.frequency_penalty ?? $settings?.params?.frequency_penalty ?? undefined,
max_tokens: params?.max_tokens ?? $settings?.params?.max_tokens ?? undefined,
// params: {
// ...$settings?.params,
// ...params,
// format: $settings.requestFormat ?? undefined,
// keep_alive: $settings.keepAlive ?? undefined,
// stop:
// (params?.stop ?? $settings?.params?.stop ?? undefined)
// ? (
// params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop
// ).map((str) =>
// decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
// )
// : undefined
// },
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
files: files.length > 0 ? files : undefined,
session_id: $socket?.id,
chat_id: $chatId,
id: responseMessageId
id: responseMessageId,
...(stream && (model.info?.meta?.capabilities?.usage ?? false)
? {
stream_options: {
include_usage: true
}
}
: {})
},
`${WEBUI_BASE_URL}/api`
);
@@ -1714,11 +1757,12 @@
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
for await (const update of textStream) {
const { value, done, citations, selectedModelId, error, usage } = update;
const { value, done, sources, selectedModelId, error, usage } = update;
if (error) {
await handleOpenAIError(error, null, model, responseMessage);
break;
}
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
history.messages[responseMessageId] = responseMessage;
@@ -1731,7 +1775,7 @@
}
if (usage) {
responseMessage.info = { ...usage, openai: true, usage };
responseMessage.usage = usage;
}
if (selectedModelId) {
@@ -1740,8 +1784,8 @@
continue;
}
if (citations) {
responseMessage.citations = citations;
if (sources) {
responseMessage.sources = sources;
// Only remove status if it was initially set
if (model?.info?.meta?.knowledge ?? false) {
responseMessage.statusHistory = responseMessage.statusHistory.filter(
@@ -1919,6 +1963,33 @@
console.log('stopResponse');
};
const submitMessage = async (parentId, prompt) => {
let userPrompt = prompt;
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: parentId,
childrenIds: [],
role: 'user',
content: userPrompt,
models: selectedModels
};
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
userMessageId
];
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId);
};
const regenerateResponse = async (message) => {
console.log('regenerateResponse');
@@ -1949,7 +2020,9 @@
responseMessage.done = false;
await tick();
const model = $models.filter((m) => m.id === responseMessage.model).at(0);
const model = $models
.filter((m) => m.id === (responseMessage?.selectedModelId ?? responseMessage.model))
.at(0);
if (model) {
if (model?.owned_by === 'openai') {
@@ -1967,8 +2040,6 @@
_chatId
);
}
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
};
@@ -1993,7 +2064,7 @@
if (res && res.ok && res.body) {
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
for await (const update of textStream) {
const { value, done, citations, error, usage } = update;
const { value, done, sources, error, usage } = update;
if (error || done) {
break;
}
@@ -2020,20 +2091,21 @@
};
const generateChatTitle = async (messages) => {
const lastUserMessage = messages.filter((message) => message.role === 'user').at(-1);
if ($settings?.title?.auto ?? true) {
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 lastUserMessage?.content ?? 'New Chat';
}
);
return title;
return title ? title : (lastUserMessage?.content ?? 'New Chat');
} else {
return 'New Chat';
return lastUserMessage?.content ?? 'New Chat';
}
};
@@ -2089,6 +2161,7 @@
parentId: string,
responseMessageId: string
) => {
// TODO: move this to the backend
const responseMessage = history.messages[responseMessageId];
const userMessage = history.messages[parentId];
const messages = createMessagesList(history.currentId);
@@ -2103,17 +2176,17 @@
history.messages[responseMessageId] = responseMessage;
const prompt = userMessage.content;
let searchQuery = await generateSearchQuery(
let queries = await generateQueries(
localStorage.token,
model,
messages.filter((message) => message?.content?.trim()),
prompt
).catch((error) => {
console.log(error);
return prompt;
return [prompt];
});
if (!searchQuery || searchQuery == '') {
if (queries.length === 0) {
responseMessage.statusHistory.push({
done: true,
error: true,
@@ -2124,6 +2197,8 @@
return;
}
const searchQuery = queries[0];
responseMessage.statusHistory.push({
done: false,
action: 'web_search',
@@ -2326,47 +2401,17 @@
{selectedModels}
{sendPrompt}
{showMessage}
{submitMessage}
{continueResponse}
{regenerateResponse}
{mergeResponses}
{chatActionHandler}
bottomPadding={files.length > 0}
on:submit={async (e) => {
if (e.detail) {
// New user message
let userPrompt = e.detail.prompt;
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: e.detail.parentId,
childrenIds: [],
role: 'user',
content: userPrompt,
models: selectedModels
};
let messageParentId = e.detail.parentId;
if (messageParentId !== null) {
history.messages[messageParentId].childrenIds = [
...history.messages[messageParentId].childrenIds,
userMessageId
];
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId);
}
}}
/>
</div>
</div>
<div class=" pb-[1.6rem]">
<div class=" pb-[1rem]">
<MessageInput
{history}
{selectedModels}
@@ -2376,13 +2421,6 @@
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
availableToolIds={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model?.info?.meta?.toolIds ?? false) {
return [...new Set([...a, ...model.info.meta.toolIds])];
}
return a;
}, [])}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}
@@ -2400,15 +2438,19 @@
on:submit={async (e) => {
if (e.detail) {
await tick();
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/>
<div
class="absolute bottom-1.5 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
class="absolute bottom-1 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
>
{$i18n.t('LLMs can make mistakes. Verify important information.')}
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{:else}
@@ -2422,13 +2464,6 @@
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
availableToolIds={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model?.info?.meta?.toolIds ?? false) {
return [...new Set([...a, ...model.info.meta.toolIds])];
}
return a;
}, [])}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}
@@ -2444,7 +2479,11 @@
on:submit={async (e) => {
if (e.detail) {
await tick();
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/>
@@ -13,6 +13,8 @@
export let models = [];
export let chatFiles = [];
export let params = {};
let showValves = false;
</script>
<div class=" dark:text-white">
@@ -59,9 +61,9 @@
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible title={$i18n.t('Valves')} buttonClassName="w-full">
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full">
<div class="text-sm" slot="content">
<Valves />
<Valves show={showValves} />
</div>
</Collapsible>
+92 -67
View File
@@ -7,12 +7,14 @@
import {
getUserValvesSpecById as getToolUserValvesSpecById,
getUserValvesById as getToolUserValvesById,
updateUserValvesById as updateToolUserValvesById
updateUserValvesById as updateToolUserValvesById,
getTools
} from '$lib/apis/tools';
import {
getUserValvesSpecById as getFunctionUserValvesSpecById,
getUserValvesById as getFunctionUserValvesById,
updateUserValvesById as updateFunctionUserValvesById
updateUserValvesById as updateFunctionUserValvesById,
getFunctions
} from '$lib/apis/functions';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -23,6 +25,8 @@
const i18n = getContext('i18n');
export let show = false;
let tab = 'tools';
let selectedId = '';
@@ -112,77 +116,98 @@
$: if (selectedId) {
getUserValves();
}
$: if (show) {
init();
}
const init = async () => {
loading = true;
if ($functions === null) {
functions.set(await getFunctions(localStorage.token));
}
if ($tools === null) {
tools.set(await getTools(localStorage.token));
}
loading = false;
};
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
submitHandler();
dispatch('save');
}}
>
<div class="flex flex-col">
<div class="space-y-1">
<div class="flex gap-2">
<div class="flex-1">
<select
class=" w-full rounded text-xs py-2 px-1 bg-transparent outline-none"
bind:value={tab}
placeholder="Select"
>
<option value="tools" class="bg-gray-100 dark:bg-gray-800">{$i18n.t('Tools')}</option>
<option value="functions" class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Functions')}</option
{#if show && !loading}
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
submitHandler();
dispatch('save');
}}
>
<div class="flex flex-col">
<div class="space-y-1">
<div class="flex gap-2">
<div class="flex-1">
<select
class=" w-full rounded text-xs py-2 px-1 bg-transparent outline-none"
bind:value={tab}
placeholder="Select"
>
</select>
</div>
<div class="flex-1">
<select
class="w-full rounded py-2 px-1 text-xs bg-transparent outline-none"
bind:value={selectedId}
on:change={async () => {
await tick();
}}
>
{#if tab === 'tools'}
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Select a tool')}</option
<option value="tools" class="bg-gray-100 dark:bg-gray-800">{$i18n.t('Tools')}</option>
<option value="functions" class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Functions')}</option
>
</select>
</div>
{#each $tools as tool, toolIdx}
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
{/each}
{:else if tab === 'functions'}
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Select a function')}</option
>
<div class="flex-1">
<select
class="w-full rounded py-2 px-1 text-xs bg-transparent outline-none"
bind:value={selectedId}
on:change={async () => {
await tick();
}}
>
{#if tab === 'tools'}
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Select a tool')}</option
>
{#each $functions as func, funcIdx}
<option value={func.id} class="bg-gray-100 dark:bg-gray-800">{func.name}</option>
{/each}
{/if}
</select>
{#each $tools as tool, toolIdx}
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
{/each}
{:else if tab === 'functions'}
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-800"
>{$i18n.t('Select a function')}</option
>
{#each $functions as func, funcIdx}
<option value={func.id} class="bg-gray-100 dark:bg-gray-800">{func.name}</option>
{/each}
{/if}
</select>
</div>
</div>
</div>
{#if selectedId}
<hr class="dark:border-gray-800 my-1 w-full" />
<div class="my-2 text-xs">
{#if !loading}
<Valves
{valvesSpec}
bind:valves
on:change={() => {
debounceSubmitHandler();
}}
/>
{:else}
<Spinner className="size-5" />
{/if}
</div>
{/if}
</div>
{#if selectedId}
<hr class="dark:border-gray-800 my-1 w-full" />
<div class="my-2 text-xs">
{#if !loading}
<Valves
{valvesSpec}
bind:valves
on:change={() => {
debounceSubmitHandler();
}}
/>
{:else}
<Spinner className="size-5" />
{/if}
</div>
{/if}
</div>
</form>
</form>
{:else}
<Spinner className="size-4" />
{/if}
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,24 @@
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import { removeLastWordFromString } from '$lib/utils';
import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval';
import Spinner from '$lib/components/common/Spinner.svelte';
export let prompt = '';
export let files = [];
let loading = false;
let commandElement = null;
export const selectUp = () => {
@@ -26,55 +31,90 @@
let command = '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
let show = false;
$: show = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2);
$: if (show) {
init();
}
const init = async () => {
loading = true;
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;
};
</script>
{#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) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
type: 'youtube',
data: e.detail
});
}}
on:url={(e) => {
console.log(e);
dispatch('upload', {
type: 'web',
data: e.detail
});
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
...e.detail,
status: 'processed'
}
];
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
{: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.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
type: 'youtube',
data: e.detail
});
}}
on:url={(e) => {
console.log(e);
dispatch('upload', {
type: 'web',
data: e.detail
});
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
...e.detail,
status: 'processed'
}
];
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
dispatch('select', {
type: 'model',
data: e.detail
});
}}
/>
dispatch('select', {
type: 'model',
data: e.detail
});
}}
/>
{/if}
{:else}
<div
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<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-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<Spinner />
</div>
</div>
</div>
{/if}
{/if}
@@ -159,7 +159,7 @@
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<div
id="commands-container"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
@@ -68,7 +68,7 @@
{#if filteredItems.length > 0}
<div
id="commands-container"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
@@ -137,7 +137,7 @@
{#if filteredPrompts.length > 0}
<div
id="commands-container"
class="pl-3 pr-14 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div
@@ -1,15 +1,16 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import { createPicker } from '$lib/utils/google-drive-picker';
import { getContext, onMount, tick } from 'svelte';
import { config, user, tools as _tools } from '$lib/stores';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import { config } from '$lib/stores';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
const i18n = getContext('i18n');
@@ -18,22 +19,31 @@
export let uploadGoogleDriveHandler: Function;
export let selectedToolIds: string[] = [];
export let webSearchEnabled: boolean;
export let tools = {};
export let webSearchEnabled: boolean;
export let onClose: Function;
$: tools = Object.fromEntries(
Object.keys(tools).map((toolId) => [
toolId,
{
...tools[toolId],
enabled: selectedToolIds.includes(toolId)
}
])
);
let tools = {};
let show = false;
$: if (show) {
init();
}
const init = async () => {
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
tools = $_tools.reduce((a, tool, i, arr) => {
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
};
return a;
}, {});
};
</script>
<Dropdown
@@ -60,49 +70,63 @@
{#if Object.keys(tools).length > 0}
<div class=" max-h-28 overflow-y-auto scrollbar-hidden">
{#each Object.keys(tools) as toolId}
<div
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
<button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => {
tools[toolId].enabled = !tools[toolId].enabled;
}}
>
<div class="flex-1">
<div class="flex-1 truncate">
<Tooltip
content={tools[toolId]?.description ?? ''}
placement="top-start"
className="flex flex-1 gap-2 items-center"
className="flex flex-1 gap-2 items-center"
>
<WrenchSolid />
<div class="flex-shrink-0">
<WrenchSolid />
</div>
<div class=" line-clamp-1">{tools[toolId].name}</div>
<div class=" truncate">{tools[toolId].name}</div>
</Tooltip>
</div>
<Switch
bind:state={tools[toolId].enabled}
on:change={(e) => {
selectedToolIds = e.detail
? [...selectedToolIds, toolId]
: selectedToolIds.filter((id) => id !== toolId);
}}
/>
</div>
<div class=" flex-shrink-0">
<Switch
state={tools[toolId].enabled}
on:change={async (e) => {
const state = e.detail;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}}
/>
</div>
</button>
{/each}
</div>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
{#if $config?.features?.enable_web_search}
<div
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
<button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => {
webSearchEnabled = !webSearchEnabled;
}}
>
<div class="flex-1 flex items-center gap-2">
<GlobeAltSolid />
<div class=" line-clamp-1">{$i18n.t('Web Search')}</div>
</div>
<Switch bind:state={webSearchEnabled} />
</div>
<Switch state={webSearchEnabled} />
</button>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
<DropdownMenu.Item
@@ -171,7 +171,7 @@
mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
mediaRecorder.onstop = async () => {
console.log('Recording stopped');
if (($settings?.audio?.stt?.engine ?? '') === 'web') {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
audioChunks = [];
} else {
if (confirmed) {
@@ -229,8 +229,7 @@
console.log('recognition ended');
confirmRecording();
dispatch('confirm', transcription);
dispatch('confirm', { text: transcription });
confirmed = false;
loading = false;
};
@@ -251,6 +250,11 @@
if (recording && mediaRecorder) {
await mediaRecorder.stop();
}
if (speechRecognition) {
speechRecognition.stop();
}
stopDurationCounter();
audioChunks = [];
@@ -325,8 +329,8 @@
rounded-full"
on:click={async () => {
dispatch('cancel');
stopRecording();
dispatch('cancel');
}}
>
<svg
+34 -31
View File
@@ -29,8 +29,10 @@
export let continueResponse: Function;
export let regenerateResponse: Function;
export let mergeResponses: Function;
export let chatActionHandler: Function;
export let showMessage: Function = () => {};
export let submitMessage: Function = () => {};
export let readOnly = false;
@@ -79,9 +81,9 @@
element.scrollTop = element.scrollHeight;
};
const updateChatHistory = async () => {
await tick();
const updateChat = async () => {
history = history;
await tick();
await updateChatById(localStorage.token, chatId, {
history: history,
messages: messages
@@ -195,7 +197,7 @@
rating: rating
};
await updateChatHistory();
await updateChat();
};
const editMessage = async (messageId, content, submit = true) => {
@@ -232,7 +234,7 @@
} else {
// Edit user message
history.messages[messageId].content = content;
await updateChatHistory();
await updateChat();
}
} else {
if (submit) {
@@ -246,6 +248,7 @@
id: responseMessageId,
parentId: parentId,
childrenIds: [],
files: undefined,
content: content,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
@@ -261,16 +264,25 @@
];
}
await updateChatHistory();
await updateChat();
} else {
// Edit response message
history.messages[messageId].originalContent = history.messages[messageId].content;
history.messages[messageId].content = content;
await updateChatHistory();
await updateChat();
}
}
};
const actionMessage = async (actionId, message, event = null) => {
await chatActionHandler(chatId, actionId, message.model, message.id, event);
};
const saveMessage = async (messageId, message) => {
history.messages[messageId] = message;
await updateChat();
};
const deleteMessage = async (messageId) => {
const messageToDelete = history.messages[messageId];
const parentMessageId = messageToDelete.parentId;
@@ -306,7 +318,17 @@
showMessage({ id: parentMessageId });
// Update the chat
await updateChatHistory();
await updateChat();
};
const triggerScroll = () => {
if (autoScroll) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
setTimeout(() => {
scrollToBottom();
}, 100);
}
};
</script>
@@ -372,37 +394,18 @@
{user}
{showPreviousMessage}
{showNextMessage}
{updateChat}
{editMessage}
{deleteMessage}
{rateMessage}
{actionMessage}
{saveMessage}
{submitMessage}
{regenerateResponse}
{continueResponse}
{mergeResponses}
{triggerScroll}
{readOnly}
on:submit={async (e) => {
dispatch('submit', e.detail);
}}
on:action={async (e) => {
if (typeof e.detail === 'string') {
await chatActionHandler(chatId, e.detail, message.model, message.id);
} else {
const { id, event } = e.detail;
await chatActionHandler(chatId, id, message.model, message.id, event);
}
}}
on:update={() => {
updateChatHistory();
}}
on:scroll={() => {
if (autoScroll) {
const element = document.getElementById('messages-container');
autoScroll =
element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
setTimeout(() => {
scrollToBottom();
}, 100);
}
}}
/>
{/each}
</div>
+76 -101
View File
@@ -7,9 +7,9 @@
const i18n = getContext('i18n');
export let citations = [];
export let sources = [];
let _citations = [];
let citations = [];
let showPercentage = false;
let showRelevance = true;
@@ -17,8 +17,8 @@
let selectedCitation: any = null;
let isCollapsibleOpen = false;
function calculateShowRelevance(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
function calculateShowRelevance(sources: any[]) {
const distances = sources.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;
@@ -36,25 +36,31 @@
return true;
}
function shouldShowPercentage(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
function shouldShowPercentage(sources: any[]) {
const distances = sources.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];
citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) {
return acc;
}
source.document.forEach((document, index) => {
const metadata = source.metadata?.[index];
const distance = source.distances?.[index];
// Within the same citation there could be multiple documents
const id = metadata?.source ?? 'N/A';
let source = citation?.source;
let _source = source?.source;
if (metadata?.name) {
source = { ...source, name: metadata.name };
_source = { ..._source, name: metadata.name };
}
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id, ...source, url: id };
_source = { ..._source, name: id, url: id };
}
const existingSource = acc.find((item) => item.id === id);
@@ -66,7 +72,7 @@
} else {
acc.push({
id: id,
source: source,
source: _source,
document: [document],
metadata: metadata ? [metadata] : [],
distances: distance !== undefined ? [distance] : undefined
@@ -76,8 +82,8 @@
return acc;
}, []);
showRelevance = calculateShowRelevance(_citations);
showPercentage = shouldShowPercentage(_citations);
showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations);
}
</script>
@@ -88,96 +94,67 @@
{showRelevance}
/>
{#if _citations.length > 0}
<div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
{#if _citations.length <= 3}
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
{#if citations.length > 0}
<div class=" py-0.5 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
{#if citations.length <= 3}
<div class="flex text-xs font-medium">
{#each citations as citation, idx}
<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"
id={`source-${citation.source.name}`}
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 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">
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1 truncate">
<div
class="flex-1 mx-1 line-clamp-1 text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
>
{citation.source.name}
</div>
</button>
</div>
{/each}
{/each}
</div>
{: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"
class="flex items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<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 class="flex text-xs font-medium items-center">
{#each citations.slice(0, 2) as citation, idx}
<button
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
</button>
{#if idx === 0}<span class="mr-1">,</span>
{/if}
</div>
<div class="flex-1 mx-1 line-clamp-1 truncate">
{citation.source.name}
</div>
</button>
{/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>
<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>
{citations.length - 2}
<span>{$i18n.t('more')}</span>
</div>
</div>
@@ -189,27 +166,25 @@
{/if}
</div>
</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 slot="content">
<div class="flex text-xs font-medium">
{#each citations as citation, idx}
<button
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
</button>
</div>
{/if}
<div class="flex-1 mx-1 line-clamp-1 truncate">
{citation.source.name}
</div>
</button>
{/each}
</div>
</div>
@@ -2,6 +2,7 @@
import { getContext, onMount, tick } from 'svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@@ -13,9 +14,9 @@
let mergedDocuments = [];
function calculatePercentage(distance: number) {
if (distance < 0) return 100;
if (distance > 1) return 0;
return Math.round((1 - distance) * 10000) / 100;
if (distance < 0) return 0;
if (distance > 1) return 100;
return Math.round(distance * 10000) / 100;
}
function getRelevanceColor(percentage: number) {
@@ -38,7 +39,9 @@
};
});
if (mergedDocuments.every((doc) => doc.distance !== undefined)) {
mergedDocuments.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
mergedDocuments = mergedDocuments.sort(
(a, b) => (b.distance ?? Infinity) - (a.distance ?? Infinity)
);
}
}
</script>
@@ -89,7 +92,7 @@
<a
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}` : ''}`
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
? document.source.url
: `#`}
@@ -148,9 +151,18 @@
<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">
{document.document}
</pre>
{#if document.metadata?.html}
<iframe
class="w-full border-0 h-auto rounded-none"
sandbox="allow-scripts allow-forms allow-same-origin"
srcdoc={document.document}
title={$i18n.t('Content')}
></iframe>
{:else}
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
{document.document}
</pre>
{/if}
</div>
{#if documentIdx !== mergedDocuments.length - 1}
@@ -7,13 +7,16 @@
import LightBlub from '$lib/components/icons/LightBlub.svelte';
import { chatId, mobile, showArtifacts, showControls, showOverview } from '$lib/stores';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import { stringify } from 'postcss';
export let id;
export let content;
export let model = null;
export let sources = null;
export let save = false;
export let floatingButtons = true;
export let onSourceClick = () => {};
let contentContainerElement;
let buttonsContainerElement;
@@ -129,6 +132,32 @@
{content}
{model}
{save}
sourceIds={(sources ?? []).reduce((acc, s) => {
let ids = [];
s.document.forEach((document, index) => {
const metadata = s.metadata?.[index];
const id = metadata?.source ?? 'N/A';
if (metadata?.name) {
ids.push(metadata.name);
return ids;
}
if (id.startsWith('http://') || id.startsWith('https://')) {
ids.push(id);
} else {
ids.push(s?.source?.name ?? id);
}
return ids;
});
acc = [...acc, ...ids];
// remove duplicates
return acc.filter((item, index) => acc.indexOf(item) === index);
}, [])}
{onSourceClick}
on:update={(e) => {
dispatch('update', e.detail);
}}
@@ -4,9 +4,9 @@
export let content = '';
</script>
<div class="flex my-2 gap-2.5 border px-4 py-3 border-red-800 bg-red-800/30 rounded-lg">
<div class="flex my-2 gap-2.5 border px-4 py-3 border-red-600/10 bg-red-600/10 rounded-lg">
<div class=" self-start mt-0.5">
<Info className="size-5" />
<Info className="size-5 text-red-700 dark:text-red-400" />
</div>
<div class=" self-center text-sm">
@@ -16,6 +16,9 @@
export let model = null;
export let save = false;
export let sourceIds = [];
export let onSourceClick = () => {};
let tokens = [];
const options = {
@@ -28,7 +31,7 @@
$: (async () => {
if (content) {
tokens = marked.lexer(
replaceTokens(processResponseContent(content), model?.name, $user?.name)
replaceTokens(processResponseContent(content), sourceIds, model?.name, $user?.name)
);
}
})();
@@ -39,6 +42,7 @@
{tokens}
{id}
{save}
{onSourceClick}
on:update={(e) => {
dispatch('update', e.detail);
}}
@@ -12,9 +12,11 @@
import Image from '$lib/components/common/Image.svelte';
import KatexRenderer from './KatexRenderer.svelte';
import Source from './Source.svelte';
export let id: string;
export let tokens: Token[];
export let onSourceClick: Function = () => {};
</script>
{#each tokens as token}
@@ -26,13 +28,15 @@
{@html html}
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`}
{:else if token.text.includes(`<source_id`)}
<Source {token} onClick={onSourceClick} />
{:else}
{token.text}
{/if}
{:else if token.type === 'link'}
{#if token.tokens}
<a href={token.href} target="_blank" rel="nofollow" title={token.title}>
<svelte:self id={`${id}-a`} tokens={token.tokens} />
<svelte:self id={`${id}-a`} tokens={token.tokens} {onSourceClick} />
</a>
{:else}
<a href={token.href} target="_blank" rel="nofollow" title={token.title}>{token.text}</a>
@@ -41,11 +45,11 @@
<Image src={token.href} alt={token.text} />
{:else if token.type === 'strong'}
<strong>
<svelte:self id={`${id}-strong`} tokens={token.tokens} />
<svelte:self id={`${id}-strong`} tokens={token.tokens} {onSourceClick} />
</strong>
{:else if token.type === 'em'}
<em>
<svelte:self id={`${id}-em`} tokens={token.tokens} />
<svelte:self id={`${id}-em`} tokens={token.tokens} {onSourceClick} />
</em>
{:else if token.type === 'codespan'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -61,7 +65,7 @@
<br />
{:else if token.type === 'del'}
<del>
<svelte:self id={`${id}-del`} tokens={token.tokens} />
<svelte:self id={`${id}-del`} tokens={token.tokens} {onSourceClick} />
</del>
{:else if token.type === 'inlineKatex'}
{#if token.text}
@@ -1,6 +1,11 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher, onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { marked, type Token } from 'marked';
import { revertSanitizedResponseContent, unescapeHtml } from '$lib/utils';
@@ -10,6 +15,8 @@
import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte';
import KatexRenderer from './KatexRenderer.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
const dispatch = createEventDispatcher();
@@ -18,19 +25,45 @@
export let top = true;
export let save = false;
export let onSourceClick: Function = () => {};
const headerComponent = (depth: number) => {
return 'h' + depth;
};
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
// Create an array for rows that will hold the mapped cell text.
const rows = token.rows.map((row) =>
row.map((cell) => cell.tokens.map((token) => token.text).join(''))
);
// Join the rows using commas (,) as the separator and rows using newline (\n).
const csvContent = rows.map((row) => row.join(',')).join('\n');
// Log rows and CSV content to ensure everything is correct.
console.log(rows);
console.log(csvContent);
// To handle Unicode characters, you need to prefix the data with a BOM:
const bom = '\uFEFF'; // BOM for UTF-8
// Create a new Blob prefixed with the BOM to ensure proper Unicode encoding.
const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=UTF-8' });
// Use FileSaver.js's saveAs function to save the generated CSV file.
saveAs(blob, `table-${id}-${tokenIdx}.csv`);
};
</script>
<!-- {JSON.stringify(tokens)} -->
{#each tokens as token, tokenIdx (tokenIdx)}
{#if token.type === 'hr'}
<hr />
<hr class=" border-gray-50 dark:border-gray-850" />
{:else if token.type === 'heading'}
<svelte:element this={headerComponent(token.depth)}>
<MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} />
<MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} />
</svelte:element>
{:else if token.type === 'code'}
{#if token.raw.includes('```')}
@@ -55,35 +88,70 @@
{token.text}
{/if}
{:else if token.type === 'table'}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full">
<thead>
<tr>
{#each token.header as header, headerIdx}
<th style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}>
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-header-${headerIdx}`}
tokens={header.tokens}
/>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each token.rows as row, rowIdx}
<tr>
{#each row ?? [] as cell, cellIdx}
<td style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`}>
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
tokens={cell.tokens}
/>
</td>
<div class="relative w-full group">
<div class="scrollbar-hidden relative overflow-x-auto max-w-full rounded-lg">
<table
class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none"
>
<tr class="">
{#each token.header as header, headerIdx}
<th
scope="col"
class="!px-3 !py-1.5 cursor-pointer select-none border border-gray-50 dark:border-gray-850"
style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
>
<div class="flex flex-col gap-1.5 text-left">
<div class="flex-shrink-0 break-normal">
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-header-${headerIdx}`}
tokens={header.tokens}
{onSourceClick}
/>
</div>
</div>
</th>
{/each}
</tr>
{/each}
</tbody>
</table>
</thead>
<tbody>
{#each token.rows as row, rowIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
{#each row ?? [] as cell, cellIdx}
<td
class="!px-3 !py-1.5 text-gray-900 dark:text-white w-max border border-gray-50 dark:border-gray-850"
style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`}
>
<div class="flex flex-col break-normal">
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
tokens={cell.tokens}
{onSourceClick}
/>
</div>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<div class=" absolute top-1 right-1.5 z-20 invisible group-hover:visible">
<Tooltip content={$i18n.t('Export to CSV')}>
<button
class="p-1 rounded-lg bg-transparent transition"
on:click={(e) => {
e.stopPropagation();
exportTableToCSVHandler(token, tokenIdx);
}}
>
<ArrowDownTray className=" size-3.5" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
</div>
{:else if token.type === 'blockquote'}
<blockquote>
@@ -140,19 +208,27 @@
></iframe>
{:else if token.type === 'paragraph'}
<p>
<MarkdownInlineTokens id={`${id}-${tokenIdx}-p`} tokens={token.tokens ?? []} />
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-p`}
tokens={token.tokens ?? []}
{onSourceClick}
/>
</p>
{:else if token.type === 'text'}
{#if top}
<p>
{#if token.tokens}
<MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} />
<MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} />
{:else}
{unescapeHtml(token.text)}
{/if}
</p>
{:else if token.tokens}
<MarkdownInlineTokens id={`${id}-${tokenIdx}-p`} tokens={token.tokens ?? []} />
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-p`}
tokens={token.tokens ?? []}
{onSourceClick}
/>
{:else}
{unescapeHtml(token.text)}
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
export let token;
export let onClick: Function = () => {};
let id = '';
function extractDataAttribute(input) {
// Use a regular expression to extract the value of the `data` attribute
const match = input.match(/data="([^"]*)"/);
// Check if a match was found and return the first captured group
return match ? match[1] : null;
}
$: id = extractDataAttribute(token.text);
</script>
<button
class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg"
on:click={() => {
onClick(id);
}}
>
<span class="line-clamp-1">
{id}
</span>
</button>
+16 -53
View File
@@ -22,23 +22,21 @@
export let showPreviousMessage;
export let showNextMessage;
export let updateChat;
export let editMessage;
export let saveMessage;
export let deleteMessage;
export let rateMessage;
export let actionMessage;
export let submitMessage;
export let regenerateResponse;
export let continueResponse;
// MultiResponseMessages
export let mergeResponses;
export let autoScroll = false;
export let triggerScroll;
export let readOnly = false;
onMount(() => {
// console.log('message', idx);
});
</script>
<div
@@ -61,7 +59,7 @@
{showPreviousMessage}
{showNextMessage}
{editMessage}
on:delete={() => deleteMessage(messageId)}
{deleteMessage}
{readOnly}
/>
{:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1}
@@ -73,30 +71,14 @@
siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
{showPreviousMessage}
{showNextMessage}
{updateChat}
{editMessage}
{saveMessage}
{rateMessage}
{actionMessage}
{submitMessage}
{continueResponse}
{regenerateResponse}
on:submit={async (e) => {
dispatch('submit', e.detail);
}}
on:action={async (e) => {
dispatch('action', e.detail);
}}
on:update={async (e) => {
dispatch('update');
}}
on:save={async (e) => {
console.log('save', e);
const message = e.detail;
if (message) {
history.messages[message.id] = message;
dispatch('update');
} else {
dispatch('update');
}
}}
{readOnly}
/>
{:else}
@@ -105,35 +87,16 @@
{chatId}
{messageId}
isLastMessage={messageId === history?.currentId}
{rateMessage}
{updateChat}
{editMessage}
{saveMessage}
{rateMessage}
{actionMessage}
{submitMessage}
{continueResponse}
{regenerateResponse}
{mergeResponses}
on:submit={async (e) => {
dispatch('submit', e.detail);
}}
on:action={async (e) => {
dispatch('action', e.detail);
}}
on:update={async (e) => {
dispatch('update');
}}
on:save={async (e) => {
console.log('save', e);
const message = e.detail;
if (message) {
history.messages[message.id] = message;
dispatch('update');
} else {
dispatch('update');
}
}}
on:change={async () => {
await tick();
dispatch('update');
dispatch('scroll');
}}
{triggerScroll}
{readOnly}
/>
{/if}
@@ -25,13 +25,19 @@
export let isLastMessage;
export let readOnly = false;
export let updateChat: Function;
export let editMessage: Function;
export let saveMessage: Function;
export let rateMessage: Function;
export let actionMessage: Function;
export let submitMessage: Function;
export let continueResponse: Function;
export let regenerateResponse: Function;
export let mergeResponses: Function;
export let triggerScroll: Function;
const dispatch = createEventDispatcher();
let currentMessageId;
@@ -46,7 +52,7 @@
}
}
const showPreviousMessage = (modelIdx) => {
const showPreviousMessage = async (modelIdx) => {
groupedMessageIdsIdx[modelIdx] = Math.max(0, groupedMessageIdsIdx[modelIdx] - 1);
let messageId = groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]];
@@ -60,10 +66,13 @@
}
history.currentId = messageId;
dispatch('change');
await tick();
await updateChat();
triggerScroll();
};
const showNextMessage = (modelIdx) => {
const showNextMessage = async (modelIdx) => {
groupedMessageIdsIdx[modelIdx] = Math.min(
groupedMessageIds[modelIdx].messageIds.length - 1,
groupedMessageIdsIdx[modelIdx] + 1
@@ -80,7 +89,10 @@
}
history.currentId = messageId;
dispatch('change');
await tick();
await updateChat();
triggerScroll();
};
const initHandler = async () => {
@@ -182,7 +194,7 @@
: `border-gray-50 dark:border-gray-850 border-dashed ${
$mobile ? 'min-w-full' : 'min-w-80'
}`} transition-all p-5 rounded-2xl"
on:click={() => {
on:click={async () => {
if (messageId != _messageId) {
let currentMessageId = _messageId;
let messageChildrenIds = history.messages[currentMessageId].childrenIds;
@@ -191,7 +203,10 @@
messageChildrenIds = history.messages[currentMessageId].childrenIds;
}
history.currentId = currentMessageId;
dispatch('change');
await tick();
await updateChat();
triggerScroll();
}
}}
>
@@ -205,8 +220,12 @@
siblings={groupedMessageIds[modelIdx].messageIds}
showPreviousMessage={() => showPreviousMessage(modelIdx)}
showNextMessage={() => showNextMessage(modelIdx)}
{rateMessage}
{updateChat}
{editMessage}
{saveMessage}
{rateMessage}
{actionMessage}
{submitMessage}
{continueResponse}
regenerateResponse={async (message) => {
regenerateResponse(message);
@@ -214,18 +233,6 @@
groupedMessageIdsIdx[modelIdx] =
groupedMessageIds[modelIdx].messageIds.length - 1;
}}
on:submit={async (e) => {
dispatch('submit', e.detail);
}}
on:action={async (e) => {
dispatch('action', e.detail);
}}
on:update={async (e) => {
dispatch('update', e.detail);
}}
on:save={async (e) => {
dispatch('save', e.detail);
}}
{readOnly}
/>
{/if}
@@ -1,11 +1,21 @@
<script lang="ts">
import { settings } from '$lib/stores';
import ProfileImageBase from './ProfileImageBase.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
export let className = 'size-8';
export let src = '';
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
</script>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<ProfileImageBase {src} {className} />
</div>
<img
crossorigin="anonymous"
src={src === ''
? `${WEBUI_BASE_URL}/static/favicon.png`
: src.startsWith(WEBUI_BASE_URL) ||
src.startsWith('https://www.gravatar.com/avatar/') ||
src.startsWith('data:') ||
src.startsWith('/')
? src
: `/user.png`}
class=" {className} object-cover rounded-full -translate-y-[1px]"
alt="profile"
draggable="false"
/>
@@ -1,21 +0,0 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
export let className = 'size-8';
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
</script>
<img
crossorigin="anonymous"
src={src === ''
? `${WEBUI_BASE_URL}/static/favicon.png`
: src.startsWith(WEBUI_BASE_URL) ||
src.startsWith('https://www.gravatar.com/avatar/') ||
src.startsWith('data:') ||
src.startsWith('/')
? src
: `/user.png`}
class=" {className} object-cover rounded-full -translate-y-[1px]"
alt="profile"
draggable="false"
/>
@@ -38,6 +38,7 @@
let selectedReason = null;
let comment = '';
let detailedRating = null;
let selectedModel = null;
$: if (message?.annotation?.rating === 1) {
@@ -56,6 +57,7 @@
tags = (message?.annotation?.tags ?? []).map((tag) => ({
name: tag
}));
detailedRating = message?.annotation?.details?.rating ?? null;
};
onMount(() => {
@@ -79,7 +81,10 @@
dispatch('save', {
reason: selectedReason,
comment: comment,
tags: tags.map((tag) => tag.name)
tags: tags.map((tag) => tag.name),
details: {
rating: detailedRating
}
});
toast.success($i18n.t('Thanks for your feedback!'));
@@ -100,7 +105,9 @@
id="message-feedback-{message.id}"
>
<div class="flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Tell us more:')}</div>
<div class="text-sm font-medium">{$i18n.t('How would you rate this response?')}</div>
<!-- <div class=" text-sm">{$i18n.t('Tell us more:')}</div> -->
<button
on:click={() => {
@@ -120,53 +127,89 @@
</button>
</div>
{#if reasons.length > 0}
<div class="flex flex-wrap gap-1.5 text-sm mt-2.5">
{#each reasons as reason}
<button
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"
on:click={() => {
selectedReason = 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 class="w-full flex justify-center">
<div class=" relative w-fit">
<div class="mt-1.5 w-fit flex gap-1 pb-5">
<!-- 1-10 scale -->
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating}
<button
class="size-7 text-sm border border-gray-50 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
rating
? 'bg-gray-100 dark:bg-gray-800'
: ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white disabled:dark:bg-gray-900"
on:click={() => {
detailedRating = rating;
}}
disabled={message?.annotation?.rating === -1 ? rating > 5 : rating < 6}
>
{rating}
</button>
{/each}
</div>
<div class="absolute bottom-0 left-0 right-0 flex justify-between text-xs">
<div>
1 - {$i18n.t('Awful')}
</div>
<div>
10 - {$i18n.t('Amazing')}
</div>
</div>
</div>
{/if}
</div>
<div>
{#if reasons.length > 0}
<div class="text-sm mt-1.5 font-medium">{$i18n.t('Why?')}</div>
<div class="flex flex-wrap gap-1.5 text-sm mt-1.5">
{#each reasons as reason}
<button
class="px-3 py-0.5 border border-gray-50 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedReason ===
reason
? 'bg-gray-100 dark:bg-gray-800'
: ''} transition rounded-xl"
on:click={() => {
selectedReason = 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>
{/if}
</div>
<div class="mt-2">
<textarea
@@ -195,7 +238,7 @@
</div>
<button
class=" bg-emerald-700 hover:bg-emerald-800 transition text-white text-sm font-medium rounded-xl px-3.5 py-1.5"
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={() => {
saveHandler();
}}
@@ -15,9 +15,6 @@
import {
copyToClipboard as _copyToClipboard,
approximateToHumanReadable,
extractParagraphsForAudio,
extractSentencesForAudio,
cleanText,
getMessageContentParts,
sanitizeResponseContent,
createMessagesList
@@ -33,7 +30,6 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import Markdown from './Markdown.svelte';
import Error from './Error.svelte';
import Citations from './Citations.svelte';
import CodeExecutions from './CodeExecutions.svelte';
@@ -68,7 +64,7 @@
};
done: boolean;
error?: boolean | { content: string };
citations?: string[];
sources?: string[];
code_executions?: {
uuid: string;
name: string;
@@ -112,9 +108,13 @@
export let showPreviousMessage: Function;
export let showNextMessage: Function;
export let updateChat: Function;
export let editMessage: Function;
export let saveMessage: Function;
export let rateMessage: Function;
export let actionMessage: Function;
export let submitMessage: Function;
export let continueResponse: Function;
export let regenerateResponse: Function;
@@ -329,7 +329,10 @@
url: `${image.url}`
}));
dispatch('save', { ...message, files: files });
saveMessage(message.id, {
...message,
files: files
});
}
generatingImage = false;
@@ -337,19 +340,16 @@
let feedbackLoading = false;
const feedbackHandler = async (
rating: number | null = null,
annotation: object | null = null
) => {
const feedbackHandler = async (rating: number | null = null, details: object | null = null) => {
feedbackLoading = true;
console.log('Feedback', rating, annotation);
console.log('Feedback', rating, details);
const updatedMessage = {
...message,
annotation: {
...(message?.annotation ?? {}),
...(rating !== null ? { rating: rating } : {}),
...(annotation ? annotation : {})
...(details ? details : {})
}
};
@@ -422,11 +422,11 @@
}
console.log(updatedMessage);
dispatch('save', updatedMessage);
saveMessage(message.id, updatedMessage);
await tick();
if (!annotation) {
if (!details) {
showRateComment = true;
if (!updatedMessage.annotation?.tags) {
@@ -443,7 +443,7 @@
updatedMessage.annotation.tags = tags;
feedbackItem.data.tags = tags;
dispatch('save', updatedMessage);
saveMessage(message.id, updatedMessage);
await updateFeedbackById(
localStorage.token,
updatedMessage.feedbackId,
@@ -465,7 +465,7 @@
}
onMount(async () => {
console.log('ResponseMessage mounted');
// console.log('ResponseMessage mounted');
await tick();
});
@@ -477,10 +477,13 @@
id="message-{message.id}"
dir={$settings.chatDirection}
>
<ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
/>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8'}
/>
</div>
<div class="flex-auto w-0 pl-1">
<Name>
@@ -514,7 +517,7 @@
{@const status = (
message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]
).at(-1)}
<div class="status-description flex items-center gap-2 pt-0.5 pb-1">
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.done === false}
<div class="">
<Spinner className="size-4" />
@@ -618,9 +621,18 @@
<ContentRenderer
id={message.id}
content={message.content}
sources={message.sources}
floatingButtons={message?.done}
save={!readOnly}
{model}
onSourceClick={(e) => {
console.log(e);
const sourceButton = document.getElementById(`source-${e}`);
if (sourceButton) {
sourceButton.click();
}
}}
on:update={(e) => {
const { raw, oldContent, newContent } = e.detail;
@@ -628,33 +640,30 @@
message.id
].content.replace(raw, raw.replace(oldContent, newContent));
dispatch('update');
updateChat();
}}
on:select={(e) => {
const { type, content } = e.detail;
if (type === 'explain') {
dispatch('submit', {
parentId: message.id,
prompt: `Explain this section to me in more detail\n\n\`\`\`\n${content}\n\`\`\``
});
submitMessage(
message.id,
`Explain this section to me in more detail\n\n\`\`\`\n${content}\n\`\`\``
);
} else if (type === 'ask') {
const input = e.detail?.input ?? '';
dispatch('submit', {
parentId: message.id,
prompt: `\`\`\`\n${content}\n\`\`\`\n${input}`
});
submitMessage(message.id, `\`\`\`\n${content}\n\`\`\`\n${input}`);
}
}}
/>
{/if}
{#if message.error}
{#if message?.error}
<Error content={message?.error?.content ?? message.content} />
{/if}
{#if message.citations}
<Citations citations={message.citations} />
{#if (message?.sources || message?.citations) && (model?.info?.meta?.capabilities?.citations ?? true)}
<Citations sources={message?.sources ?? message?.citations} />
{/if}
{#if message.code_executions}
@@ -726,7 +735,7 @@
{#if message.done}
{#if !readOnly}
{#if $user.role === 'user' ? ($config?.permissions?.chat?.editing ?? true) : true}
{#if $user.role === 'user' ? ($user?.permissions?.chat?.edit ?? true) : true}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="{isLastMessage
@@ -923,82 +932,45 @@
</Tooltip>
{/if}
{#if message.info}
{#if message.usage}
<Tooltip
content={message.info.openai
? message.info.usage
? `<pre>${sanitizeResponseContent(
JSON.stringify(message.info.usage, null, 2)
.replace(/"([^(")"]+)":/g, '$1:')
.slice(1, -1)
.split('\n')
.map((line) => line.slice(2))
.map((line) => (line.endsWith(',') ? line.slice(0, -1) : line))
.join('\n')
)}</pre>`
: `prompt_tokens: ${message.info.prompt_tokens ?? 'N/A'}<br/>
completion_tokens: ${message.info.completion_tokens ?? 'N/A'}<br/>
total_tokens: ${message.info.total_tokens ?? 'N/A'}`
: `response_token/s: ${
`${
Math.round(
((message.info.eval_count ?? 0) /
((message.info.eval_duration ?? 0) / 1000000000)) *
100
) / 100
} tokens` ?? 'N/A'
}<br/>
prompt_token/s: ${
Math.round(
((message.info.prompt_eval_count ?? 0) /
((message.info.prompt_eval_duration ?? 0) / 1000000000)) *
100
) / 100 ?? 'N/A'
} tokens<br/>
total_duration: ${
Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
}ms<br/>
load_duration: ${
Math.round(((message.info.load_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
}ms<br/>
prompt_eval_count: ${message.info.prompt_eval_count ?? 'N/A'}<br/>
prompt_eval_duration: ${
Math.round(((message.info.prompt_eval_duration ?? 0) / 1000000) * 100) / 100 ??
'N/A'
}ms<br/>
eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
eval_duration: ${
Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
}ms<br/>
approximate_total: ${approximateToHumanReadable(message.info.total_duration ?? 0)}`}
placement="top"
content={message.usage
? `<pre>${sanitizeResponseContent(
JSON.stringify(message.usage, null, 2)
.replace(/"([^(")"]+)":/g, '$1:')
.slice(1, -1)
.split('\n')
.map((line) => line.slice(2))
.map((line) => (line.endsWith(',') ? line.slice(0, -1) : line))
.join('\n')
)}</pre>`
: ''}
placement="bottom"
>
<Tooltip content={$i18n.t('Generation Info')} placement="bottom">
<button
class=" {isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
on:click={() => {
console.log(message);
}}
id="info-{message.id}"
<button
class=" {isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
on:click={() => {
console.log(message);
}}
id="info-{message.id}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
</button>
</Tooltip>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
</button>
</Tooltip>
{/if}
@@ -1016,21 +988,6 @@
disabled={feedbackLoading}
on:click={async () => {
await feedbackHandler(1);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'good-response',
data: {
messageId: message.id
}
}
});
});
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
@@ -1067,21 +1024,6 @@
disabled={feedbackLoading}
on:click={async () => {
await feedbackHandler(-1);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'bad-response',
data: {
messageId: message.id
}
}
});
});
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
@@ -1117,20 +1059,6 @@
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
continueResponse();
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'continue-response',
data: {
messageId: message.id
}
}
});
});
}}
>
<svg
@@ -1154,50 +1082,50 @@
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<button
type="button"
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<button
type="button"
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{#each (model?.actions ?? []).filter((action) => !(action?.__webui__ ?? false)) as action}
{#if isLastMessage}
{#each model?.actions ?? [] as action}
<Tooltip content={action.name} placement="bottom">
<button
type="button"
@@ -1205,7 +1133,7 @@
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
dispatch('action', action.id);
actionMessage(action.id, message);
}}
>
{#if action.icon_url}
@@ -1235,26 +1163,8 @@
bind:show={showRateComment}
on:save={async (e) => {
await feedbackHandler(null, {
tags: e.detail.tags,
comment: e.detail.comment,
reason: e.detail.reason
...e.detail
});
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'rate-comment',
data: {
messageId: message.id,
comment: e.detail.comment,
reason: e.detail.reason
}
}
});
});
}}
/>
{/if}
@@ -1,25 +1,21 @@
<script lang="ts">
import dayjs from 'dayjs';
import { toast } from 'svelte-sonner';
import { tick, createEventDispatcher, getContext, onMount } from 'svelte';
import { tick, getContext, onMount } from 'svelte';
import { models, settings } from '$lib/stores';
import { user as _user } from '$lib/stores';
import {
copyToClipboard as _copyToClipboard,
processResponseContent,
replaceTokens
} from '$lib/utils';
import { copyToClipboard as _copyToClipboard } from '$lib/utils';
import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import Markdown from './Markdown.svelte';
import Image from '$lib/components/common/Image.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let user;
export let history;
@@ -31,6 +27,7 @@
export let showNextMessage: Function;
export let editMessage: Function;
export let deleteMessage: Function;
export let isFirstMessage: boolean;
export let readOnly: boolean;
@@ -78,21 +75,25 @@
};
const deleteMessageHandler = async () => {
dispatch('delete', message.id);
deleteMessage(message.id);
};
onMount(() => {
console.log('UserMessage mounted');
// console.log('UserMessage mounted');
});
</script>
<div class=" flex w-full user-message" dir={$settings.chatDirection} id="message-{message.id}">
{#if !($settings?.chatBubble ?? true)}
<ProfileImage
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ?? '/user.png')
: (user?.profile_image_url ?? '/user.png')}
/>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<ProfileImage
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??
'/user.png')
: (user?.profile_image_url ?? '/user.png')}
className={'size-8'}
/>
</div>
{/if}
<div class="flex-auto w-0 max-w-full pl-1">
{#if !($settings?.chatBubble ?? true)}
@@ -124,7 +125,7 @@
{#each message.files as file}
<div class={($settings?.chatBubble ?? true) ? 'self-end' : ''}>
{#if file.type === 'image'}
<img src={file.url} alt="input" class=" max-h-96 rounded-lg" draggable="false" />
<Image src={file.url} imageClassName=" max-h-96 rounded-lg" />
{:else}
<FileItem
item={file}
+1 -3
View File
@@ -5,9 +5,7 @@
import Selector from './ModelSelector/Selector.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { setDefaultModels } from '$lib/apis/configs';
import { updateUserSettings } from '$lib/apis/users';
const i18n = getContext('i18n');
export let selectedModels = [''];
@@ -48,7 +46,7 @@
model: model
}))}
showTemporaryChatControl={$user.role === 'user'
? ($config?.permissions?.chat?.temporary ?? true)
? ($user?.permissions?.chat?.temporary ?? true)
: true}
bind:value={selectedModel}
/>
@@ -55,20 +55,18 @@
let selectedModelIdx = 0;
const fuse = new Fuse(
items
.filter((item) => !item.model?.info?.meta?.hidden)
.map((item) => {
const _item = {
...item,
modelName: item.model?.name,
tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '),
desc: item.model?.info?.meta?.description
};
return _item;
}),
items.map((item) => {
const _item = {
...item,
modelName: item.model?.name,
tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '),
desc: item.model?.info?.meta?.description
};
return _item;
}),
{
keys: ['value', 'tags', 'modelName'],
threshold: 0.3
threshold: 0.4
}
);
@@ -76,7 +74,7 @@
? fuse.search(searchValue).map((e) => {
return e.item;
})
: items.filter((item) => !item.model?.info?.meta?.hidden);
: items;
const pullModelHandler = async () => {
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
@@ -322,12 +320,17 @@
<div class="flex items-center min-w-fit">
<div class="line-clamp-1">
<div class="flex items-center min-w-fit">
<img
src={item.model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
alt="Model"
class="rounded-full size-5 flex items-center mr-2"
/>
{item.label}
<Tooltip
content={$user?.role === 'admin' ? (item?.value ?? '') : ''}
placement="top-start"
>
<img
src={item.model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
alt="Model"
class="rounded-full size-5 flex items-center mr-2"
/>
{item.label}
</Tooltip>
</div>
</div>
{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
@@ -438,14 +441,23 @@
{/each}
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user.role === 'admin'}
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
on:click={() => {
pullModelHandler();
}}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
>
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</button>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
@@ -574,14 +586,3 @@
</slot>
</DropdownMenu.Content>
</DropdownMenu.Root>
<style>
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>
+3 -3
View File
@@ -2,7 +2,7 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import ProfileImageBase from '../Messages/ProfileImageBase.svelte';
import ProfileImage from '../Messages/ProfileImage.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Heart from '$lib/components/icons/Heart.svelte';
@@ -20,7 +20,7 @@
>
{#if data.message.role === 'user'}
<div class="flex w-full">
<ProfileImageBase
<ProfileImage
src={data.user?.profile_image_url ?? '/user.png'}
className={'size-5 -translate-y-[1px]'}
/>
@@ -40,7 +40,7 @@
</div>
{:else}
<div class="flex w-full">
<ProfileImageBase
<ProfileImage
src={data?.model?.info?.meta?.profile_image_url ?? ''}
className={'size-5 -translate-y-[1px]'}
/>
+122 -128
View File
@@ -32,7 +32,7 @@
export let prompt = '';
export let files = [];
export let availableToolIds = [];
export let selectedToolIds = [];
export let webSearchEnabled = false;
@@ -75,7 +75,6 @@
await tick();
};
let mounted = false;
let selectedModelIdx = 0;
$: if (selectedModels.length > 0) {
@@ -84,148 +83,143 @@
$: models = selectedModels.map((id) => $_models.find((m) => m.id === id));
onMount(() => {
mounted = true;
});
onMount(() => {});
</script>
{#key mounted}
<div class="m-auto w-full max-w-6xl px-2 xl:px-20 translate-y-6 py-24 text-center">
{#if $temporaryChatEnabled}
<Tooltip
content="This chat won't appear in history and your messages will not be saved."
className="w-full flex justify-center mb-0.5"
placement="top"
>
<div class="flex items-center gap-2 text-gray-500 font-medium text-lg my-2 w-fit">
<EyeSlash strokeWidth="2.5" className="size-5" /> Temporary Chat
</div>
</Tooltip>
{/if}
<div
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="m-auto w-full max-w-6xl px-2 xl:px-20 translate-y-6 py-24 text-center">
{#if $temporaryChatEnabled}
<Tooltip
content="This chat won't appear in history and your messages will not be saved."
className="w-full flex justify-center mb-0.5"
placement="top"
>
<div class="w-full flex flex-col justify-center items-center">
<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}
<Tooltip
content={(models[modelIdx]?.info?.meta?.tags ?? [])
.map((tag) => tag.name.toUpperCase())
.join(', ')}
placement="top"
>
<button
on:click={() => {
selectedModelIdx = modelIdx;
}}
>
<img
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 sm:size-10 rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo"
draggable="false"
/>
</button>
</Tooltip>
{/each}
</div>
</div>
<div class="flex items-center gap-2 text-gray-500 font-medium text-lg my-2 w-fit">
<EyeSlash strokeWidth="2.5" className="size-5" /> Temporary Chat
</div>
</Tooltip>
{/if}
<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}
</div>
</div>
<div class="flex mt-1 mb-2">
<div in:fade={{ duration: 100, delay: 50 }}>
{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
<div
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-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}
<Tooltip
className=" w-fit"
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
)}
content={(models[modelIdx]?.info?.meta?.tags ?? [])
.map((tag) => tag.name.toUpperCase())
.join(', ')}
placement="top"
>
<div
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
<button
on:click={() => {
selectedModelIdx = modelIdx;
}}
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
)}
</div>
<img
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 sm:size-10 rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo"
draggable="false"
/>
</button>
</Tooltip>
{#if models[selectedModelIdx]?.info?.meta?.user}
<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
By
{#if models[selectedModelIdx]?.info?.meta?.user.community}
<a
href="https://openwebui.com/m/{models[selectedModelIdx]?.info?.meta?.user
.username}"
>{models[selectedModelIdx]?.info?.meta?.user.name
? models[selectedModelIdx]?.info?.meta?.user.name
: `@${models[selectedModelIdx]?.info?.meta?.user.username}`}</a
>
{:else}
{models[selectedModelIdx]?.info?.meta?.user.name}
{/if}
</div>
{/if}
{/if}
{/each}
</div>
</div>
<div
class="text-base font-normal xl:translate-x-6 md:max-w-3xl w-full py-3 {atSelectedModel
? 'mt-2'
: ''}"
>
<MessageInput
{history}
{selectedModels}
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
{availableToolIds}
{transparentBackground}
{stopResponse}
{createMessagePair}
placeholder={$i18n.t('How can I help you today?')}
on:upload={(e) => {
dispatch('upload', e.detail);
}}
on:submit={(e) => {
dispatch('submit', e.detail);
}}
/>
<div class=" 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}
</div>
</div>
</div>
<div class="mx-auto max-w-2xl font-primary" in:fade={{ duration: 200, delay: 200 }}>
<div class="mx-5">
<Suggestions
suggestionPrompts={models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ??
[]}
on:select={(e) => {
selectSuggestionPrompt(e.detail);
<div class="flex mt-1 mb-2">
<div in:fade={{ duration: 100, delay: 50 }}>
{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
<Tooltip
className=" w-fit"
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
)}
placement="top"
>
<div
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
)}
</div>
</Tooltip>
{#if models[selectedModelIdx]?.info?.meta?.user}
<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
By
{#if models[selectedModelIdx]?.info?.meta?.user.community}
<a
href="https://openwebui.com/m/{models[selectedModelIdx]?.info?.meta?.user
.username}"
>{models[selectedModelIdx]?.info?.meta?.user.name
? models[selectedModelIdx]?.info?.meta?.user.name
: `@${models[selectedModelIdx]?.info?.meta?.user.username}`}</a
>
{:else}
{models[selectedModelIdx]?.info?.meta?.user.name}
{/if}
</div>
{/if}
{/if}
</div>
</div>
<div
class="text-base font-normal xl:translate-x-6 md:max-w-3xl w-full py-3 {atSelectedModel
? 'mt-2'
: ''}"
>
<MessageInput
{history}
{selectedModels}
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
{transparentBackground}
{stopResponse}
{createMessagePair}
placeholder={$i18n.t('How can I help you today?')}
on:upload={(e) => {
dispatch('upload', e.detail);
}}
on:submit={(e) => {
dispatch('submit', e.detail);
}}
/>
</div>
</div>
</div>
{/key}
<div class="mx-auto max-w-2xl font-primary" in:fade={{ duration: 200, delay: 200 }}>
<div class="mx-5">
<Suggestions
suggestionPrompts={models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ??
[]}
on:select={(e) => {
selectSuggestionPrompt(e.detail);
}}
/>
</div>
</div>
</div>
@@ -43,7 +43,7 @@
</script>
<div class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
<div class=" space-y-3">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center">
<div>
+85 -85
View File
@@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { user } from '$lib/stores';
import { user, config } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey } from '$lib/apis/auths';
import UpdatePassword from './Account/UpdatePassword.svelte';
@@ -26,7 +26,6 @@
let APIKey = '';
let APIKeyCopied = false;
let profileImageInputElement: HTMLInputElement;
const submitHandler = async () => {
@@ -70,7 +69,7 @@
</script>
<div class="flex flex-col h-full justify-between text-sm">
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[25rem]">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<input
id="profile-image-input"
bind:this={profileImageInputElement}
@@ -301,96 +300,97 @@
</button>
</div>
</div>
<div class="justify-between w-full">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
</div>
{#if $config?.features?.enable_api_key ?? true}
<div class="justify-between w-full">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
</div>
<div class="flex mt-2">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
<div class="flex mt-2">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
on:click={() => {
copyToClipboard(APIKey);
APIKeyCopied = true;
setTimeout(() => {
APIKeyCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
on:click={() => {
copyToClipboard(APIKey);
APIKeyCopied = true;
setTimeout(() => {
APIKeyCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
on:click={() => {
createAPIKeyHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
on:click={() => {
createAPIKeyHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
on:click={() => {
createAPIKeyHandler();
}}
>
<Plus strokeWidth="2" className=" size-3.5" />
<Plus strokeWidth="2" className=" size-3.5" />
{$i18n.t('Create new secret key')}</button
>
{/if}
{$i18n.t('Create new secret key')}</button
>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -98,7 +98,7 @@
dispatch('save');
}}
>
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[25rem]">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>
@@ -97,8 +97,8 @@
};
</script>
<div class="flex flex-col h-full justify-between space-y-3 text-sm max-h-[22rem]">
<div class=" space-y-2">
<div class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class="flex flex-col">
<input
id="chat-import-input"
@@ -55,6 +55,7 @@
mirostat_tau: null,
top_k: null,
top_p: null,
min_p: null,
stop: null,
tfs_z: null,
num_ctx: null,
@@ -156,7 +157,7 @@
</script>
<div class="flex flex-col h-full justify-between text-sm">
<div class=" pr-1.5 overflow-y-scroll max-h-[25rem]">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class="">
<div class=" mb-1 text-sm font-medium">{$i18n.t('WebUI Settings')}</div>
@@ -340,6 +341,7 @@
mirostat_tau: params.mirostat_tau !== null ? params.mirostat_tau : undefined,
top_k: params.top_k !== null ? params.top_k : undefined,
top_p: params.top_p !== null ? params.top_p : undefined,
min_p: params.min_p !== null ? params.min_p : undefined,
tfs_z: params.tfs_z !== null ? params.tfs_z : undefined,
num_ctx: params.num_ctx !== null ? params.num_ctx : undefined,
num_batch: params.num_batch !== null ? params.num_batch : undefined,
+156 -117
View File
@@ -31,11 +31,15 @@
let defaultModelId = '';
let showUsername = false;
let richTextInput = true;
let largeTextAsFile = false;
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;
@@ -71,6 +75,11 @@
saveSettings({ showUpdateToast: showUpdateToast });
};
const toggleShowChangelog = async () => {
showChangelog = !showChangelog;
saveSettings({ showChangelog: showChangelog });
};
const toggleShowUsername = async () => {
showUsername = !showUsername;
saveSettings({ showUsername: showUsername });
@@ -131,6 +140,11 @@
saveSettings({ richTextInput });
};
const toggleLargeTextAsFile = async () => {
largeTextAsFile = !largeTextAsFile;
saveSettings({ largeTextAsFile });
};
const toggleResponseAutoCopy = async () => {
const permission = await navigator.clipboard
.readText()
@@ -174,11 +188,14 @@
showUsername = $settings.showUsername ?? false;
showUpdateToast = $settings.showUpdateToast ?? true;
showChangelog = $settings.showChangelog ?? true;
showEmojiInCall = $settings.showEmojiInCall ?? false;
voiceInterruption = $settings.voiceInterruption ?? false;
richTextInput = $settings.richTextInput ?? true;
largeTextAsFile = $settings.largeTextAsFile ?? false;
landingPageMode = $settings.landingPageMode ?? '';
chatBubble = $settings.chatBubble ?? true;
widescreenMode = $settings.widescreenMode ?? false;
@@ -233,29 +250,7 @@
}}
/>
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[25rem] scrollbar-hidden">
<div class=" space-y-1 mb-3">
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={defaultModelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('UI')}</div>
@@ -383,101 +378,30 @@
</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>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Fluidly stream large external response chunks')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleSplitLargeChunks();
}}
type="button"
>
{#if splitLargeChunks === 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('Scroll to bottom when switching between branches')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
togglesScrollOnBranchChange();
}}
type="button"
>
{#if scrollOnBranchChange === 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 class=" my-1.5 text-sm font-medium">{$i18n.t('Chat')}</div>
<div>
@@ -523,7 +447,7 @@
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Response AutoCopy to Clipboard')}
{$i18n.t('Auto-Copy Response to Clipboard')}
</div>
<button
@@ -542,6 +466,77 @@
</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('Paste Large Text as File')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleLargeTextAsFile();
}}
type="button"
>
{#if largeTextAsFile === 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>
@@ -582,6 +577,50 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Fluidly stream large external response chunks')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleSplitLargeChunks();
}}
type="button"
>
{#if splitLargeChunks === 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('Scroll to bottom when switching between branches')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
togglesScrollOnBranchChange();
}}
type="button"
>
{#if scrollOnBranchChange === 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>
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@
dispatch('save');
}}
>
<div class=" pr-1.5 py-1 overflow-y-scroll max-h-[25rem]">
<div class="py-1 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div class="flex items-center justify-between mb-1">
<Tooltip
+509 -200
View File
@@ -15,11 +15,298 @@
import Chats from './Settings/Chats.svelte';
import User from '../icons/User.svelte';
import Personalization from './Settings/Personalization.svelte';
import SearchInput from '../layout/Sidebar/SearchInput.svelte';
import Search from '../icons/Search.svelte';
const i18n = getContext('i18n');
export let show = false;
interface SettingsTab {
id: string;
title: string;
keywords: string[];
}
const searchData: SettingsTab[] = [
{
id: 'general',
title: 'General',
keywords: [
'general',
'theme',
'language',
'notifications',
'system',
'systemprompt',
'prompt',
'advanced',
'settings',
'defaultsettings',
'configuration',
'systemsettings',
'notificationsettings',
'systempromptconfig',
'languageoptions',
'defaultparameters',
'systemparameters'
]
},
{
id: 'interface',
title: 'Interface',
keywords: [
'defaultmodel',
'selectmodel',
'ui',
'userinterface',
'display',
'layout',
'design',
'landingpage',
'landingpagemode',
'default',
'chat',
'chatbubble',
'chatui',
'username',
'showusername',
'displayusername',
'widescreen',
'widescreenmode',
'fullscreen',
'expandmode',
'chatdirection',
'lefttoright',
'ltr',
'righttoleft',
'rtl',
'notifications',
'toast',
'toastnotifications',
'largechunks',
'streamlargechunks',
'scroll',
'scrollonbranchchange',
'scrollbehavior',
'richtext',
'richtextinput',
'background',
'chatbackground',
'chatbackgroundimage',
'backgroundimage',
'uploadbackground',
'resetbackground',
'titleautogen',
'titleautogeneration',
'autotitle',
'chattags',
'autochattags',
'responseautocopy',
'clipboard',
'location',
'userlocation',
'userlocationaccess',
'haptic',
'hapticfeedback',
'vibration',
'voice',
'voicecontrol',
'voiceinterruption',
'call',
'emojis',
'displayemoji',
'save',
'interfaceoptions',
'interfacecustomization'
]
},
{
id: 'personalization',
title: 'Personalization',
keywords: [
'personalization',
'memory',
'personalize',
'preferences',
'profile',
'personalsettings',
'customsettings',
'userpreferences',
'accountpreferences'
]
},
{
id: 'audio',
title: 'Audio',
keywords: [
'audio',
'sound',
'soundsettings',
'audiocontrol',
'volume',
'speech',
'speechrecognition',
'stt',
'speechtotext',
'tts',
'texttospeech',
'playback',
'playbackspeed',
'voiceplayback',
'speechplayback',
'audiooutput',
'speechengine',
'voicecontrol',
'audioplayback',
'transcription',
'autotranscribe',
'autosend',
'speechsettings',
'audiovoice',
'voiceoptions',
'setvoice',
'nonlocalvoices',
'savesettings',
'audioconfig',
'speechconfig',
'voicerecognition',
'speechsynthesis',
'speechmode',
'voicespeed',
'speechrate',
'speechspeed',
'audioinput',
'audiofeatures',
'voicemodes'
]
},
{
id: 'chats',
title: 'Chats',
keywords: [
'chat',
'messages',
'conversations',
'chatsettings',
'history',
'chathistory',
'messagehistory',
'messagearchive',
'convo',
'chats',
'conversationhistory',
'exportmessages',
'chatactivity'
]
},
{
id: 'account',
title: 'Account',
keywords: [
'account',
'profile',
'security',
'privacy',
'settings',
'login',
'useraccount',
'userdata',
'api',
'apikey',
'userprofile',
'profiledetails',
'accountsettings',
'accountpreferences',
'securitysettings',
'privacysettings'
]
},
{
id: 'admin',
title: 'Admin',
keywords: [
'admin',
'administrator',
'adminsettings',
'adminpanel',
'systemadmin',
'administratoraccess',
'systemcontrol',
'manage',
'management',
'admincontrols',
'adminfeatures',
'usercontrol',
'arenamodel',
'evaluations',
'websearch',
'database',
'pipelines',
'images',
'audio',
'documents',
'rag',
'models',
'ollama',
'openai',
'users'
]
},
{
id: 'about',
title: 'About',
keywords: [
'about',
'info',
'information',
'version',
'documentation',
'help',
'support',
'details',
'aboutus',
'softwareinfo',
'timothyjaeryangbaek',
'openwebui',
'release',
'updates',
'updateinfo',
'versioninfo',
'aboutapp',
'terms',
'termsandconditions',
'contact',
'aboutpage'
]
}
];
let search = '';
let visibleTabs = searchData.map((tab) => tab.id);
let searchDebounceTimeout;
const searchSettings = (query: string): string[] => {
const lowerCaseQuery = query.toLowerCase().trim();
return searchData
.filter(
(tab) =>
tab.title.toLowerCase().includes(lowerCaseQuery) ||
tab.keywords.some((keyword) => keyword.includes(lowerCaseQuery))
)
.map((tab) => tab.id);
};
const searchDebounceHandler = () => {
clearTimeout(searchDebounceTimeout);
searchDebounceTimeout = setTimeout(() => {
visibleTabs = searchSettings(search);
if (visibleTabs.length > 0 && !visibleTabs.includes(selectedTab)) {
selectedTab = visibleTabs[0];
}
}, 100);
};
const saveSettings = async (updated) => {
console.log(updated);
await settings.set({ ...$settings, ...updated });
@@ -65,7 +352,7 @@
}
</script>
<Modal bind:show>
<Modal size="xl" bind:show>
<div class="text-gray-700 dark:text-gray-100">
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>
@@ -88,213 +375,235 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pt-2 pb-4 md:space-x-4">
<div class="flex flex-col md:flex-row w-full px-4 pt-1 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"
class="tabs flex flex-row overflow-x-auto gap-2.5 md:gap-1 md:flex-col flex-1 md:flex-none md:w-40 dark:text-gray-200 text-sm font-medium text-left mb-1 md:mb-0 -translate-y-1"
>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'general'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'general';
}}
>
<div class=" self-center mr-2">
<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="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM13 10a3 3 0 11-6 0 3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
<div class="hidden md:flex w-full rounded-xl -mb-1 px-0.5 gap-2" id="settings-search">
<div class="self-center rounded-l-xl bg-transparent">
<Search className="size-3.5" />
</div>
<div class=" self-center">{$i18n.t('General')}</div>
</button>
<input
class="w-full py-1.5 text-sm bg-transparent dark:text-gray-300 outline-none"
bind:value={search}
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
/>
</div>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'interface'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'interface';
}}
>
<div class=" self-center mr-2">
<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="M2 4.25A2.25 2.25 0 0 1 4.25 2h7.5A2.25 2.25 0 0 1 14 4.25v5.5A2.25 2.25 0 0 1 11.75 12h-1.312c.1.128.21.248.328.36a.75.75 0 0 1 .234.545v.345a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.345a.75.75 0 0 1 .234-.545c.118-.111.228-.232.328-.36H4.25A2.25 2.25 0 0 1 2 9.75v-5.5Zm2.25-.75a.75.75 0 0 0-.75.75v4.5c0 .414.336.75.75.75h7.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-7.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Interface')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'personalization'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'personalization';
}}
>
<div class=" self-center mr-2">
<User />
</div>
<div class=" self-center">{$i18n.t('Personalization')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'audio'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'audio';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.557 2.066A.75.75 0 0 1 8 2.75v10.5a.75.75 0 0 1-1.248.56L3.59 11H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.59l3.162-2.81a.75.75 0 0 1 .805-.124ZM12.95 3.05a.75.75 0 1 0-1.06 1.06 5.5 5.5 0 0 1 0 7.78.75.75 0 1 0 1.06 1.06 7 7 0 0 0 0-9.9Z"
/>
<path
d="M10.828 5.172a.75.75 0 1 0-1.06 1.06 2.5 2.5 0 0 1 0 3.536.75.75 0 1 0 1.06 1.06 4 4 0 0 0 0-5.656Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Audio')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'chats'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'chats';
}}
>
<div class=" self-center mr-2">
<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 2C4.262 2 1 4.57 1 8c0 1.86.98 3.486 2.455 4.566a3.472 3.472 0 0 1-.469 1.26.75.75 0 0 0 .713 1.14 6.961 6.961 0 0 0 3.06-1.06c.403.062.818.094 1.241.094 3.738 0 7-2.57 7-6s-3.262-6-7-6ZM5 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm7-1a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM8 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Chats')}</div>
</button>
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'account'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'account';
}}
>
<div class=" self-center mr-2">
<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="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-5-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 9c-1.825 0-3.422.977-4.295 2.437A5.49 5.49 0 0 0 8 13.5a5.49 5.49 0 0 0 4.294-2.063A4.997 4.997 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Account')}</div>
</button>
{#if $user.role === 'admin'}
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'admin'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={async () => {
await goto('/admin/settings');
show = false;
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
{#if visibleTabs.length > 0}
{#each visibleTabs as tabId (tabId)}
{#if tabId === 'general'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'general';
}}
>
<path
fill-rule="evenodd"
d="M4.5 3.75a3 3 0 0 0-3 3v10.5a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V6.75a3 3 0 0 0-3-3h-15Zm4.125 3a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5Zm-3.873 8.703a4.126 4.126 0 0 1 7.746 0 .75.75 0 0 1-.351.92 7.47 7.47 0 0 1-3.522.877 7.47 7.47 0 0 1-3.522-.877.75.75 0 0 1-.351-.92ZM15 8.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15ZM14.25 12a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H15a.75.75 0 0 1-.75-.75Zm.75 2.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Admin Settings')}</div>
</button>
{/if}
<button
class="px-2.5 py-2 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'about'
? 'bg-gray-100 dark:bg-gray-800'
: ' hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => {
selectedTab = 'about';
}}
>
<div class=" self-center mr-2">
<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="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
<div class=" self-center mr-2">
<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="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM13 10a3 3 0 11-6 0 3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('General')}</div>
</button>
{:else if tabId === 'interface'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'interface'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'interface';
}}
>
<div class=" self-center mr-2">
<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="M2 4.25A2.25 2.25 0 0 1 4.25 2h7.5A2.25 2.25 0 0 1 14 4.25v5.5A2.25 2.25 0 0 1 11.75 12h-1.312c.1.128.21.248.328.36a.75.75 0 0 1 .234.545v.345a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.345a.75.75 0 0 1 .234-.545c.118-.111.228-.232.328-.36H4.25A2.25 2.25 0 0 1 2 9.75v-5.5Zm2.25-.75a.75.75 0 0 0-.75.75v4.5c0 .414.336.75.75.75h7.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-7.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Interface')}</div>
</button>
{:else if tabId === 'personalization'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'personalization'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'personalization';
}}
>
<div class=" self-center mr-2">
<User />
</div>
<div class=" self-center">{$i18n.t('Personalization')}</div>
</button>
{:else if tabId === 'audio'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'audio'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'audio';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.557 2.066A.75.75 0 0 1 8 2.75v10.5a.75.75 0 0 1-1.248.56L3.59 11H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.59l3.162-2.81a.75.75 0 0 1 .805-.124ZM12.95 3.05a.75.75 0 1 0-1.06 1.06 5.5 5.5 0 0 1 0 7.78.75.75 0 1 0 1.06 1.06 7 7 0 0 0 0-9.9Z"
/>
<path
d="M10.828 5.172a.75.75 0 1 0-1.06 1.06 2.5 2.5 0 0 1 0 3.536.75.75 0 1 0 1.06 1.06 4 4 0 0 0 0-5.656Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Audio')}</div>
</button>
{:else if tabId === 'chats'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'chats'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'chats';
}}
>
<div class=" self-center mr-2">
<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 2C4.262 2 1 4.57 1 8c0 1.86.98 3.486 2.455 4.566a3.472 3.472 0 0 1-.469 1.26.75.75 0 0 0 .713 1.14 6.961 6.961 0 0 0 3.06-1.06c.403.062.818.094 1.241.094 3.738 0 7-2.57 7-6s-3.262-6-7-6ZM5 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm7-1a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM8 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Chats')}</div>
</button>
{:else if tabId === 'account'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'account'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'account';
}}
>
<div class=" self-center mr-2">
<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="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-5-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 9c-1.825 0-3.422.977-4.295 2.437A5.49 5.49 0 0 0 8 13.5a5.49 5.49 0 0 0 4.294-2.063A4.997 4.997 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Account')}</div>
</button>
{:else if tabId === 'about'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'about'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'about';
}}
>
<div class=" self-center mr-2">
<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="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('About')}</div>
</button>
{:else if tabId === 'admin'}
{#if $user.role === 'admin'}
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'admin'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={async () => {
await goto('/admin/settings');
show = false;
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.5 3.75a3 3 0 0 0-3 3v10.5a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V6.75a3 3 0 0 0-3-3h-15Zm4.125 3a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5Zm-3.873 8.703a4.126 4.126 0 0 1 7.746 0 .75.75 0 0 1-.351.92 7.47 7.47 0 0 1-3.522.877 7.47 7.47 0 0 1-3.522-.877.75.75 0 0 1-.351-.92ZM15 8.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15ZM14.25 12a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H15a.75.75 0 0 1-.75-.75Zm.75 2.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Admin Settings')}</div>
</button>
{/if}
{/if}
{/each}
{:else}
<div class="text-center text-gray-500 mt-4">
{$i18n.t('No results found')}
</div>
<div class=" self-center">{$i18n.t('About')}</div>
</button>
{/if}
</div>
<div class="flex-1 md:min-h-[28rem]">
<div class="flex-1 md:min-h-[32rem] max-h-[32rem]">
{#if selectedTab === 'general'}
<General
{getModels}
@@ -80,7 +80,7 @@
}
</script>
<Modal bind:show size="sm">
<Modal bind:show size="md">
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-0.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Share Chat')}</div>
+3 -1
View File
@@ -2,6 +2,8 @@
import type { Banner } from '$lib/types';
import { onMount, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
const dispatch = createEventDispatcher();
@@ -81,7 +83,7 @@
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white">
{banner.content}
{@html marked.parse(DOMPurify.sanitize(banner.content))}
</div>
</div>
+11 -5
View File
@@ -6,12 +6,9 @@
import { acceptCompletion } from '@codemirror/autocomplete';
import { indentWithTab } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { indentUnit, LanguageDescription } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
// import { python } from '@codemirror/lang-python';
// import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { onMount, createEventDispatcher, getContext, tick } from 'svelte';
@@ -50,6 +47,15 @@
let editorTheme = new Compartment();
let editorLanguage = new Compartment();
languages.push(
LanguageDescription.of({
name: 'HCL',
extensions: ['hcl', 'tf'],
load() {
return import('codemirror-lang-hcl').then((m) => m.hcl());
}
})
);
const getLang = async () => {
const language = languages.find((l) => l.alias.includes(lang));
return await language?.load();
@@ -101,7 +107,7 @@
const setLanguage = async () => {
const language = await getLang();
if (language) {
if (language && codeEditor) {
codeEditor.dispatch({
effects: editorLanguage.reconfigure(language)
});
+12 -5
View File
@@ -12,6 +12,8 @@
export let cancelLabel = $i18n.t('Cancel');
export let confirmLabel = $i18n.t('Confirm');
export let onConfirm = () => {};
export let input = false;
export let inputPlaceholder = '';
export let inputValue = '';
@@ -29,11 +31,17 @@
if (event.key === 'Enter') {
console.log('Enter');
show = false;
dispatch('confirm', inputValue);
confirmHandler();
}
};
const confirmHandler = async () => {
show = false;
await onConfirm();
dispatch('confirm', inputValue);
};
onMount(() => {
mounted = true;
});
@@ -54,7 +62,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={modalElement}
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-[9999] overflow-hidden overscroll-contain"
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-[99999] overflow-hidden overscroll-contain"
in:fade={{ duration: 10 }}
on:mousedown={() => {
show = false;
@@ -110,8 +118,7 @@
<button
class="bg-gray-900 hover:bg-gray-850 text-gray-100 dark:bg-gray-100 dark:hover:bg-white dark:text-gray-800 font-medium w-full py-2.5 rounded-lg transition"
on:click={() => {
show = false;
dispatch('confirm', inputValue);
confirmHandler();
}}
type="button"
>
+62 -40
View File
@@ -5,6 +5,7 @@
import FileItemModal from './FileItemModal.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Spinner from './Spinner.svelte';
import Tooltip from './Tooltip.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -18,6 +19,7 @@
export let item = null;
export let edit = false;
export let small = false;
export let name: string;
export let type: string;
@@ -31,7 +33,9 @@
{/if}
<button
class="relative group p-1.5 {className} flex items-center {colorClassName} rounded-2xl text-left"
class="relative group p-1.5 {className} flex items-center gap-1 {colorClassName} {small
? 'rounded-xl'
: 'rounded-2xl'} text-left"
type="button"
on:click={async () => {
if (item?.file?.data?.content) {
@@ -49,48 +53,66 @@
dispatch('click');
}}
>
<div class="p-3 bg-black/20 dark:bg-white/10 text-white rounded-xl">
{#if !loading}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class=" size-5"
>
<path
fill-rule="evenodd"
d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
clip-rule="evenodd"
/>
<path
d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
/>
</svg>
{:else}
<Spinner />
{/if}
</div>
<div class="flex flex-col justify-center -space-y-0.5 ml-1 px-2.5 w-full">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1 mb-1">
{name}
</div>
<div class=" flex justify-between text-gray-500 text-xs line-clamp-1">
{#if type === 'file'}
{$i18n.t('File')}
{:else if type === 'doc'}
{$i18n.t('Document')}
{:else if type === 'collection'}
{$i18n.t('Collection')}
{#if !small}
<div class="p-3 bg-black/20 dark:bg-white/10 text-white rounded-xl">
{#if !loading}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class=" size-5"
>
<path
fill-rule="evenodd"
d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
clip-rule="evenodd"
/>
<path
d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
/>
</svg>
{:else}
<span class=" capitalize line-clamp-1">{type}</span>
{/if}
{#if size}
<span class="capitalize">{formatFileSize(size)}</span>
<Spinner />
{/if}
</div>
</div>
{/if}
{#if !small}
<div class="flex flex-col justify-center -space-y-0.5 px-2.5 w-full">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1 mb-1">
{name}
</div>
<div class=" flex justify-between text-gray-500 text-xs line-clamp-1">
{#if type === 'file'}
{$i18n.t('File')}
{:else if type === 'doc'}
{$i18n.t('Document')}
{:else if type === 'collection'}
{$i18n.t('Collection')}
{:else}
<span class=" capitalize line-clamp-1">{type}</span>
{/if}
{#if size}
<span class="capitalize">{formatFileSize(size)}</span>
{/if}
</div>
</div>
{:else}
<Tooltip content={name} className="flex flex-col w-full" placement="top-start">
<div class="flex flex-col justify-center -space-y-0.5 px-2.5 w-full">
<div class=" dark:text-gray-100 text-sm flex justify-between items-center">
{#if loading}
<div class=" shrink-0 mr-2">
<Spinner className="size-4" />
</div>
{/if}
<div class="font-medium line-clamp-1 flex-1">{name}</div>
<div class="text-gray-500 text-xs capitalize shrink-0">{formatFileSize(size)}</div>
</div>
</div>
</Tooltip>
{/if}
{#if dismissible}
<div class=" absolute -top-1 -right-1">

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