Commit ae376ec8 authored by Jun Siang Cheah's avatar Jun Siang Cheah
Browse files

Merge remote-tracking branch 'upstream/dev' into feat/oauth

parents af4f8aa5 1bb7fc7c
...@@ -29,8 +29,24 @@ export const getModels = async (token: string = '') => { ...@@ -29,8 +29,24 @@ export const getModels = async (token: string = '') => {
models = models models = models
.filter((models) => models) .filter((models) => models)
// Sort the models
.sort((a, b) => { .sort((a, b) => {
// Compare case-insensitively // 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 lowerA = a.name.toLowerCase();
const lowerB = b.name.toLowerCase(); const lowerB = b.name.toLowerCase();
...@@ -39,8 +55,8 @@ export const getModels = async (token: string = '') => { ...@@ -39,8 +55,8 @@ export const getModels = async (token: string = '') => {
// If same case-insensitively, sort by original strings, // If same case-insensitively, sort by original strings,
// lowercase will come before uppercase due to ASCII values // lowercase will come before uppercase due to ASCII values
if (a < b) return -1; if (a.name < b.name) return -1;
if (a > b) return 1; if (a.name > b.name) return 1;
return 0; // They are equal return 0; // They are equal
}); });
...@@ -49,6 +65,299 @@ export const getModels = async (token: string = '') => { ...@@ -49,6 +65,299 @@ export const getModels = async (token: string = '') => {
return models; return models;
}; };
type ChatCompletedForm = {
model: string;
messages: string[];
chat_id: string;
};
export const chatCompleted = async (token: string, body: ChatCompletedForm) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/chat/completed`, {
method: 'POST',
headers: {
Accept: 'application/json',
'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);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPipelinesList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/list`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
let pipelines = res?.data ?? [];
return pipelines;
};
export const downloadPipeline = async (token: string, url: string, urlIdx: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
url: url,
urlIdx: urlIdx
})
})
.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;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const deletePipeline = async (token: string, id: string, urlIdx: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
id: id,
urlIdx: urlIdx
})
})
.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;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPipelines = async (token: string, urlIdx?: string) => {
let error = null;
const searchParams = new URLSearchParams();
if (urlIdx !== undefined) {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
let pipelines = res?.data ?? [];
return pipelines;
};
export const getPipelineValves = async (token: string, pipeline_id: string, urlIdx: string) => {
let error = null;
const searchParams = new URLSearchParams();
if (urlIdx !== undefined) {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPipelineValvesSpec = async (token: string, pipeline_id: string, urlIdx: string) => {
let error = null;
const searchParams = new URLSearchParams();
if (urlIdx !== undefined) {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/spec?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updatePipelineValves = async (
token: string = '',
pipeline_id: string,
valves: object,
urlIdx: string
) => {
let error = null;
const searchParams = new URLSearchParams();
if (urlIdx !== undefined) {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(
`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/update?${searchParams.toString()}`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify(valves)
}
)
.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;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getBackendConfig = async () => { export const getBackendConfig = async () => {
let error = null; let error = null;
......
import { OLLAMA_API_BASE_URL } from '$lib/constants'; import { OLLAMA_API_BASE_URL } from '$lib/constants';
import { promptTemplate } from '$lib/utils'; import { titleGenerationTemplate } from '$lib/utils';
export const getOllamaConfig = async (token: string = '') => { export const getOllamaConfig = async (token: string = '') => {
let error = null; let error = null;
...@@ -135,10 +135,10 @@ export const updateOllamaUrls = async (token: string = '', urls: string[]) => { ...@@ -135,10 +135,10 @@ export const updateOllamaUrls = async (token: string = '', urls: string[]) => {
return res.OLLAMA_BASE_URLS; return res.OLLAMA_BASE_URLS;
}; };
export const getOllamaVersion = async (token: string = '') => { export const getOllamaVersion = async (token: string, urlIdx?: number) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version${urlIdx ? `/${urlIdx}` : ''}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
...@@ -212,7 +212,7 @@ export const generateTitle = async ( ...@@ -212,7 +212,7 @@ export const generateTitle = async (
) => { ) => {
let error = null; let error = null;
template = promptTemplate(template, prompt); template = titleGenerationTemplate(template, prompt);
console.log(template); console.log(template);
...@@ -369,42 +369,29 @@ export const generateChatCompletion = async (token: string = '', body: object) = ...@@ -369,42 +369,29 @@ export const generateChatCompletion = async (token: string = '', body: object) =
return [res, controller]; return [res, controller];
}; };
export const cancelOllamaRequest = async (token: string = '', requestId: string) => { export const createModel = async (
token: string,
tagName: string,
content: string,
urlIdx: string | null = null
) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/cancel/${requestId}`, { const res = await fetch(
method: 'GET', `${OLLAMA_API_BASE_URL}/api/create${urlIdx !== null ? `/${urlIdx}` : ''}`,
headers: { {
'Content-Type': 'text/event-stream', method: 'POST',
Authorization: `Bearer ${token}` headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName,
modelfile: content
})
} }
}).catch((err) => { ).catch((err) => {
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const createModel = async (token: string, tagName: string, content: string) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName,
modelfile: content
})
}).catch((err) => {
error = err; error = err;
return null; return null;
}); });
...@@ -461,8 +448,10 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string ...@@ -461,8 +448,10 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
export const pullModel = async (token: string, tagName: string, urlIdx: string | null = null) => { export const pullModel = async (token: string, tagName: string, urlIdx: string | null = null) => {
let error = null; let error = null;
const controller = new AbortController();
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull${urlIdx !== null ? `/${urlIdx}` : ''}`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull${urlIdx !== null ? `/${urlIdx}` : ''}`, {
signal: controller.signal,
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
...@@ -485,7 +474,7 @@ export const pullModel = async (token: string, tagName: string, urlIdx: string | ...@@ -485,7 +474,7 @@ export const pullModel = async (token: string, tagName: string, urlIdx: string |
if (error) { if (error) {
throw error; throw error;
} }
return res; return [res, controller];
}; };
export const downloadModel = async ( export const downloadModel = async (
......
import { OPENAI_API_BASE_URL } from '$lib/constants'; import { OPENAI_API_BASE_URL } from '$lib/constants';
import { promptTemplate } from '$lib/utils'; import { titleGenerationTemplate } from '$lib/utils';
import { type Model, models, settings } from '$lib/stores';
export const getOpenAIConfig = async (token: string = '') => { export const getOpenAIConfig = async (token: string = '') => {
let error = null; let error = null;
...@@ -202,17 +203,20 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => { ...@@ -202,17 +203,20 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
return res.OPENAI_API_KEYS; return res.OPENAI_API_KEYS;
}; };
export const getOpenAIModels = async (token: string = '') => { export const getOpenAIModels = async (token: string, urlIdx?: number) => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models`, { const res = await fetch(
method: 'GET', `${OPENAI_API_BASE_URL}/models${typeof urlIdx === 'number' ? `/${urlIdx}` : ''}`,
headers: { {
Accept: 'application/json', method: 'GET',
'Content-Type': 'application/json', headers: {
...(token && { authorization: `Bearer ${token}` }) Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
} }
}) )
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw await res.json(); if (!res.ok) throw await res.json();
return res.json(); return res.json();
...@@ -226,20 +230,7 @@ export const getOpenAIModels = async (token: string = '') => { ...@@ -226,20 +230,7 @@ export const getOpenAIModels = async (token: string = '') => {
throw error; throw error;
} }
const models = Array.isArray(res) ? res : res?.data ?? null; return res;
return models
? models
.map((model) => ({
id: model.id,
name: model.name ?? model.id,
external: true,
custom_info: model.custom_info
}))
.sort((a, b) => {
return a.name.localeCompare(b.name);
})
: models;
}; };
export const getOpenAIModelsDirect = async ( export const getOpenAIModelsDirect = async (
...@@ -345,11 +336,12 @@ export const generateTitle = async ( ...@@ -345,11 +336,12 @@ export const generateTitle = async (
template: string, template: string,
model: string, model: string,
prompt: string, prompt: string,
chat_id?: string,
url: string = OPENAI_API_BASE_URL url: string = OPENAI_API_BASE_URL
) => { ) => {
let error = null; let error = null;
template = promptTemplate(template, prompt); template = titleGenerationTemplate(template, prompt);
console.log(template); console.log(template);
...@@ -370,7 +362,9 @@ export const generateTitle = async ( ...@@ -370,7 +362,9 @@ export const generateTitle = async (
], ],
stream: false, stream: false,
// Restricting the max tokens to 50 to avoid long titles // Restricting the max tokens to 50 to avoid long titles
max_tokens: 50 max_tokens: 50,
...(chat_id && { chat_id: chat_id }),
title: true
}) })
}) })
.then(async (res) => { .then(async (res) => {
...@@ -391,3 +385,71 @@ export const generateTitle = async ( ...@@ -391,3 +385,71 @@ export const generateTitle = async (
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat'; return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
}; };
export const generateSearchQuery = async (
token: string = '',
model: string,
previousMessages: string[],
prompt: string,
url: string = OPENAI_API_BASE_URL
): Promise<string | undefined> => {
let error = null;
// TODO: Allow users to specify the prompt
// Get the current date in the format "January 20, 2024"
const currentDate = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: '2-digit'
}).format(new Date());
const res = await fetch(`${url}/chat/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
// Few shot prompting
messages: [
{
role: 'assistant',
content: `You are tasked with generating web search queries. Give me an appropriate query to answer my question for google search. Answer with only the query. Today is ${currentDate}.`
},
{
role: 'user',
content: prompt
}
// {
// role: 'user',
// content:
// (previousMessages.length > 0
// ? `Previous Questions:\n${previousMessages.join('\n')}\n\n`
// : '') + `Current Question: ${prompt}`
// }
],
stream: false,
// Restricting the max tokens to 30 to avoid long search queries
max_tokens: 30
})
})
.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 undefined;
});
if (error) {
throw error;
}
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? undefined;
};
...@@ -359,6 +359,32 @@ export const scanDocs = async (token: string) => { ...@@ -359,6 +359,32 @@ export const scanDocs = async (token: string) => {
return res; return res;
}; };
export const resetUploadDir = async (token: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/reset/uploads`, {
method: 'GET',
headers: {
Accept: 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const resetVectorDB = async (token: string) => { export const resetVectorDB = async (token: string) => {
let error = null; let error = null;
...@@ -415,6 +441,7 @@ export const getEmbeddingConfig = async (token: string) => { ...@@ -415,6 +441,7 @@ export const getEmbeddingConfig = async (token: string) => {
type OpenAIConfigForm = { type OpenAIConfigForm = {
key: string; key: string;
url: string; url: string;
batch_size: number;
}; };
type EmbeddingModelUpdateForm = { type EmbeddingModelUpdateForm = {
...@@ -513,3 +540,44 @@ export const updateRerankingConfig = async (token: string, payload: RerankingMod ...@@ -513,3 +540,44 @@ export const updateRerankingConfig = async (token: string, payload: RerankingMod
return res; return res;
}; };
export const runWebSearch = async (
token: string,
query: string,
collection_name?: string
): Promise<SearchDocument | null> => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/web/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
query,
collection_name: collection_name ?? ''
})
})
.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 interface SearchDocument {
status: boolean;
collection_name: string;
filenames: string[];
}
...@@ -8,6 +8,16 @@ type TextStreamUpdate = { ...@@ -8,6 +8,16 @@ type TextStreamUpdate = {
citations?: any; citations?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
error?: any; error?: any;
usage?: ResponseUsage;
};
type ResponseUsage = {
/** Including images and tools if any */
prompt_tokens: number;
/** The tokens generated */
completion_tokens: number;
/** Sum of the above two fields */
total_tokens: number;
}; };
// createOpenAITextStream takes a responseBody with a SSE response, // createOpenAITextStream takes a responseBody with a SSE response,
...@@ -59,7 +69,11 @@ async function* openAIStreamToIterator( ...@@ -59,7 +69,11 @@ async function* openAIStreamToIterator(
continue; continue;
} }
yield { done: false, value: parsedData.choices?.[0]?.delta?.content ?? '' }; yield {
done: false,
value: parsedData.choices?.[0]?.delta?.content ?? '',
usage: parsedData.usage
};
} catch (e) { } catch (e) {
console.error('Error extracting delta from SSE event:', e); console.error('Error extracting delta from SSE event:', e);
} }
......
...@@ -108,3 +108,39 @@ export const downloadDatabase = async (token: string) => { ...@@ -108,3 +108,39 @@ export const downloadDatabase = async (token: string) => {
throw error; throw error;
} }
}; };
export const downloadLiteLLMConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/litellm/config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (response) => {
if (!response.ok) {
throw await response.json();
}
return response.blob();
})
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.yaml';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
};
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
import { downloadDatabase } from '$lib/apis/utils'; import { downloadDatabase, downloadLiteLLMConfig } from '$lib/apis/utils';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { config, user } from '$lib/stores'; import { config, user } from '$lib/stores';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
...@@ -68,10 +68,8 @@ ...@@ -68,10 +68,8 @@
</button> </button>
</div> </div>
<hr class=" dark:border-gray-700 my-1" />
<button <button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition" class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => { on:click={() => {
exportAllUserChats(); exportAllUserChats();
}} }}
...@@ -96,6 +94,41 @@ ...@@ -96,6 +94,41 @@
</div> </div>
</button> </button>
{/if} {/if}
<hr class=" dark:border-gray-850 my-1" />
<div class=" flex w-full justify-between">
<!-- <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div> -->
<button
class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
type="button"
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 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875Zm5.845 17.03a.75.75 0 0 0 1.06 0l3-3a.75.75 0 1 0-1.06-1.06l-1.72 1.72V12a.75.75 0 0 0-1.5 0v4.19l-1.72-1.72a.75.75 0 0 0-1.06 1.06l3 3Z"
clip-rule="evenodd"
/>
<path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">Export LiteLLM config.yaml</div>
</button>
</div>
</div> </div>
</div> </div>
......
...@@ -6,61 +6,44 @@ ...@@ -6,61 +6,44 @@
updateWebhookUrl updateWebhookUrl
} from '$lib/apis'; } from '$lib/apis';
import { import {
getAdminConfig,
getDefaultUserRole, getDefaultUserRole,
getJWTExpiresDuration, getJWTExpiresDuration,
getSignUpEnabledStatus, getSignUpEnabledStatus,
toggleSignUpEnabledStatus, toggleSignUpEnabledStatus,
updateAdminConfig,
updateDefaultUserRole, updateDefaultUserRole,
updateJWTExpiresDuration updateJWTExpiresDuration
} from '$lib/apis/auths'; } from '$lib/apis/auths';
import Switch from '$lib/components/common/Switch.svelte';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let saveHandler: Function; export let saveHandler: Function;
let signUpEnabled = true;
let defaultUserRole = 'pending';
let JWTExpiresIn = '';
let adminConfig = null;
let webhookUrl = ''; let webhookUrl = '';
let communitySharingEnabled = true;
const toggleSignUpEnabled = async () => { const updateHandler = async () => {
signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token);
};
const updateDefaultUserRoleHandler = async (role) => {
defaultUserRole = await updateDefaultUserRole(localStorage.token, role);
};
const updateJWTExpiresDurationHandler = async (duration) => {
JWTExpiresIn = await updateJWTExpiresDuration(localStorage.token, duration);
};
const updateWebhookUrlHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl); webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
}; const res = await updateAdminConfig(localStorage.token, adminConfig);
const toggleCommunitySharingEnabled = async () => { if (res) {
communitySharingEnabled = await toggleCommunitySharingEnabledStatus(localStorage.token); toast.success(i18n.t('Settings updated successfully'));
} else {
toast.error(i18n.t('Failed to update settings'));
}
}; };
onMount(async () => { onMount(async () => {
await Promise.all([ await Promise.all([
(async () => { (async () => {
signUpEnabled = await getSignUpEnabledStatus(localStorage.token); adminConfig = await getAdminConfig(localStorage.token);
})(),
(async () => {
defaultUserRole = await getDefaultUserRole(localStorage.token);
})(),
(async () => {
JWTExpiresIn = await getJWTExpiresDuration(localStorage.token);
})(), })(),
(async () => { (async () => {
webhookUrl = await getWebhookUrl(localStorage.token); webhookUrl = await getWebhookUrl(localStorage.token);
})(),
(async () => {
communitySharingEnabled = await getCommunitySharingEnabledStatus(localStorage.token);
})() })()
]); ]);
}); });
...@@ -69,156 +52,94 @@ ...@@ -69,156 +52,94 @@
<form <form
class="flex flex-col h-full justify-between space-y-3 text-sm" class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
updateJWTExpiresDurationHandler(JWTExpiresIn); updateHandler();
updateWebhookUrlHandler();
saveHandler(); saveHandler();
}} }}
> >
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80"> <div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]">
<div> {#if adminConfig !== null}
<div class=" mb-2 text-sm font-medium">{$i18n.t('General Settings')}</div> <div>
<div class=" mb-3 text-sm font-medium">{$i18n.t('General Settings')}</div>
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div> <div class=" flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleSignUpEnabled();
}}
type="button"
>
{#if signUpEnabled}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Enabled')}</span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
clip-rule="evenodd"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Disabled')}</span>
{/if}
</button>
</div>
<div class=" flex w-full justify-between"> <Switch bind:state={adminConfig.ENABLE_SIGNUP} />
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right"
bind:value={defaultUserRole}
placeholder="Select a theme"
on:change={(e) => {
updateDefaultUserRoleHandler(e.target.value);
}}
>
<option value="pending">{$i18n.t('pending')}</option>
<option value="user">{$i18n.t('user')}</option>
<option value="admin">{$i18n.t('admin')}</option>
</select>
</div> </div>
</div>
<div class=" flex w-full justify-between"> <div class=" my-3 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable Community Sharing')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<button <select
class="p-1 px-3 text-xs flex rounded transition" class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
on:click={() => { bind:value={adminConfig.DEFAULT_USER_ROLE}
toggleCommunitySharingEnabled(); placeholder="Select a role"
}}
type="button"
>
{#if communitySharingEnabled}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path <option value="pending">{$i18n.t('pending')}</option>
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z" <option value="user">{$i18n.t('user')}</option>
/> <option value="admin">{$i18n.t('admin')}</option>
</svg> </select>
<span class="ml-2 self-center">{$i18n.t('Enabled')}</span> </div>
{:else} </div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
clip-rule="evenodd"
/>
</svg>
<span class="ml-2 self-center">{$i18n.t('Disabled')}</span>
{/if}
</button>
</div>
<hr class=" dark:border-gray-700 my-3" /> <hr class=" dark:border-gray-850 my-2" />
<div class=" w-full justify-between"> <div class="my-3 flex w-full items-center justify-between pr-2">
<div class="flex w-full justify-between"> <div class=" self-center text-xs font-medium">
<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div> {$i18n.t('Show Admin Details in Account Pending Overlay')}
</div> </div>
<div class="flex mt-2 space-x-2"> <Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`https://example.com/webhook`}
bind:value={webhookUrl}
/>
</div> </div>
</div>
<hr class=" dark:border-gray-700 my-3" /> <div class="my-3 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable Community Sharing')}</div>
<div class=" w-full justify-between"> <Switch bind:state={adminConfig.ENABLE_COMMUNITY_SHARING} />
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div> </div>
<div class="flex mt-2 space-x-2"> <hr class=" dark:border-gray-850 my-2" />
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" <div class=" w-full justify-between">
type="text" <div class="flex w-full justify-between">
placeholder={`e.g.) "30m","1h", "10d". `} <div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
bind:value={JWTExpiresIn} </div>
/>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={adminConfig.JWT_EXPIRES_IN}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
</div> </div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> <hr class=" dark:border-gray-850 my-2" />
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium" <div class=" w-full justify-between">
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span <div class="flex w-full justify-between">
> <div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`https://example.com/webhook`}
bind:value={webhookUrl}
/>
</div>
</div> </div>
</div> </div>
</div> {/if}
</div> </div>
<div class="flex justify-end pt-3 text-sm font-medium"> <div class="flex justify-end pt-3 text-sm font-medium">
......
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { models } from '$lib/stores';
import { getContext, onMount, tick } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import {
getPipelineValves,
getPipelineValvesSpec,
updatePipelineValves,
getPipelines,
getModels,
getPipelinesList,
downloadPipeline,
deletePipeline
} from '$lib/apis';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n: Writable<i18nType> = getContext('i18n');
export let saveHandler: Function;
let downloading = false;
let PIPELINES_LIST = null;
let selectedPipelinesUrlIdx = '';
let pipelines = null;
let valves = null;
let valves_spec = null;
let selectedPipelineIdx = null;
let pipelineDownloadUrl = '';
const updateHandler = async () => {
const pipeline = pipelines[selectedPipelineIdx];
if (pipeline && (pipeline?.valves ?? false)) {
for (const property in valves_spec.properties) {
if (valves_spec.properties[property]?.type === 'array') {
valves[property] = valves[property].split(',').map((v) => v.trim());
}
}
const res = await updatePipelineValves(
localStorage.token,
pipeline.id,
valves,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
});
if (res) {
toast.success('Valves updated successfully');
setPipelines();
models.set(await getModels(localStorage.token));
saveHandler();
}
} else {
toast.error('No valves to update');
}
};
const getValves = async (idx) => {
valves = null;
valves_spec = null;
valves_spec = await getPipelineValvesSpec(
localStorage.token,
pipelines[idx].id,
selectedPipelinesUrlIdx
);
valves = await getPipelineValves(
localStorage.token,
pipelines[idx].id,
selectedPipelinesUrlIdx
);
for (const property in valves_spec.properties) {
if (valves_spec.properties[property]?.type === 'array') {
valves[property] = valves[property].join(',');
}
}
};
const setPipelines = async () => {
pipelines = null;
valves = null;
valves_spec = null;
if (PIPELINES_LIST.length > 0) {
console.log(selectedPipelinesUrlIdx);
pipelines = await getPipelines(localStorage.token, selectedPipelinesUrlIdx);
if (pipelines.length > 0) {
selectedPipelineIdx = 0;
await getValves(selectedPipelineIdx);
}
} else {
pipelines = [];
}
};
const addPipelineHandler = async () => {
downloading = true;
const res = await downloadPipeline(
localStorage.token,
pipelineDownloadUrl,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success('Pipeline downloaded successfully');
setPipelines();
models.set(await getModels(localStorage.token));
}
downloading = false;
};
const deletePipelineHandler = async () => {
const res = await deletePipeline(
localStorage.token,
pipelines[selectedPipelineIdx].id,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success('Pipeline deleted successfully');
setPipelines();
models.set(await getModels(localStorage.token));
}
};
onMount(async () => {
PIPELINES_LIST = await getPipelinesList(localStorage.token);
console.log(PIPELINES_LIST);
if (PIPELINES_LIST.length > 0) {
selectedPipelinesUrlIdx = PIPELINES_LIST[0]['idx'].toString();
}
await setPipelines();
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
updateHandler();
}}
>
<div class=" pr-1.5 overflow-y-scroll max-h-80 h-full">
{#if PIPELINES_LIST !== null}
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Manage Pipelines')}
</div>
</div>
{#if PIPELINES_LIST.length > 0}
<div class="space-y-1">
<div class="flex gap-2">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={selectedPipelinesUrlIdx}
placeholder={$i18n.t('Select a pipeline url')}
on:change={async () => {
await tick();
await setPipelines();
}}
>
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-700"
>{$i18n.t('Select a pipeline url')}</option
>
{#each PIPELINES_LIST as pipelines, idx}
<option value={pipelines.idx.toString()} class="bg-gray-100 dark:bg-gray-700"
>{pipelines.url}</option
>
{/each}
</select>
</div>
</div>
</div>
<div class=" my-2">
<div class=" mb-2 text-sm font-medium">
{$i18n.t('Install from Github URL')}
</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('Enter Github Raw URL')}
bind:value={pipelineDownloadUrl}
/>
</div>
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => {
addPipelineHandler();
}}
disabled={downloading}
type="button"
>
{#if downloading}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
<div class="mt-2 text-xs text-gray-500">
<span class=" font-semibold dark:text-gray-200">Warning:</span> Pipelines are a plugin
system with arbitrary code execution —
<span class=" font-medium dark:text-gray-400"
>don't fetch random pipelines from sources you don't trust.</span
>
</div>
</div>
<hr class=" dark:border-gray-800 my-3 w-full" />
{#if pipelines !== null}
{#if pipelines.length > 0}
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Pipelines Valves')}
</div>
</div>
<div class="space-y-1">
{#if pipelines.length > 0}
<div class="flex gap-2">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={selectedPipelineIdx}
placeholder={$i18n.t('Select a pipeline')}
on:change={async () => {
await tick();
await getValves(selectedPipelineIdx);
}}
>
{#each pipelines as pipeline, idx}
<option value={idx} class="bg-gray-100 dark:bg-gray-700"
>{pipeline.name} ({pipeline.type ?? 'pipe'})</option
>
{/each}
</select>
</div>
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => {
deletePipelineHandler();
}}
type="button"
>
<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="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<div class="space-y-1">
{#if pipelines[selectedPipelineIdx].valves}
{#if valves}
{#each Object.keys(valves_spec.properties) as property, idx}
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{valves_spec.properties[property].title}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
valves[property] = (valves[property] ?? null) === null ? '' : null;
}}
>
{#if (valves[property] ?? null) === null}
<span class="ml-2 self-center"> {$i18n.t('None')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
{#if (valves[property] ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={valves_spec.properties[property].title}
bind:value={valves[property]}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
{/each}
{:else}
<Spinner className="size-5" />
{/if}
{:else}
<div>No valves</div>
{/if}
</div>
</div>
{:else if pipelines.length === 0}
<div>Pipelines Not Detected</div>
{/if}
{:else}
<div class="flex justify-center">
<div class="my-auto">
<Spinner className="size-4" />
</div>
</div>
{/if}
{/if}
{:else}
<div class="flex justify-center h-full">
<div class="my-auto">
<Spinner className="size-6" />
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
Save
</button>
</div>
</form>
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
import Banners from '$lib/components/admin/Settings/Banners.svelte'; import Banners from '$lib/components/admin/Settings/Banners.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Pipelines from './Settings/Pipelines.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -149,33 +150,65 @@ ...@@ -149,33 +150,65 @@
</div> </div>
<div class=" self-center">{$i18n.t('Banners')}</div> <div class=" self-center">{$i18n.t('Banners')}</div>
</button> </button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'pipelines'
? 'bg-gray-200 dark:bg-gray-700'
: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
on:click={() => {
selectedTab = 'pipelines';
}}
>
<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
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>
</div>
<div class=" self-center">{$i18n.t('Pipelines')}</div>
</button>
</div> </div>
<div class="flex-1 md:min-h-[380px]"> <div class="flex-1 md:min-h-[380px]">
{#if selectedTab === 'general'} {#if selectedTab === 'general'}
<General <General
saveHandler={() => { saveHandler={() => {
show = false;
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));
}} }}
/> />
{:else if selectedTab === 'users'} {:else if selectedTab === 'users'}
<Users <Users
saveHandler={() => { saveHandler={() => {
show = false;
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));
}} }}
/> />
{:else if selectedTab === 'db'} {:else if selectedTab === 'db'}
<Database <Database
saveHandler={() => { saveHandler={() => {
show = false;
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));
}} }}
/> />
{:else if selectedTab === 'banners'} {:else if selectedTab === 'banners'}
<Banners <Banners
saveHandler={() => { saveHandler={() => {
show = false; toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'pipelines'}
<Pipelines
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));
}} }}
/> />
......
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import mermaid from 'mermaid';
import { getContext, onMount, tick } from 'svelte'; import { getContext, onMount, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
...@@ -16,11 +17,18 @@ ...@@ -16,11 +17,18 @@
showSidebar, showSidebar,
tags as _tags, tags as _tags,
WEBUI_NAME, WEBUI_NAME,
banners banners,
user,
socket
} from '$lib/stores'; } from '$lib/stores';
import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils'; import {
convertMessagesToHistory,
copyToClipboard,
promptTemplate,
splitStream
} from '$lib/utils';
import { cancelOllamaRequest, generateChatCompletion } from '$lib/apis/ollama'; import { generateChatCompletion } from '$lib/apis/ollama';
import { import {
addTagById, addTagById,
createNewChat, createNewChat,
...@@ -31,7 +39,11 @@ ...@@ -31,7 +39,11 @@
getTagsById, getTagsById,
updateChatById updateChatById
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import { generateOpenAIChatCompletion, generateTitle } from '$lib/apis/openai'; import {
generateOpenAIChatCompletion,
generateSearchQuery,
generateTitle
} from '$lib/apis/openai';
import MessageInput from '$lib/components/chat/MessageInput.svelte'; import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte'; import Messages from '$lib/components/chat/Messages.svelte';
...@@ -41,8 +53,10 @@ ...@@ -41,8 +53,10 @@
import { queryMemory } from '$lib/apis/memories'; import { queryMemory } from '$lib/apis/memories';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next'; import type { i18n as i18nType } from 'i18next';
import { runWebSearch } from '$lib/apis/rag';
import Banner from '../common/Banner.svelte'; import Banner from '../common/Banner.svelte';
import { getUserSettings } from '$lib/apis/users'; import { getUserSettings } from '$lib/apis/users';
import { chatCompleted } from '$lib/apis';
const i18n: Writable<i18nType> = getContext('i18n'); const i18n: Writable<i18nType> = getContext('i18n');
...@@ -53,13 +67,14 @@ ...@@ -53,13 +67,14 @@
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
let messagesContainerElement: HTMLDivElement; let messagesContainerElement: HTMLDivElement;
let currentRequestId = null;
let showModelSelector = true; let showModelSelector = true;
let selectedModels = ['']; let selectedModels = [''];
let atSelectedModel: Model | undefined; let atSelectedModel: Model | undefined;
let webSearchEnabled = false;
let chat = null; let chat = null;
let tags = []; let tags = [];
...@@ -116,10 +131,6 @@ ...@@ -116,10 +131,6 @@
////////////////////////// //////////////////////////
const initNewChat = async () => { const initNewChat = async () => {
if (currentRequestId !== null) {
await cancelOllamaRequest(localStorage.token, currentRequestId);
currentRequestId = null;
}
window.history.replaceState(history.state, '', `/`); window.history.replaceState(history.state, '', `/`);
await chatId.set(''); await chatId.set('');
...@@ -228,6 +239,58 @@ ...@@ -228,6 +239,58 @@
} }
}; };
const createMessagesList = (responseMessageId) => {
const message = history.messages[responseMessageId];
if (message.parentId) {
return [...createMessagesList(message.parentId), message];
} else {
return [message];
}
};
const chatCompletedHandler = async (modelId, messages) => {
await mermaid.run({
querySelector: '.mermaid'
});
const res = await chatCompleted(localStorage.token, {
model: modelId,
messages: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: m.timestamp
})),
chat_id: $chatId
}).catch((error) => {
console.error(error);
return null;
});
if (res !== null) {
// Update chat history with the new messages
for (const message of res.messages) {
history.messages[message.id] = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
: {}),
...message
};
}
}
};
const getChatEventEmitter = async (modelId: string, chatId: string = '') => {
return setInterval(() => {
$socket?.emit('usage', {
action: 'chat',
model: modelId,
chat_id: chatId
});
}, 1000);
};
////////////////////////// //////////////////////////
// Ollama functions // Ollama functions
////////////////////////// //////////////////////////
...@@ -399,11 +462,21 @@ ...@@ -399,11 +462,21 @@
} }
responseMessage.userContext = userContext; responseMessage.userContext = userContext;
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
if (webSearchEnabled) {
await getWebSearchResults(model.id, parentId, responseMessageId);
}
if (model?.owned_by === 'openai') { if (model?.owned_by === 'openai') {
await sendPromptOpenAI(model, prompt, responseMessageId, _chatId); await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (model) { } else if (model) {
await sendPromptOllama(model, prompt, responseMessageId, _chatId); await sendPromptOllama(model, prompt, responseMessageId, _chatId);
} }
console.log('chatEventEmitter', chatEventEmitter);
if (chatEventEmitter) clearInterval(chatEventEmitter);
} else { } else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId })); toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
} }
...@@ -413,8 +486,80 @@ ...@@ -413,8 +486,80 @@
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
}; };
const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
const responseMessage = history.messages[responseId];
responseMessage.status = {
done: false,
action: 'web_search',
description: $i18n.t('Generating search query')
};
messages = messages;
const prompt = history.messages[parentId].content;
let searchQuery = prompt;
if (prompt.length > 100) {
searchQuery = await generateChatSearchQuery(model, prompt);
if (!searchQuery) {
toast.warning($i18n.t('No search query generated'));
responseMessage.status = {
...responseMessage.status,
done: true,
error: true,
description: 'No search query generated'
};
messages = messages;
return;
}
}
responseMessage.status = {
...responseMessage.status,
description: $i18n.t("Searching the web for '{{searchQuery}}'", { searchQuery })
};
messages = messages;
const results = await runWebSearch(localStorage.token, searchQuery).catch((error) => {
console.log(error);
toast.error(error);
return null;
});
if (results) {
responseMessage.status = {
...responseMessage.status,
done: true,
description: $i18n.t('Searched {{count}} sites', { count: results.filenames.length }),
urls: results.filenames
};
if (responseMessage?.files ?? undefined === undefined) {
responseMessage.files = [];
}
responseMessage.files.push({
collection_name: results.collection_name,
name: searchQuery,
type: 'web_search_results',
urls: results.filenames
});
messages = messages;
} else {
responseMessage.status = {
...responseMessage.status,
done: true,
error: true,
description: 'No search results found'
};
messages = messages;
}
};
const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => { const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
model = model.id; model = model.id;
const responseMessage = history.messages[responseMessageId]; const responseMessage = history.messages[responseMessageId];
// Wait until history/message have been updated // Wait until history/message have been updated
...@@ -427,7 +572,7 @@ ...@@ -427,7 +572,7 @@
$settings.system || (responseMessage?.userContext ?? null) $settings.system || (responseMessage?.userContext ?? null)
? { ? {
role: 'system', role: 'system',
content: `${$settings?.system ?? ''}${ content: `${promptTemplate($settings?.system ?? '', $user.name)}${
responseMessage?.userContext ?? null responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: '' : ''
...@@ -475,7 +620,9 @@ ...@@ -475,7 +620,9 @@
const docs = messages const docs = messages
.filter((message) => message?.files ?? null) .filter((message) => message?.files ?? null)
.map((message) => .map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection') message.files.filter((item) =>
['doc', 'collection', 'web_search_results'].includes(item.type)
)
) )
.flat(1); .flat(1);
...@@ -496,7 +643,8 @@ ...@@ -496,7 +643,8 @@
format: $settings.requestFormat ?? undefined, format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined, keep_alive: $settings.keepAlive ?? undefined,
docs: docs.length > 0 ? docs : undefined, docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0 citations: docs.length > 0,
chat_id: $chatId
}); });
if (res && res.ok) { if (res && res.ok) {
...@@ -515,11 +663,11 @@ ...@@ -515,11 +663,11 @@
if (stopResponseFlag) { if (stopResponseFlag) {
controller.abort('User: Stop Response'); controller.abort('User: Stop Response');
await cancelOllamaRequest(localStorage.token, currentRequestId); } else {
const messages = createMessagesList(responseMessageId);
await chatCompletedHandler(model, messages);
} }
currentRequestId = null;
break; break;
} }
...@@ -540,62 +688,58 @@ ...@@ -540,62 +688,58 @@
throw data; throw data;
} }
if ('id' in data) { if (data.done == false) {
console.log(data); if (responseMessage.content == '' && data.message.content == '\n') {
currentRequestId = data.id; continue;
} else {
if (data.done == false) {
if (responseMessage.content == '' && data.message.content == '\n') {
continue;
} else {
responseMessage.content += data.message.content;
messages = messages;
}
} else { } else {
responseMessage.done = true; responseMessage.content += data.message.content;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null;
responseMessage.info = {
total_duration: data.total_duration,
load_duration: data.load_duration,
sample_count: data.sample_count,
sample_duration: data.sample_duration,
prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages; messages = messages;
}
} else {
responseMessage.done = true;
if ($settings.notificationEnabled && !document.hasFocus()) { if (responseMessage.content == '') {
const notification = new Notification( responseMessage.error = {
selectedModelfile code: 400,
? `${ content: `Oops! No text generated from Ollama, Please try again.`
selectedModelfile.title.charAt(0).toUpperCase() + };
selectedModelfile.title.slice(1) }
}`
: `${model}`, responseMessage.context = data.context ?? null;
{ responseMessage.info = {
body: responseMessage.content, total_duration: data.total_duration,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png` load_duration: data.load_duration,
} sample_count: data.sample_count,
); sample_duration: data.sample_duration,
} prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
if ($settings.responseAutoCopy) { eval_count: data.eval_count,
copyToClipboard(responseMessage.content); eval_duration: data.eval_duration
} };
messages = messages;
if ($settings.responseAutoPlayback) {
await tick(); if ($settings.notificationEnabled && !document.hasFocus()) {
document.getElementById(`speak-button-${responseMessage.id}`)?.click(); const notification = new Notification(
} selectedModelfile
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `${model}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
}
);
}
if ($settings.responseAutoCopy) {
copyToClipboard(responseMessage.content);
}
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
} }
} }
} }
...@@ -629,24 +773,21 @@ ...@@ -629,24 +773,21 @@
console.log(error); console.log(error);
if ('detail' in error) { if ('detail' in error) {
toast.error(error.detail); toast.error(error.detail);
responseMessage.content = error.detail; responseMessage.error = { content: error.detail };
} else { } else {
toast.error(error.error); toast.error(error.error);
responseMessage.content = error.error; responseMessage.error = { content: error.error };
} }
} else { } else {
toast.error( toast.error(
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: 'Ollama' }) $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: 'Ollama' })
); );
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { responseMessage.error = {
provider: 'Ollama' content: $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
}); provider: 'Ollama'
})
};
} }
responseMessage.error = true;
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: 'Ollama'
});
responseMessage.done = true; responseMessage.done = true;
messages = messages; messages = messages;
} }
...@@ -671,7 +812,9 @@ ...@@ -671,7 +812,9 @@
const docs = messages const docs = messages
.filter((message) => message?.files ?? null) .filter((message) => message?.files ?? null)
.map((message) => .map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection') message.files.filter((item) =>
['doc', 'collection', 'web_search_results'].includes(item.type)
)
) )
.flat(1); .flat(1);
...@@ -685,11 +828,17 @@ ...@@ -685,11 +828,17 @@
{ {
model: model.id, model: model.id,
stream: true, stream: true,
stream_options:
model.info?.meta?.capabilities?.usage ?? false
? {
include_usage: true
}
: undefined,
messages: [ messages: [
$settings.system || (responseMessage?.userContext ?? null) $settings.system || (responseMessage?.userContext ?? null)
? { ? {
role: 'system', role: 'system',
content: `${$settings?.system ?? ''}${ content: `${promptTemplate($settings?.system ?? '', $user.name)}${
responseMessage?.userContext ?? null responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: '' : ''
...@@ -741,7 +890,8 @@ ...@@ -741,7 +890,8 @@
frequency_penalty: $settings?.params?.frequency_penalty ?? undefined, frequency_penalty: $settings?.params?.frequency_penalty ?? undefined,
max_tokens: $settings?.params?.max_tokens ?? undefined, max_tokens: $settings?.params?.max_tokens ?? undefined,
docs: docs.length > 0 ? docs : undefined, docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0 citations: docs.length > 0,
chat_id: $chatId
}, },
`${OPENAI_API_BASE_URL}` `${OPENAI_API_BASE_URL}`
); );
...@@ -753,9 +903,10 @@ ...@@ -753,9 +903,10 @@
if (res && res.ok && res.body) { if (res && res.ok && res.body) {
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks); const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
let lastUsage = null;
for await (const update of textStream) { for await (const update of textStream) {
const { value, done, citations, error } = update; const { value, done, citations, error, usage } = update;
if (error) { if (error) {
await handleOpenAIError(error, null, model, responseMessage); await handleOpenAIError(error, null, model, responseMessage);
break; break;
...@@ -766,11 +917,19 @@ ...@@ -766,11 +917,19 @@
if (stopResponseFlag) { if (stopResponseFlag) {
controller.abort('User: Stop Response'); controller.abort('User: Stop Response');
} else {
const messages = createMessagesList(responseMessageId);
await chatCompletedHandler(model.id, messages);
} }
break; break;
} }
if (usage) {
lastUsage = usage;
}
if (citations) { if (citations) {
responseMessage.citations = citations; responseMessage.citations = citations;
continue; continue;
...@@ -783,25 +942,29 @@ ...@@ -783,25 +942,29 @@
messages = messages; messages = messages;
} }
if ($settings.notificationEnabled && !document.hasFocus()) { if (autoScroll) {
const notification = new Notification(`OpenAI ${model}`, { scrollToBottom();
body: responseMessage.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
} }
}
if ($settings.responseAutoCopy) { if ($settings.notificationEnabled && !document.hasFocus()) {
copyToClipboard(responseMessage.content); const notification = new Notification(`OpenAI ${model}`, {
} body: responseMessage.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
if ($settings.responseAutoPlayback) { if ($settings.responseAutoCopy) {
await tick(); copyToClipboard(responseMessage.content);
document.getElementById(`speak-button-${responseMessage.id}`)?.click(); }
}
if (autoScroll) { if ($settings.responseAutoPlayback) {
scrollToBottom(); await tick();
} document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
if (lastUsage) {
responseMessage.info = { ...lastUsage, openai: true };
} }
if ($chatId == _chatId) { if ($chatId == _chatId) {
...@@ -863,13 +1026,14 @@ ...@@ -863,13 +1026,14 @@
errorMessage = innerError.message; errorMessage = innerError.message;
} }
responseMessage.error = true; responseMessage.error = {
responseMessage.content = content:
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model.name ?? model.id provider: model.name ?? model.id
}) + }) +
'\n' + '\n' +
errorMessage; errorMessage
};
responseMessage.done = true; responseMessage.done = true;
messages = messages; messages = messages;
...@@ -907,7 +1071,7 @@ ...@@ -907,7 +1071,7 @@
const model = $models.filter((m) => m.id === responseMessage.model).at(0); const model = $models.filter((m) => m.id === responseMessage.model).at(0);
if (model) { if (model) {
if (model?.external) { if (model?.owned_by === 'openai') {
await sendPromptOpenAI( await sendPromptOpenAI(
model, model,
history.messages[responseMessage.parentId].content, history.messages[responseMessage.parentId].content,
...@@ -932,7 +1096,7 @@ ...@@ -932,7 +1096,7 @@
const model = $models.find((model) => model.id === selectedModels[0]); const model = $models.find((model) => model.id === selectedModels[0]);
const titleModelId = const titleModelId =
model?.external ?? false model?.owned_by === 'openai' ?? false
? $settings?.title?.modelExternal ?? selectedModels[0] ? $settings?.title?.modelExternal ?? selectedModels[0]
: $settings?.title?.model ?? selectedModels[0]; : $settings?.title?.model ?? selectedModels[0];
const titleModel = $models.find((model) => model.id === titleModelId); const titleModel = $models.find((model) => model.id === titleModelId);
...@@ -946,6 +1110,7 @@ ...@@ -946,6 +1110,7 @@
) + ' {{prompt}}', ) + ' {{prompt}}',
titleModelId, titleModelId,
userPrompt, userPrompt,
$chatId,
titleModel?.owned_by === 'openai' ?? false titleModel?.owned_by === 'openai' ?? false
? `${OPENAI_API_BASE_URL}` ? `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1` : `${OLLAMA_API_BASE_URL}/v1`
...@@ -957,6 +1122,29 @@ ...@@ -957,6 +1122,29 @@
} }
}; };
const generateChatSearchQuery = async (modelId: string, prompt: string) => {
const model = $models.find((model) => model.id === modelId);
const taskModelId =
model?.owned_by === 'openai' ?? false
? $settings?.title?.modelExternal ?? modelId
: $settings?.title?.model ?? modelId;
const taskModel = $models.find((model) => model.id === taskModelId);
const previousMessages = messages
.filter((message) => message.role === 'user')
.map((message) => message.content);
return await generateSearchQuery(
localStorage.token,
taskModelId,
previousMessages,
prompt,
taskModel?.owned_by === 'openai' ?? false
? `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1`
);
};
const setChatTitle = async (_chatId, _title) => { const setChatTitle = async (_chatId, _title) => {
if (_chatId === $chatId) { if (_chatId === $chatId) {
title = _title; title = _title;
...@@ -1007,7 +1195,7 @@ ...@@ -1007,7 +1195,7 @@
{#if !chatIdProp || (loaded && chatIdProp)} {#if !chatIdProp || (loaded && chatIdProp)}
<div <div
class="min-h-screen max-h-screen {$showSidebar class="h-screen max-h-[100dvh] {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col" : ''} w-full max-w-full flex flex-col"
> >
...@@ -1020,7 +1208,7 @@ ...@@ -1020,7 +1208,7 @@
{initNewChat} {initNewChat}
/> />
{#if $banners.length > 0 && !$chatId && selectedModels.length <= 1} {#if $banners.length > 0 && messages.length === 0 && !$chatId && selectedModels.length <= 1}
<div <div
class="absolute top-[4.25rem] w-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}" class="absolute top-[4.25rem] w-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}"
> >
...@@ -1074,17 +1262,17 @@ ...@@ -1074,17 +1262,17 @@
/> />
</div> </div>
</div> </div>
<MessageInput
bind:files
bind:prompt
bind:autoScroll
bind:webSearchEnabled
bind:atSelectedModel
{selectedModels}
{messages}
{submitPrompt}
{stopResponse}
/>
</div> </div>
</div> </div>
<MessageInput
bind:files
bind:prompt
bind:autoScroll
bind:atSelectedModel
{selectedModels}
{messages}
{submitPrompt}
{stopResponse}
/>
{/if} {/if}
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { type Model, mobile, settings, showSidebar, models } from '$lib/stores'; import { type Model, mobile, settings, showSidebar, models, config } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
import { import {
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
import Models from './MessageInput/Models.svelte'; import Models from './MessageInput/Models.svelte';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { t } from 'i18next';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -46,8 +48,8 @@ ...@@ -46,8 +48,8 @@
export let files = []; export let files = [];
export let fileUploadEnabled = true;
export let speechRecognitionEnabled = true; export let speechRecognitionEnabled = true;
export let webSearchEnabled = false;
export let prompt = ''; export let prompt = '';
export let messages = []; export let messages = [];
...@@ -438,292 +440,212 @@ ...@@ -438,292 +440,212 @@
</div> </div>
{/if} {/if}
<div class="fixed bottom-0 {$showSidebar ? 'left-0 md:left-[260px]' : 'left-0'} right-0"> <div class="w-full">
<div class="w-full"> <div class=" -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class=" -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center"> <div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
<div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full"> <div class="relative">
<div class="relative"> {#if autoScroll === false && messages.length > 0}
{#if autoScroll === false && messages.length > 0} <div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30"> <button
<button class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full" on:click={() => {
on:click={() => { autoScroll = true;
autoScroll = true; scrollToBottom();
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="w-full relative">
{#if prompt.charAt(0) === '/'}
<Prompts bind:this={promptsElement} bind:prompt />
{:else if prompt.charAt(0) === '#'}
<Documents
bind:this={documentsElement}
bind:prompt
on:youtube={(e) => {
console.log(e);
uploadYoutubeTranscription(e.detail);
}}
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}} }}
on:select={(e) => { >
console.log(e); <svg
files = [ xmlns="http://www.w3.org/2000/svg"
...files, viewBox="0 0 20 20"
{ fill="currentColor"
type: e?.detail?.type ?? 'doc', class="w-5 h-5"
...e.detail, >
upload_status: true <path
} fill-rule="evenodd"
]; d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
}} clip-rule="evenodd"
/> />
{/if} </svg>
</button>
</div>
{/if}
</div>
<Models <div class="w-full relative">
bind:this={modelsElement} {#if prompt.charAt(0) === '/'}
<Prompts bind:this={promptsElement} bind:prompt />
{:else if prompt.charAt(0) === '#'}
<Documents
bind:this={documentsElement}
bind:prompt bind:prompt
bind:user on:youtube={(e) => {
bind:chatInputPlaceholder console.log(e);
{messages} uploadYoutubeTranscription(e.detail);
}}
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}}
on:select={(e) => { on:select={(e) => {
atSelectedModel = e.detail; console.log(e);
chatTextAreaElement?.focus(); files = [
...files,
{
type: e?.detail?.type ?? 'doc',
...e.detail,
upload_status: true
}
];
}} }}
/> />
{/if}
<Models
bind:this={modelsElement}
bind:prompt
bind:user
bind:chatInputPlaceholder
{messages}
on:select={(e) => {
atSelectedModel = e.detail;
chatTextAreaElement?.focus();
}}
/>
{#if atSelectedModel !== undefined} {#if atSelectedModel !== undefined}
<div <div
class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900" class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900"
> >
<div class="flex items-center gap-2 text-sm dark:text-gray-500"> <div class="flex items-center gap-2 text-sm dark:text-gray-500">
<img <img
crossorigin="anonymous" crossorigin="anonymous"
alt="model profile" alt="model profile"
class="size-5 max-w-[28px] object-cover rounded-full" class="size-5 max-w-[28px] object-cover rounded-full"
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
?.profile_image_url ?? ?.profile_image_url ??
($i18n.language === 'dg-DG' ($i18n.language === 'dg-DG'
? `/doge.png` ? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)} : `${WEBUI_BASE_URL}/static/favicon.png`)}
/> />
<div>
Talking to <span class=" font-medium">{atSelectedModel.name}</span>
</div>
</div>
<div> <div>
<button Talking to <span class=" font-medium">{atSelectedModel.name}</span>
class="flex items-center"
on:click={() => {
atSelectedModel = undefined;
}}
>
<XMark />
</button>
</div> </div>
</div> </div>
{/if} <div>
</div> <button
class="flex items-center"
on:click={() => {
atSelectedModel = undefined;
}}
>
<XMark />
</button>
</div>
</div>
{/if}
</div> </div>
</div> </div>
</div>
<div class="bg-white dark:bg-gray-900"> <div class="bg-white dark:bg-gray-900">
<div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0"> <div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
<div class=" pb-2"> <div class=" pb-2">
<input <input
bind:this={filesInputElement} bind:this={filesInputElement}
bind:files={inputFiles} bind:files={inputFiles}
type="file" type="file"
hidden hidden
multiple multiple
on:change={async () => { on:change={async () => {
if (inputFiles && inputFiles.length > 0) { if (inputFiles && inputFiles.length > 0) {
const _inputFiles = Array.from(inputFiles); const _inputFiles = Array.from(inputFiles);
_inputFiles.forEach((file) => { _inputFiles.forEach((file) => {
if ( if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type']) if (visionCapableModels.length === 0) {
) { toast.error($i18n.t('Selected model(s) do not support image inputs'));
if (visionCapableModels.length === 0) { inputFiles = null;
toast.error($i18n.t('Selected model(s) do not support image inputs'));
inputFiles = null;
filesInputElement.value = '';
return;
}
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
inputFiles = null;
filesInputElement.value = '';
};
reader.readAsDataURL(file);
} else if (
SUPPORTED_FILE_TYPE.includes(file['type']) ||
SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
) {
uploadDoc(file);
filesInputElement.value = '';
} else {
toast.error(
$i18n.t(
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
{ file_type: file['type'] }
)
);
uploadDoc(file);
filesInputElement.value = ''; filesInputElement.value = '';
return;
} }
}); let reader = new FileReader();
} else { reader.onload = (event) => {
toast.error($i18n.t(`File not found.`)); files = [
} ...files,
}} {
/> type: 'image',
<form url: `${event.target.result}`
dir={$settings?.chatDirection ?? 'LTR'} }
class=" flex flex-col relative w-full rounded-3xl px-1.5 bg-gray-50 dark:bg-gray-850 dark:text-gray-100" ];
on:submit|preventDefault={() => { inputFiles = null;
// check if selectedModels support image input filesInputElement.value = '';
submitPrompt(prompt, user); };
}} reader.readAsDataURL(file);
> } else if (
{#if files.length > 0} SUPPORTED_FILE_TYPE.includes(file['type']) ||
<div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2"> SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
{#each files as file, fileIdx} ) {
<div class=" relative group"> uploadDoc(file);
{#if file.type === 'image'} filesInputElement.value = '';
<div class="relative"> } else {
<img toast.error(
src={file.url} $i18n.t(
alt="input" `Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
class=" h-16 w-16 rounded-xl object-cover" { file_type: file['type'] }
/> )
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length} );
<Tooltip uploadDoc(file);
className=" absolute top-1 left-1" filesInputElement.value = '';
content={$i18n.t('{{ models }}', { }
models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)] });
.filter((id) => !visionCapableModels.includes(id)) } else {
.join(', ') toast.error($i18n.t(`File not found.`));
})} }
}}
/>
<form
dir={$settings?.chatDirection ?? 'LTR'}
class=" flex flex-col relative w-full rounded-3xl px-1.5 bg-gray-50 dark:bg-gray-850 dark:text-gray-100"
on:submit|preventDefault={() => {
// check if selectedModels support image input
submitPrompt(prompt, user);
}}
>
{#if files.length > 0}
<div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
<div class=" relative group">
{#if file.type === 'image'}
<div class="relative">
<img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" />
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
<Tooltip
className=" absolute top-1 left-1"
content={$i18n.t('{{ models }}', {
models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)]
.filter((id) => !visionCapableModels.includes(id))
.join(', ')
})}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4 fill-yellow-300"
> >
<svg <path
xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd"
viewBox="0 0 24 24" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
fill="currentColor" clip-rule="evenodd"
class="size-4 fill-yellow-300" />
> </svg>
<path </Tooltip>
fill-rule="evenodd" {/if}
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" </div>
clip-rule="evenodd" {:else if file.type === 'doc'}
/> <div
</svg> class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
</Tooltip> >
{/if} <div class="p-2.5 bg-red-400 text-white rounded-lg">
</div> {#if file.upload_status}
{:else if file.type === 'doc'}
<div
class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
>
<div class="p-2.5 bg-red-400 text-white rounded-lg">
{#if file.upload_status}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6 h-6"
>
<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}
<svg
class=" w-6 h-6 translate-y-[0.5px]"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
r="2.5"
/><circle
class="spinner_qM83 spinner_ZTLf"
cx="20"
cy="12"
r="2.5"
/></svg
>
{/if}
</div>
<div class="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file.name}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
</div>
</div>
{:else if file.type === 'collection'}
<div
class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
>
<div class="p-2.5 bg-red-400 text-white rounded-lg">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -731,364 +653,443 @@ ...@@ -731,364 +653,443 @@
class="w-6 h-6" class="w-6 h-6"
> >
<path <path
d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z" 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 <path
d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z" 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> </svg>
</div> {:else}
<svg
<div class="flex flex-col justify-center -space-y-0.5"> class=" w-6 h-6 translate-y-[0.5px]"
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1"> fill="currentColor"
{file?.title ?? `#${file.name}`} viewBox="0 0 24 24"
</div> xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
r="2.5"
/><circle
class="spinner_qM83 spinner_ZTLf"
cx="20"
cy="12"
r="2.5"
/></svg
>
{/if}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div> <div class="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file.name}
</div> </div>
<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
</div> </div>
{/if} </div>
{:else if file.type === 'collection'}
<div class=" absolute -top-1 -right-1"> <div
<button class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition" >
type="button" <div class="p-2.5 bg-red-400 text-white rounded-lg">
on:click={() => {
files.splice(fileIdx, 1);
files = files;
}}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
class="w-4 h-4" class="w-6 h-6"
> >
<path <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" d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z"
/>
<path
d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
/> />
</svg> </svg>
</button> </div>
<div class="flex flex-col justify-center -space-y-0.5">
<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
{file?.title ?? `#${file.name}`}
</div>
<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
</div>
</div> </div>
</div> {/if}
{/each}
</div>
{/if}
<div class=" flex"> <div class=" absolute -top-1 -right-1">
{#if fileUploadEnabled}
<div class=" self-end mb-2 ml-1">
<Tooltip content={$i18n.t('Upload files')}>
<button <button
class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5" class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
type="button" type="button"
on:click={() => { on:click={() => {
filesInputElement.click(); files.splice(fileIdx, 1);
files = files;
}} }}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
class="w-[1.2rem] h-[1.2rem]" class="w-4 h-4"
> >
<path <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" 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> </svg>
</button> </button>
</Tooltip> </div>
</div> </div>
{/if} {/each}
</div>
{/if}
<textarea <div class=" flex">
id="chat-textarea" <div class=" ml-1 self-end mb-2 flex space-x-1">
bind:this={chatTextAreaElement} <InputMenu
class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled bind:webSearchEnabled
? '' uploadFilesHandler={() => {
: ' pl-4'} rounded-xl resize-none h-[48px]" filesInputElement.click();
placeholder={chatInputPlaceholder !== '' }}
? chatInputPlaceholder onClose={async () => {
: isRecording await tick();
? $i18n.t('Listening...') chatTextAreaElement?.focus();
: $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
if (e.keyCode == 13 && !e.shiftKey) {
e.preventDefault();
}
if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) {
submitPrompt(prompt, user);
}
}
}} }}
on:keydown={async (e) => { >
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac <button
class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-none focus:outline-none"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<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>
</InputMenu>
</div>
// Check if Ctrl + R is pressed <textarea
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { id="chat-textarea"
bind:this={chatTextAreaElement}
class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-3 rounded-xl resize-none h-[48px]"
placeholder={chatInputPlaceholder !== ''
? chatInputPlaceholder
: isRecording
? $i18n.t('Listening...')
: $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
console.log('regenerate'); }
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
regenerateButton?.click(); // Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
submitPrompt(prompt, user);
} }
}
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
if (prompt === '' && e.key == 'ArrowUp') { // Check if Ctrl + R is pressed
e.preventDefault(); if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
const userMessageElement = [ const regenerateButton = [
...document.getElementsByClassName('user-message') ...document.getElementsByClassName('regenerate-response-button')
]?.at(-1); ]?.at(-1);
const editButton = [ regenerateButton?.click();
...document.getElementsByClassName('edit-user-message-button') }
]?.at(-1);
console.log(userMessageElement); if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
userMessageElement.scrollIntoView({ block: 'center' }); const userMessageElement = [
editButton?.click(); ...document.getElementsByClassName('user-message')
} ]?.at(-1);
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') { const editButton = [
e.preventDefault(); ...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
(promptsElement || documentsElement || modelsElement).selectUp(); console.log(userMessageElement);
const commandOptionButton = [ userMessageElement.scrollIntoView({ block: 'center' });
...document.getElementsByClassName('selected-command-option-button') editButton?.click();
]?.at(-1); }
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
(promptsElement || documentsElement || modelsElement).selectDown(); (promptsElement || documentsElement || modelsElement).selectUp();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' }); commandOptionButton.scrollIntoView({ block: 'center' });
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ (promptsElement || documentsElement || modelsElement).selectDown();
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (commandOptionButton) { const commandOptionButton = [
commandOptionButton?.click(); ...document.getElementsByClassName('selected-command-option-button')
} else { ]?.at(-1);
document.getElementById('send-message-button')?.click(); commandOptionButton.scrollIntoView({ block: 'center' });
} }
}
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1); ]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click(); commandOptionButton?.click();
} else if (e.key === 'Tab') { } else {
const words = findWordIndices(prompt); document.getElementById('send-message-button')?.click();
}
}
if (words.length > 0) { if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
const word = words.at(0); e.preventDefault();
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1); const commandOptionButton = [
await tick(); ...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
e.target.scrollTop = e.target.scrollHeight; commandOptionButton?.click();
prompt = fullPrompt; } else if (e.key === 'Tab') {
await tick(); const words = findWordIndices(prompt);
e.preventDefault(); if (words.length > 0) {
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1); const word = words.at(0);
} const fullPrompt = prompt;
e.target.style.height = ''; prompt = prompt.substring(0, word?.endIndex + 1);
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; await tick();
}
if (e.key === 'Escape') { e.target.scrollTop = e.target.scrollHeight;
console.log('Escape'); prompt = fullPrompt;
atSelectedModel = undefined; await tick();
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
} }
}}
rows="1"
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={(e) => {
e.target.style.height = ''; e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}} }
on:paste={(e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob); if (e.key === 'Escape') {
} console.log('Escape');
atSelectedModel = undefined;
}
}}
rows="1"
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
on:paste={(e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
} }
} }
}} }
/> }}
/>
<div class="self-end mb-2 flex space-x-1 mr-1"> <div class="self-end mb-2 flex space-x-1 mr-1">
{#if messages.length == 0 || messages.at(-1).done == true} {#if messages.length == 0 || messages.at(-1).done == true}
<Tooltip content={$i18n.t('Record voice')}> <Tooltip content={$i18n.t('Record voice')}>
{#if speechRecognitionEnabled} {#if speechRecognitionEnabled}
<button <button
id="voice-input-button" id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center" class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
type="button" type="button"
on:click={() => { on:click={() => {
speechRecognitionHandler(); speechRecognitionHandler();
}} }}
> >
{#if isRecording} {#if isRecording}
<svg <svg
class=" w-5 h-5 translate-y-[0.5px]" class=" w-5 h-5 translate-y-[0.5px]"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
><style> ><style>
.spinner_qM83 { .spinner_qM83 {
animation: spinner_8HQG 1.05s infinite; animation: spinner_8HQG 1.05s infinite;
} }
.spinner_oXPr { .spinner_oXPr {
animation-delay: 0.1s; animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
} }
.spinner_ZTLf { 28.57% {
animation-delay: 0.2s; animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
} }
@keyframes spinner_8HQG { 100% {
0%, transform: translate(0);
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
} }
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle }
class="spinner_qM83 spinner_oXPr" </style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
cx="12" class="spinner_qM83 spinner_oXPr"
cy="12" cx="12"
r="2.5" cy="12"
/><circle r="2.5"
class="spinner_qM83 spinner_ZTLf" /><circle
cx="20" class="spinner_qM83 spinner_ZTLf"
cy="12" cx="20"
r="2.5" cy="12"
/></svg r="2.5"
> /></svg
{:else} >
<svg {:else}
xmlns="http://www.w3.org/2000/svg" <svg
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 20 20"
class="w-5 h-5 translate-y-[0.5px]" fill="currentColor"
> class="w-5 h-5 translate-y-[0.5px]"
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" /> >
<path <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z" <path
/> d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
</svg> />
{/if} </svg>
</button> {/if}
{/if}
</Tooltip>
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === ''}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
</Tooltip> {/if}
{:else} </Tooltip>
<Tooltip content={$i18n.t('Send message')}>
<button <button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5" id="send-message-button"
on:click={stopResponse} class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === ''}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 16 16"
fill="currentColor" fill="currentColor"
class="w-5 h-5" class="w-5 h-5"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z" d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
</button> </button>
{/if} </Tooltip>
</div> {:else}
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={stopResponse}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div> </div>
</form>
<div class="mt-1.5 text-xs text-gray-500 text-center">
{$i18n.t('LLMs can make mistakes. Verify important information.')}
</div> </div>
</form>
<div class="mt-1.5 text-xs text-gray-500 text-center">
{$i18n.t('LLMs can make mistakes. Verify important information.')}
</div> </div>
</div> </div>
</div> </div>
......
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import 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';
const i18n = getContext('i18n');
export let uploadFilesHandler: Function;
export let webSearchEnabled: boolean;
export let onClose: Function;
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[190px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
sideOffset={15}
alignOffset={-8}
side="top"
align="start"
transition={flyAndScale}
>
{#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"
>
<div class="flex-1 flex items-center gap-2">
<GlobeAltSolid />
<div class="flex items-center">{$i18n.t('Web Search')}</div>
</div>
<Switch bind:state={webSearchEnabled} />
</div>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
>
<DocumentArrowUpSolid />
<div class="flex items-center">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { chats, config, settings, user as _user, mobile } from '$lib/stores'; import { chats, config, settings, user as _user, mobile } from '$lib/stores';
import { tick, getContext } from 'svelte'; import { tick, getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { getChatList, updateChatById } from '$lib/apis/chats'; import { getChatList, updateChatById } from '$lib/apis/chats';
...@@ -242,7 +241,7 @@ ...@@ -242,7 +241,7 @@
}; };
</script> </script>
<div class="h-full flex mb-16"> <div class="h-full flex">
{#if messages.length == 0} {#if messages.length == 0}
<Placeholder <Placeholder
modelIds={selectedModels} modelIds={selectedModels}
...@@ -285,9 +284,9 @@ ...@@ -285,9 +284,9 @@
<div class="w-full pt-2"> <div class="w-full pt-2">
{#key chatId} {#key chatId}
{#each messages as message, messageIdx} {#each messages as message, messageIdx}
<div class=" w-full {messageIdx === messages.length - 1 ? 'pb-28' : ''}"> <div class=" w-full {messageIdx === messages.length - 1 ? ' pb-12' : ''}">
<div <div
class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null class="flex flex-col justify-between px-5 mb-3 {$settings?.widescreenMode ?? null
? 'max-w-full' ? 'max-w-full'
: 'max-w-5xl'} mx-auto rounded-lg group" : 'max-w-5xl'} mx-auto rounded-lg group"
> >
...@@ -340,6 +339,7 @@ ...@@ -340,6 +339,7 @@
<CompareMessages <CompareMessages
bind:history bind:history
{messages} {messages}
{readOnly}
{chatId} {chatId}
parentMessage={history.messages[message.parentId]} parentMessage={history.messages[message.parentId]}
{messageIdx} {messageIdx}
......
...@@ -215,7 +215,7 @@ __builtins__.input = input`); ...@@ -215,7 +215,7 @@ __builtins__.input = input`);
<div class="p-1">{@html lang}</div> <div class="p-1">{@html lang}</div>
<div class="flex items-center"> <div class="flex items-center">
{#if lang === 'python' || (lang === '' && checkPythonCode(code))} {#if lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code))}
{#if executing} {#if executing}
<div class="copy-code-button bg-none border-none p-1 cursor-not-allowed">Running</div> <div class="copy-code-button bg-none border-none p-1 cursor-not-allowed">Running</div>
{:else} {:else}
......
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
export let parentMessage; export let parentMessage;
export let readOnly = false;
export let updateChatMessages: Function; export let updateChatMessages: Function;
export let confirmEditResponseMessage: Function; export let confirmEditResponseMessage: Function;
export let rateMessage: Function; export let rateMessage: Function;
...@@ -134,6 +136,7 @@ ...@@ -134,6 +136,7 @@
{confirmEditResponseMessage} {confirmEditResponseMessage}
showPreviousMessage={() => showPreviousMessage(model)} showPreviousMessage={() => showPreviousMessage(model)}
showNextMessage={() => showNextMessage(model)} showNextMessage={() => showNextMessage(model)}
{readOnly}
{rateMessage} {rateMessage}
{copyToClipboard} {copyToClipboard}
{continueGeneration} {continueGeneration}
......
...@@ -64,7 +64,7 @@ ...@@ -64,7 +64,7 @@
</div> </div>
<div in:fade={{ duration: 200, delay: 200 }}> <div in:fade={{ duration: 200, delay: 200 }}>
{#if models[selectedModelIdx]?.info} {#if models[selectedModelIdx]?.info?.meta?.description ?? null}
<div class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3"> <div class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3">
{models[selectedModelIdx]?.info?.meta?.description} {models[selectedModelIdx]?.info?.meta?.description}
</div> </div>
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import tippy from 'tippy.js'; import tippy from 'tippy.js';
import auto_render from 'katex/dist/contrib/auto-render.mjs'; import auto_render from 'katex/dist/contrib/auto-render.mjs';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import mermaid from 'mermaid';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
...@@ -33,6 +34,8 @@ ...@@ -33,6 +34,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import RateComment from './RateComment.svelte'; import RateComment from './RateComment.svelte';
import CitationsModal from '$lib/components/chat/Messages/CitationsModal.svelte'; import CitationsModal from '$lib/components/chat/Messages/CitationsModal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
export let message; export let message;
export let siblings; export let siblings;
...@@ -106,8 +109,13 @@ ...@@ -106,8 +109,13 @@
renderLatex(); renderLatex();
if (message.info) { if (message.info) {
tooltipInstance = tippy(`#info-${message.id}`, { let tooltipContent = '';
content: `<span class="text-xs" id="tooltip-${message.id}">response_token/s: ${ if (message.info.openai) {
tooltipContent = `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'}`;
} else {
tooltipContent = `response_token/s: ${
`${ `${
Math.round( Math.round(
((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100 ((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
...@@ -137,9 +145,10 @@ ...@@ -137,9 +145,10 @@
eval_duration: ${ eval_duration: ${
Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A' Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
}ms<br/> }ms<br/>
approximate_total: ${approximateToHumanReadable( approximate_total: ${approximateToHumanReadable(message.info.total_duration)}`;
message.info.total_duration }
)}</span>`, tooltipInstance = tippy(`#info-${message.id}`, {
content: `<span class="text-xs" id="tooltip-${message.id}">${tooltipContent}</span>`,
allowHTML: true allowHTML: true
}); });
} }
...@@ -332,9 +341,24 @@ ...@@ -332,9 +341,24 @@
generatingImage = false; generatingImage = false;
}; };
$: if (!edit) {
(async () => {
await tick();
renderStyling();
await mermaid.run({
querySelector: '.mermaid'
});
})();
}
onMount(async () => { onMount(async () => {
await tick(); await tick();
renderStyling(); renderStyling();
await mermaid.run({
querySelector: '.mermaid'
});
}); });
</script> </script>
...@@ -364,7 +388,7 @@ ...@@ -364,7 +388,7 @@
{/if} {/if}
</Name> </Name>
{#if message.files} {#if (message?.files ?? []).filter((f) => f.type === 'image').length > 0}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"> <div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.files as file} {#each message.files as file}
<div> <div>
...@@ -377,9 +401,35 @@ ...@@ -377,9 +401,35 @@
{/if} {/if}
<div <div
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-ol:p-0 prose-li:-mb-4 whitespace-pre-line" class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-headings:-mb-4 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-ol:p-0 prose-li:-mb-4 whitespace-pre-line"
> >
<div> <div>
{#if message?.status}
<div class="flex items-center gap-2 pt-1 pb-1">
{#if message?.status?.done === false}
<div class="">
<Spinner className="size-4" />
</div>
{/if}
{#if message?.status?.action === 'web_search' && message?.status?.urls}
<WebSearchResults urls={message?.status?.urls}>
<div class="flex flex-col justify-center -space-y-0.5">
<div class="text-base line-clamp-1 text-wrap">
{message.status.description}
</div>
</div>
</WebSearchResults>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div class=" text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap">
{message.status.description}
</div>
</div>
{/if}
</div>
{/if}
{#if edit === true} {#if edit === true}
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2"> <div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
<textarea <textarea
...@@ -417,7 +467,34 @@ ...@@ -417,7 +467,34 @@
</div> </div>
{:else} {:else}
<div class="w-full"> <div class="w-full">
{#if message?.error === true} {#if message.content === '' && !message.error}
<Skeleton />
{:else if message.content && message.error !== true}
<!-- always show message contents even if there's an error -->
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
{#each tokens as token, tokenIdx}
{#if token.type === 'code'}
{#if token.lang === 'mermaid'}
<pre class="mermaid">{revertSanitizedResponseContent(token.text)}</pre>
{:else}
<CodeBlock
id={`${message.id}-${tokenIdx}`}
lang={token?.lang ?? ''}
code={revertSanitizedResponseContent(token?.text ?? '')}
/>
{/if}
{:else}
{@html marked.parse(token.raw, {
...defaults,
gfm: true,
breaks: true,
renderer
})}
{/if}
{/each}
{/if}
{#if message.error}
<div <div
class="flex mt-2 mb-4 space-x-2 border px-4 py-3 border-red-800 bg-red-800/30 font-medium rounded-lg" class="flex mt-2 mb-4 space-x-2 border px-4 py-3 border-red-800 bg-red-800/30 font-medium rounded-lg"
> >
...@@ -437,36 +514,22 @@ ...@@ -437,36 +514,22 @@
</svg> </svg>
<div class=" self-center"> <div class=" self-center">
{message.content} {message?.error?.content ?? message.content}
</div> </div>
</div> </div>
{:else if message.content === ''}
<Skeleton />
{:else}
{#each tokens as token, tokenIdx}
{#if token.type === 'code'}
<CodeBlock
id={`${message.id}-${tokenIdx}`}
lang={token?.lang ?? ''}
code={revertSanitizedResponseContent(token?.text ?? '')}
/>
{:else}
{@html marked.parse(token.raw, {
...defaults,
gfm: true,
breaks: true,
renderer
})}
{/if}
{/each}
{/if} {/if}
{#if message.citations} {#if message.citations}
<div class="mt-1 mb-2 w-full flex gap-1 items-center"> <div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
{#each message.citations.reduce((acc, citation) => { {#each message.citations.reduce((acc, citation) => {
citation.document.forEach((document, index) => { citation.document.forEach((document, index) => {
const metadata = citation.metadata?.[index]; const metadata = citation.metadata?.[index];
const id = metadata?.source ?? 'N/A'; const id = metadata?.source ?? 'N/A';
let source = citation?.source;
// Check if ID looks like a URL
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id };
}
const existingSource = acc.find((item) => item.id === id); const existingSource = acc.find((item) => item.id === id);
...@@ -474,7 +537,7 @@ ...@@ -474,7 +537,7 @@
existingSource.document.push(document); existingSource.document.push(document);
existingSource.metadata.push(metadata); existingSource.metadata.push(metadata);
} else { } else {
acc.push( { id: id, source: citation?.source, document: [document], metadata: metadata ? [metadata] : [] } ); acc.push( { id: id, source: source, document: [document], metadata: metadata ? [metadata] : [] } );
} }
}); });
return acc; return acc;
......
<script lang="ts">
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import { Collapsible } from 'bits-ui';
import { slide } from 'svelte/transition';
export let urls = [];
let state = false;
</script>
<Collapsible.Root class="w-full space-y-1" bind:open={state}>
<Collapsible.Trigger>
<div
class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
>
<slot />
{#if state}
<ChevronUp strokeWidth="3.5" className="size-3.5 " />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5 " />
{/if}
</div>
</Collapsible.Trigger>
<Collapsible.Content
class=" text-sm border border-gray-300/30 dark:border-gray-700/50 rounded-xl"
transition={slide}
>
{#each urls as url, urlIdx}
<a
href={url}
target="_blank"
class="flex w-full items-center p-3 px-4 {urlIdx === urls.length - 1
? ''
: 'border-b border-gray-300/30 dark:border-gray-700/50'} group/item justify-between font-normal text-gray-800 dark:text-gray-300"
>
<div class=" line-clamp-1">
{url}
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<!-- -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
</Collapsible.Content>
</Collapsible.Root>
<div class="w-full mt-3 mb-4"> <div class="w-full mt-2 mb-4">
<div class="animate-pulse flex w-full"> <div class="animate-pulse flex w-full">
<div class="space-y-2 w-full"> <div class="space-y-2 w-full">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" /> <div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment