merged new updates

This commit is contained in:
Jannik Streidl 2024-03-04 11:53:09 +01:00
commit d4fd6c5a57
16 changed files with 828 additions and 67 deletions

View file

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.108] - 2024-03-02
### Added
- **🎮 Playground Feature (Beta)**: Explore the full potential of the raw API through an intuitive UI with our new playground feature, accessible to admins. Simply click on the bottom name area of the sidebar to access it. The playground feature offers two modes text completion (notebook) and chat completion. As it's in beta, please report any issues you encounter.
- **🛠️ Direct Database Download for Admins**: Admins can now download the database directly from the WebUI via the admin settings.
- **🎨 Additional RAG Settings**: Customize your RAG process with the ability to edit the TOP K value. Navigate to Documents > Settings > General to make changes.
- **🖥️ UI Improvements**: Tooltips now available in the input area and sidebar handle. More tooltips will be added across other parts of the UI.
### Fixed
- Resolved input autofocus issue on mobile when the sidebar is open, making it easier to use.
- Corrected numbered list display issue in Safari (#963).
- Restricted user ability to delete chats without proper permissions (#993).
### Changed
- **Simplified Ollama Settings**: Ollama settings now don't require the `/api` suffix. You can now utilize the Ollama base URL directly, e.g., `http://localhost:11434`. Also, an `OLLAMA_BASE_URL` environment variable has been added.
- **Database Renaming**: Starting from this release, `ollama.db` will be automatically renamed to `webui.db`.
## [0.1.107] - 2024-03-01 ## [0.1.107] - 2024-03-01
### Added ### Added

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel
from apps.web.models.users import Users from apps.web.models.users import Users
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from utils.utils import decode_token, get_current_user, get_admin_user from utils.utils import decode_token, get_current_user, get_admin_user
from config import OLLAMA_API_BASE_URL, WEBUI_AUTH from config import OLLAMA_BASE_URL, WEBUI_AUTH
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
@ -22,7 +22,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.state.OLLAMA_API_BASE_URL = OLLAMA_API_BASE_URL app.state.OLLAMA_BASE_URL = OLLAMA_BASE_URL
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL # TARGET_SERVER_URL = OLLAMA_API_BASE_URL
@ -32,7 +32,7 @@ REQUEST_POOL = []
@app.get("/url") @app.get("/url")
async def get_ollama_api_url(user=Depends(get_admin_user)): async def get_ollama_api_url(user=Depends(get_admin_user)):
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL} return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL}
class UrlUpdateForm(BaseModel): class UrlUpdateForm(BaseModel):
@ -41,8 +41,8 @@ class UrlUpdateForm(BaseModel):
@app.post("/url/update") @app.post("/url/update")
async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)):
app.state.OLLAMA_API_BASE_URL = form_data.url app.state.OLLAMA_BASE_URL = form_data.url
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL} return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL}
@app.get("/cancel/{request_id}") @app.get("/cancel/{request_id}")
@ -57,7 +57,7 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user))
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_current_user)): async def proxy(path: str, request: Request, user=Depends(get_current_user)):
target_url = f"{app.state.OLLAMA_API_BASE_URL}/{path}" target_url = f"{app.state.OLLAMA_BASE_URL}/{path}"
body = await request.body() body = await request.body()
headers = dict(request.headers) headers = dict(request.headers)
@ -91,7 +91,13 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)):
def stream_content(): def stream_content():
try: try:
if path in ["chat"]: if path == "generate":
data = json.loads(body.decode("utf-8"))
if not ("stream" in data and data["stream"] == False):
yield json.dumps({"id": request_id, "done": False}) + "\n"
elif path == "chat":
yield json.dumps({"id": request_id, "done": False}) + "\n" yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
@ -103,6 +109,7 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)):
finally: finally:
if hasattr(r, "close"): if hasattr(r, "close"):
r.close() r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id) REQUEST_POOL.remove(request_id)
r = requests.request( r = requests.request(

View file

@ -79,6 +79,8 @@ app.state.CHUNK_SIZE = CHUNK_SIZE
app.state.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.CHUNK_OVERLAP = CHUNK_OVERLAP
app.state.RAG_TEMPLATE = RAG_TEMPLATE app.state.RAG_TEMPLATE = RAG_TEMPLATE
app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.TOP_K = 4
app.state.sentence_transformer_ef = ( app.state.sentence_transformer_ef = (
embedding_functions.SentenceTransformerEmbeddingFunction( embedding_functions.SentenceTransformerEmbeddingFunction(
model_name=app.state.RAG_EMBEDDING_MODEL, model_name=app.state.RAG_EMBEDDING_MODEL,
@ -210,23 +212,33 @@ async def get_rag_template(user=Depends(get_current_user)):
} }
class RAGTemplateForm(BaseModel): @app.get("/query/settings")
template: str async def get_query_settings(user=Depends(get_admin_user)):
return {
"status": True,
"template": app.state.RAG_TEMPLATE,
"k": app.state.TOP_K,
}
@app.post("/template/update") class QuerySettingsForm(BaseModel):
async def update_rag_template(form_data: RAGTemplateForm, user=Depends(get_admin_user)): k: Optional[int] = None
# TODO: check template requirements template: Optional[str] = None
app.state.RAG_TEMPLATE = (
form_data.template if form_data.template != "" else RAG_TEMPLATE
) @app.post("/query/settings/update")
async def update_query_settings(
form_data: QuerySettingsForm, user=Depends(get_admin_user)
):
app.state.RAG_TEMPLATE = form_data.template if form_data.template else RAG_TEMPLATE
app.state.TOP_K = form_data.k if form_data.k else 4
return {"status": True, "template": app.state.RAG_TEMPLATE} return {"status": True, "template": app.state.RAG_TEMPLATE}
class QueryDocForm(BaseModel): class QueryDocForm(BaseModel):
collection_name: str collection_name: str
query: str query: str
k: Optional[int] = 4 k: Optional[int] = None
@app.post("/query/doc") @app.post("/query/doc")
@ -240,7 +252,10 @@ def query_doc(
name=form_data.collection_name, name=form_data.collection_name,
embedding_function=app.state.sentence_transformer_ef, embedding_function=app.state.sentence_transformer_ef,
) )
result = collection.query(query_texts=[form_data.query], n_results=form_data.k) result = collection.query(
query_texts=[form_data.query],
n_results=form_data.k if form_data.k else app.state.TOP_K,
)
return result return result
except Exception as e: except Exception as e:
print(e) print(e)
@ -253,7 +268,7 @@ def query_doc(
class QueryCollectionsForm(BaseModel): class QueryCollectionsForm(BaseModel):
collection_names: List[str] collection_names: List[str]
query: str query: str
k: Optional[int] = 4 k: Optional[int] = None
def merge_and_sort_query_results(query_results, k): def merge_and_sort_query_results(query_results, k):
@ -317,13 +332,16 @@ def query_collection(
) )
result = collection.query( result = collection.query(
query_texts=[form_data.query], n_results=form_data.k query_texts=[form_data.query],
n_results=form_data.k if form_data.k else app.state.TOP_K,
) )
results.append(result) results.append(result)
except: except:
pass pass
return merge_and_sort_query_results(results, form_data.k) return merge_and_sort_query_results(
results, form_data.k if form_data.k else app.state.TOP_K
)
@app.post("/web") @app.post("/web")
@ -423,7 +441,9 @@ def get_loader(filename: str, file_content_type: str, file_path: str):
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
] or file_ext in ["xls", "xlsx"]: ] or file_ext in ["xls", "xlsx"]:
loader = UnstructuredExcelLoader(file_path) loader = UnstructuredExcelLoader(file_path)
elif file_ext in known_source_ext or (file_content_type and file_content_type.find("text/") >= 0): elif file_ext in known_source_ext or (
file_content_type and file_content_type.find("text/") >= 0
):
loader = TextLoader(file_path) loader = TextLoader(file_path)
else: else:
loader = TextLoader(file_path) loader = TextLoader(file_path)

View file

@ -211,6 +211,17 @@ if ENV == "prod":
if OLLAMA_API_BASE_URL == "/ollama/api": if OLLAMA_API_BASE_URL == "/ollama/api":
OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api" OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api"
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "")
if OLLAMA_BASE_URL == "":
OLLAMA_BASE_URL = (
OLLAMA_API_BASE_URL[:-4]
if OLLAMA_API_BASE_URL.endswith("/api")
else OLLAMA_API_BASE_URL
)
#################################### ####################################
# OPENAI_API # OPENAI_API
#################################### ####################################

View file

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.1.107", "version": "0.1.108",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",

View file

@ -29,7 +29,7 @@ export const getOllamaAPIUrl = async (token: string = '') => {
throw error; throw error;
} }
return res.OLLAMA_API_BASE_URL; return res.OLLAMA_BASE_URL;
}; };
export const updateOllamaAPIUrl = async (token: string = '', url: string) => { export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
@ -64,13 +64,13 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
throw error; throw error;
} }
return res.OLLAMA_API_BASE_URL; return res.OLLAMA_BASE_URL;
}; };
export const getOllamaVersion = async (token: string = '') => { export const getOllamaVersion = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/version`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -102,7 +102,7 @@ export const getOllamaVersion = async (token: string = '') => {
export const getOllamaModels = async (token: string = '') => { export const getOllamaModels = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/tags`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -148,7 +148,7 @@ export const generateTitle = async (
console.log(template); console.log(template);
const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -186,7 +186,7 @@ export const generatePrompt = async (token: string = '', model: string, conversa
conversation = '[no existing conversation]'; conversation = '[no existing conversation]';
} }
const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -217,11 +217,37 @@ export const generatePrompt = async (token: string = '', model: string, conversa
return res; return res;
}; };
export const generateTextCompletion = async (token: string = '', model: string, text: string) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
prompt: text,
stream: true
})
}).catch((err) => {
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const generateChatCompletion = async (token: string = '', body: object) => { export const generateChatCompletion = async (token: string = '', body: object) => {
let controller = new AbortController(); let controller = new AbortController();
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/chat`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/chat`, {
signal: controller.signal, signal: controller.signal,
method: 'POST', method: 'POST',
headers: { headers: {
@ -265,7 +291,7 @@ export const cancelChatCompletion = async (token: string = '', requestId: string
export const createModel = async (token: string, tagName: string, content: string) => { export const createModel = async (token: string, tagName: string, content: string) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/create`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -290,7 +316,7 @@ export const createModel = async (token: string, tagName: string, content: strin
export const deleteModel = async (token: string, tagName: string) => { export const deleteModel = async (token: string, tagName: string) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/delete`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/delete`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -324,7 +350,7 @@ export const deleteModel = async (token: string, tagName: string) => {
export const pullModel = async (token: string, tagName: string) => { export const pullModel = async (token: string, tagName: string) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/pull`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',

View file

@ -85,17 +85,49 @@ export const getRAGTemplate = async (token: string) => {
return res?.template ?? ''; return res?.template ?? '';
}; };
export const updateRAGTemplate = async (token: string, template: string) => { export const getQuerySettings = async (token: string) => {
let error = null; let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/template/update`, { const res = await fetch(`${RAG_API_BASE_URL}/query/settings`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
type QuerySettings = {
k: number | null;
template: string | null;
};
export const updateQuerySettings = async (token: string, settings: QuerySettings) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/query/settings/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
template: template ...settings
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -183,7 +215,7 @@ export const queryDoc = async (
token: string, token: string,
collection_name: string, collection_name: string,
query: string, query: string,
k: number k: number | null = null
) => { ) => {
let error = null; let error = null;

View file

@ -81,6 +81,9 @@
throw data; throw data;
} }
if ('id' in data) {
console.log(data);
} else {
if (data.done == false) { if (data.done == false) {
if (prompt == '' && data.response == '\n') { if (prompt == '' && data.response == '\n') {
continue; continue;
@ -93,6 +96,7 @@
} }
} }
} }
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
if ('detail' in error) { if ('detail' in error) {

View file

@ -121,7 +121,7 @@
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none" class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
placeholder="Enter URL (e.g. http://localhost:11434/api)" placeholder="Enter URL (e.g. http://localhost:11434)"
bind:value={API_BASE_URL} bind:value={API_BASE_URL}
/> />
</div> </div>

View file

@ -2,10 +2,10 @@
import { getDocs } from '$lib/apis/documents'; import { getDocs } from '$lib/apis/documents';
import { import {
getChunkParams, getChunkParams,
getRAGTemplate, getQuerySettings,
scanDocs, scanDocs,
updateChunkParams, updateChunkParams,
updateRAGTemplate updateQuerySettings
} from '$lib/apis/rag'; } from '$lib/apis/rag';
import { documents } from '$lib/stores'; import { documents } from '$lib/stores';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
@ -20,7 +20,10 @@
let chunkSize = 0; let chunkSize = 0;
let chunkOverlap = 0; let chunkOverlap = 0;
let template = ''; let querySettings = {
template: '',
k: 4
};
const scanHandler = async () => { const scanHandler = async () => {
loading = true; loading = true;
@ -35,7 +38,7 @@
const submitHandler = async () => { const submitHandler = async () => {
const res = await updateChunkParams(localStorage.token, chunkSize, chunkOverlap); const res = await updateChunkParams(localStorage.token, chunkSize, chunkOverlap);
await updateRAGTemplate(localStorage.token, template); querySettings = await updateQuerySettings(localStorage.token, querySettings);
}; };
onMount(async () => { onMount(async () => {
@ -46,7 +49,7 @@
chunkOverlap = res.chunk_overlap; chunkOverlap = res.chunk_overlap;
} }
template = await getRAGTemplate(localStorage.token); querySettings = await getQuerySettings(localStorage.token);
}); });
</script> </script>
@ -160,10 +163,44 @@
</div> </div>
</div> </div>
<div class=" text-sm font-medium">Query Params</div>
<div class=" flex">
<div class=" flex w-full justify-between">
<div class="self-center text-xs font-medium flex-1">Top K</div>
<div class="self-center p-3">
<input
class=" w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="number"
placeholder="Enter Top K"
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
</div>
</div>
<!-- <div class="flex w-full">
<div class=" self-center text-xs font-medium min-w-fit">Chunk Overlap</div>
<div class="self-center p-3">
<input
class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="number"
placeholder="Enter Chunk Overlap"
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div> -->
</div>
<div> <div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('RAG Template')}</div> <div class=" mb-2.5 text-sm font-medium">{$i18n.t('RAG Template')}</div>
<textarea <textarea
bind:value={template} bind:value={querySettings.template}
class="w-full rounded p-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none" class="w-full rounded p-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
rows="4" rows="4"
/> />

View file

@ -38,7 +38,7 @@
let isEditing = false; let isEditing = false;
onMount(async () => { onMount(async () => {
if (window.innerWidth > 1280) { if (window.innerWidth > 1024) {
show = true; show = true;
} }
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
@ -388,6 +388,11 @@
? 'bg-gray-900' ? 'bg-gray-900'
: ''} transition whitespace-nowrap text-ellipsis" : ''} transition whitespace-nowrap text-ellipsis"
href="/c/{chat.id}" href="/c/{chat.id}"
on:click={() => {
if (window.innerWidth < 1024) {
show = false;
}
}}
> >
<div class=" flex self-center flex-1 w-full"> <div class=" flex self-center flex-1 w-full">
<div <div
@ -599,6 +604,32 @@
</div> </div>
<div class=" self-center font-medium">{$i18n.t('Admin Panel')}</div> <div class=" self-center font-medium">{$i18n.t('Admin Panel')}</div>
</button> </button>
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
on:click={() => {
goto('/playground');
showDropdown = false;
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<div class=" self-center font-medium">Playground</div>
</button>
{/if} {/if}
<button <button

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
export let messages = [];
onMount(() => {
messages.forEach((message, idx) => {
let textareaElement = document.getElementById(`${message.role}-${idx}-textarea`);
textareaElement.style.height = '';
textareaElement.style.height = textareaElement.scrollHeight + 'px';
});
});
</script>
<div class="py-3 space-y-3">
{#each messages as message, idx}
<div class="flex gap-2 group">
<div class="flex items-start pt-1">
<button
class="px-2 py-1 text-sm font-semibold uppercase min-w-[6rem] text-left dark:group-hover:bg-gray-800 rounded-lg transition"
on:click={() => {
message.role = message.role === 'user' ? 'assistant' : 'user';
}}>{message.role}</button
>
</div>
<div class="flex-1">
<textarea
id="{message.role}-{idx}-textarea"
class="w-full bg-transparent outline-none rounded-lg p-2 text-sm resize-none overflow-hidden"
placeholder="Enter {message.role === 'user' ? 'a user' : 'an assistant'} message here"
rows="1"
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = e.target.scrollHeight + 'px';
}}
on:focus={(e) => {
e.target.style.height = '';
e.target.style.height = e.target.scrollHeight + 'px';
// e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
bind:value={message.content}
/>
</div>
<div class=" pt-1">
<button
class=" group-hover:text-gray-500 dark:text-gray-900 dark:hover:text-gray-300 transition"
on:click={() => {
messages = messages.filter((message, messageIdx) => messageIdx !== idx);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</button>
</div>
</div>
<hr class=" dark:border-gray-800" />
{/each}
<button
class="flex items-center gap-2 px-2 py-1"
on:click={() => {
console.log(messages.at(-1));
messages.push({
role: (messages.at(-1)?.role ?? 'assistant') === 'user' ? 'assistant' : 'user',
content: ''
});
messages = messages;
}}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class=" text-sm font-medium">Add message</div>
</button>
</div>

View file

@ -253,19 +253,17 @@
let relevantContexts = await Promise.all( let relevantContexts = await Promise.all(
docs.map(async (doc) => { docs.map(async (doc) => {
if (doc.type === 'collection') { if (doc.type === 'collection') {
return await queryCollection(localStorage.token, doc.collection_names, query, 4).catch( return await queryCollection(localStorage.token, doc.collection_names, query).catch(
(error) => { (error) => {
console.log(error); console.log(error);
return null; return null;
} }
); );
} else { } else {
return await queryDoc(localStorage.token, doc.collection_name, query, 4).catch( return await queryDoc(localStorage.token, doc.collection_name, query).catch((error) => {
(error) => {
console.log(error); console.log(error);
return null; return null;
} });
);
} }
}) })
); );

View file

@ -263,19 +263,17 @@
let relevantContexts = await Promise.all( let relevantContexts = await Promise.all(
docs.map(async (doc) => { docs.map(async (doc) => {
if (doc.type === 'collection') { if (doc.type === 'collection') {
return await queryCollection(localStorage.token, doc.collection_names, query, 4).catch( return await queryCollection(localStorage.token, doc.collection_names, query).catch(
(error) => { (error) => {
console.log(error); console.log(error);
return null; return null;
} }
); );
} else { } else {
return await queryDoc(localStorage.token, doc.collection_name, query, 4).catch( return await queryDoc(localStorage.token, doc.collection_name, query).catch((error) => {
(error) => {
console.log(error); console.log(error);
return null; return null;
} });
);
} }
}) })
); );

View file

@ -0,0 +1,472 @@
<script>
import { goto } from '$app/navigation';
import { onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import {
LITELLM_API_BASE_URL,
OLLAMA_API_BASE_URL,
OPENAI_API_BASE_URL,
WEBUI_API_BASE_URL
} from '$lib/constants';
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
import { cancelChatCompletion, generateChatCompletion } from '$lib/apis/ollama';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import ChatCompletion from '$lib/components/playground/ChatCompletion.svelte';
let mode = 'chat';
let loaded = false;
let text = '';
let selectedModelId = '';
let loading = false;
let currentRequestId;
let stopResponseFlag = false;
let system = '';
let messages = [
{
role: 'user',
content: ''
}
];
const scrollToBottom = () => {
let element;
if (mode === 'chat') {
element = document.getElementById('messages-container');
} else {
element = document.getElementById('text-completion-textarea');
}
if (element) {
element.scrollTop = element?.scrollHeight;
}
};
// const cancelHandler = async () => {
// if (currentRequestId) {
// const res = await cancelChatCompletion(localStorage.token, currentRequestId);
// currentRequestId = null;
// loading = false;
// }
// };
const stopResponse = () => {
stopResponseFlag = true;
console.log('stopResponse');
};
const textCompletionHandler = async () => {
const model = $models.find((model) => model.id === selectedModelId);
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
{
role: 'assistant',
content: text
}
]
},
model.external
? model.source === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1`
);
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
currentRequestId = null;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
if (line === 'data: [DONE]') {
// responseMessage.done = true;
console.log('done');
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
text += data.choices[0].delta.content ?? '';
}
}
}
} catch (error) {
console.log(error);
}
scrollToBottom();
}
}
};
const chatCompletionHandler = async () => {
const model = $models.find((model) => model.id === selectedModelId);
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
system
? {
role: 'system',
content: system
}
: undefined,
...messages
].filter((message) => message)
},
model.external
? model.source === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1`
);
// const [res, controller] = await generateChatCompletion(localStorage.token, {
// model: selectedModelId,
// messages: [
// {
// role: 'assistant',
// content: text
// }
// ]
// });
let responseMessage;
if (messages.at(-1)?.role === 'assistant') {
responseMessage = messages.at(-1);
} else {
responseMessage = {
role: 'assistant',
content: ''
};
messages.push(responseMessage);
messages = messages;
}
await tick();
const textareaElement = document.getElementById(`assistant-${messages.length - 1}-textarea`);
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
// responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
textareaElement.style.height = textareaElement.scrollHeight + 'px';
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
textareaElement.style.height = textareaElement.scrollHeight + 'px';
await tick();
}
}
}
}
} catch (error) {
console.log(error);
}
scrollToBottom();
}
// while (true) {
// const { value, done } = await reader.read();
// if (done || stopResponseFlag) {
// if (stopResponseFlag) {
// await cancelChatCompletion(localStorage.token, currentRequestId);
// }
// currentRequestId = null;
// break;
// }
// try {
// let lines = value.split('\n');
// for (const line of lines) {
// if (line !== '') {
// console.log(line);
// let data = JSON.parse(line);
// if ('detail' in data) {
// throw data;
// }
// if ('id' in data) {
// console.log(data);
// currentRequestId = data.id;
// } else {
// if (data.done == false) {
// text += data.message.content;
// } else {
// console.log('done');
// }
// }
// }
// }
// } catch (error) {
// console.log(error);
// }
// scrollToBottom();
// }
}
};
const submitHandler = async () => {
if (selectedModelId) {
loading = true;
if (mode === 'complete') {
await textCompletionHandler();
} else if (mode === 'chat') {
await chatCompletionHandler();
}
loading = false;
stopResponseFlag = false;
currentRequestId = null;
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
}
if ($settings?.models) {
selectedModelId = $settings?.models[0];
} else if ($config?.default_models) {
selectedModelId = $config?.default_models.split(',')[0];
} else {
selectedModelId = '';
}
loaded = true;
});
</script>
<svelte:head>
<title>
{`Playground | ${$WEBUI_NAME}`}
</title>
</svelte:head>
<div class="min-h-screen max-h-[100dvh] w-full flex justify-center dark:text-white">
<div class=" flex flex-col justify-between w-full overflow-y-auto h-[100dvh]">
<div class="max-w-2xl mx-auto w-full px-3 p-3 md:px-0 h-full">
<div class=" flex flex-col h-full">
<div class="flex flex-col justify-between mb-2.5 gap-1">
<div class="flex justify-between items-center gap-2">
<div class=" text-2xl font-semibold self-center flex">
Playground <span class=" text-xs text-gray-500 self-center ml-1">(Beta)</span>
</div>
<div>
<button
class=" flex items-center gap-0.5 text-xs px-2.5 py-0.5 rounded-lg {mode ===
'chat' && 'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {mode === 'complete' &&
'text-green-600 dark:text-green-200 bg-green-200/30'} "
on:click={() => {
if (mode === 'complete') {
mode = 'chat';
} else {
mode = 'complete';
}
}}
>
{#if mode === 'complete'}
Text Completion
{:else if mode === 'chat'}
Chat
{/if}
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M5.22 10.22a.75.75 0 0 1 1.06 0L8 11.94l1.72-1.72a.75.75 0 1 1 1.06 1.06l-2.25 2.25a.75.75 0 0 1-1.06 0l-2.25-2.25a.75.75 0 0 1 0-1.06ZM10.78 5.78a.75.75 0 0 1-1.06 0L8 4.06 6.28 5.78a.75.75 0 0 1-1.06-1.06l2.25-2.25a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
<div class=" flex gap-1 px-1">
<select
id="models"
class="outline-none bg-transparent text-sm font-medium rounded-lg w-full placeholder-gray-400"
bind:value={selectedModelId}
>
<option class=" text-gray-800" value="" selected disabled>Select a model</option>
{#each $models as model}
{#if model.name === 'hr'}
<hr />
{:else}
<option value={model.id} class="text-gray-800 text-lg"
>{model.name +
`${model.size ? ` (${(model.size / 1024 ** 3).toFixed(1)}GB)` : ''}`}</option
>
{/if}
{/each}
</select>
<!-- <button
class=" self-center dark:hover:text-gray-300"
id="open-settings-button"
on:click={async () => {}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button> -->
</div>
</div>
{#if mode === 'chat'}
<div class="p-1">
<div class="p-3 outline outline-1 outline-gray-200 dark:outline-gray-800 rounded-lg">
<div class=" text-sm font-medium">System</div>
<textarea
id="system-textarea"
class="w-full h-full bg-transparent resize-none outline-none text-sm"
bind:value={system}
placeholder="You're a helpful assistant."
rows="4"
/>
</div>
</div>
{/if}
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container"
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
{#if mode === 'complete'}
<textarea
id="text-completion-textarea"
class="w-full h-full p-3 bg-transparent outline outline-1 outline-gray-200 dark:outline-gray-800 resize-none rounded-lg text-sm"
bind:value={text}
placeholder="You're a helpful assistant."
/>
{:else if mode === 'chat'}
<ChatCompletion bind:messages />
{/if}
</div>
</div>
</div>
<div class="pb-2">
{#if !loading}
<button
class="px-3 py-1.5 text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-gray-50 transition rounded-lg"
on:click={() => {
submitHandler();
}}
>
Submit
</button>
{:else}
<button
class="px-3 py-1.5 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-900 transition rounded-lg"
on:click={() => {
stopResponse();
}}
>
Cancel
</button>
{/if}
</div>
</div>
</div>
</div>
</div>
<style>
.scrollbar-hidden::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>