Commit 9021f068 authored by Jun Siang Cheah's avatar Jun Siang Cheah
Browse files

Merge remote-tracking branch 'origin/dev' into feat/backend-web-search

parents 81a3c970 de0f3168
...@@ -37,3 +37,21 @@ jobs: ...@@ -37,3 +37,21 @@ jobs:
- name: Build Frontend - name: Build Frontend
run: npm run build run: npm run build
test-frontend:
name: 'Frontend Unit Tests'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: npm ci
- name: Run vitest
run: npm run test:frontend
...@@ -80,25 +80,25 @@ RUN mkdir -p $HOME/.cache/chroma ...@@ -80,25 +80,25 @@ RUN mkdir -p $HOME/.cache/chroma
RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id
RUN if [ "$USE_OLLAMA" = "true" ]; then \ RUN if [ "$USE_OLLAMA" = "true" ]; then \
apt-get update && \ apt-get update && \
# Install pandoc and netcat # Install pandoc and netcat
apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \ apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \
# for RAG OCR # for RAG OCR
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
# install helper tools # install helper tools
apt-get install -y --no-install-recommends curl && \ apt-get install -y --no-install-recommends curl jq && \
# install ollama # install ollama
curl -fsSL https://ollama.com/install.sh | sh && \ curl -fsSL https://ollama.com/install.sh | sh && \
# cleanup # cleanup
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
else \ else \
apt-get update && \ apt-get update && \
# Install pandoc and netcat # Install pandoc and netcat
apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \ apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \
# for RAG OCR # for RAG OCR
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
# cleanup # cleanup
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
fi fi
# install python dependencies # install python dependencies
...@@ -106,16 +106,16 @@ COPY ./backend/requirements.txt ./requirements.txt ...@@ -106,16 +106,16 @@ COPY ./backend/requirements.txt ./requirements.txt
RUN pip3 install uv && \ RUN pip3 install uv && \
if [ "$USE_CUDA" = "true" ]; then \ if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use # If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
else \ else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
fi fi
...@@ -134,6 +134,7 @@ COPY ./backend . ...@@ -134,6 +134,7 @@ COPY ./backend .
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK CMD curl --fail http://localhost:8080 || exit 1 HEALTHCHECK CMD curl --silent --fail http://localhost:8080/health | jq -e '.status == true' || exit 1
CMD [ "bash", "start.sh"] CMD [ "bash", "start.sh"]
...@@ -379,6 +379,11 @@ async def get_opensearch_xml(): ...@@ -379,6 +379,11 @@ async def get_opensearch_xml():
return Response(content=xml_content, media_type="application/xml") return Response(content=xml_content, media_type="application/xml")
@app.get("/health")
async def healthcheck():
return {"status": True}
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache") app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache")
......
...@@ -42,5 +42,38 @@ describe('Settings', () => { ...@@ -42,5 +42,38 @@ describe('Settings', () => {
.find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received .find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received
.should('exist'); .should('exist');
}); });
it('user can share chat', () => {
// Click on the model selector
cy.get('button[aria-label="Select a model"]').click();
// Select the first model
cy.get('button[aria-label="model-item"]').first().click();
// Type a message
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
force: true
});
// Send the message
cy.get('button[type="submit"]').click();
// User's message should be visible
cy.get('.chat-user').should('exist');
// Wait for the response
cy.get('.chat-assistant', { timeout: 120_000 }) // .chat-assistant is created after the first token is received
.find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received
.should('exist');
// spy on requests
const spy = cy.spy();
cy.intercept("GET", "/api/v1/chats/*", spy);
// Open context menu
cy.get('#chat-context-menu-button').click();
// Click share button
cy.get('#chat-share-button').click();
// Check if the share dialog is visible
cy.get('#copy-and-share-chat-button').should('exist');
cy.wrap({}, { timeout: 5000 })
.should(() => {
// Check if the request was made twice (once for to replace chat object and once more due to change event)
expect(spy).to.be.callCount(2);
});
});
}); });
}); });
...@@ -15,12 +15,8 @@ describe('Settings', () => { ...@@ -15,12 +15,8 @@ describe('Settings', () => {
cy.loginAdmin(); cy.loginAdmin();
// Visit the home page // Visit the home page
cy.visit('/'); cy.visit('/');
// Open the sidebar if it is not already open // Click on the user menu
cy.get('[aria-label="Open sidebar"]').then(() => { cy.get('button[aria-label="User Menu"]').click();
cy.get('button[id="sidebar-toggle-button"]').click();
});
// Click on the profile link
cy.get('button').contains(adminUser.name).click();
// Click on the settings link // Click on the settings link
cy.get('button').contains('Settings').click(); cy.get('button').contains('Settings').click();
}); });
......
This diff is collapsed.
...@@ -15,7 +15,8 @@ ...@@ -15,7 +15,8 @@
"format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"", "format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"",
"format:backend": "black . --exclude \"/venv/\"", "format:backend": "black . --exclude \"/venv/\"",
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"", "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"",
"cy:open": "cypress open" "cy:open": "cypress open",
"test:frontend": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-auto": "^2.0.0",
...@@ -41,7 +42,8 @@ ...@@ -41,7 +42,8 @@
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^4.4.2" "vite": "^4.4.2",
"vitest": "^1.6.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
......
...@@ -83,11 +83,31 @@ select { ...@@ -83,11 +83,31 @@ select {
display: none; display: none;
} }
.scrollbar-none:active::-webkit-scrollbar-thumb, .scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-none:focus::-webkit-scrollbar-thumb, .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-none:hover::-webkit-scrollbar-thumb { .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible; visibility: visible;
} }
.scrollbar-none::-webkit-scrollbar-thumb { .scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden; visibility: hidden;
} }
.scrollbar-none::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.scrollbar-none {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
input[type='number'] {
-moz-appearance: textfield; /* Firefox */
}
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
</div> </div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100"> <div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-80 scrollbar-none"> <div class=" overflow-y-scroll max-h-80 scrollbar-hidden">
<div class="mb-3"> <div class="mb-3">
{#if changelog} {#if changelog}
{#each Object.keys(changelog) as version} {#each Object.keys(changelog) as version}
......
...@@ -123,7 +123,7 @@ ...@@ -123,7 +123,7 @@
<div class="flex mt-2 space-x-2"> <div class="flex mt-2 space-x-2">
<input <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" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text" type="text"
placeholder={`https://example.com/webhook`} placeholder={`https://example.com/webhook`}
bind:value={webhookUrl} bind:value={webhookUrl}
...@@ -140,7 +140,7 @@ ...@@ -140,7 +140,7 @@
<div class="flex mt-2 space-x-2"> <div class="flex mt-2 space-x-2">
<input <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" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text" type="text"
placeholder={`e.g.) "30m","1h", "10d". `} placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={JWTExpiresIn} bind:value={JWTExpiresIn}
......
<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 { modelfiles, settings, showSidebar } from '$lib/stores'; import { mobile, modelfiles, settings, showSidebar } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
import { import {
...@@ -415,7 +415,7 @@ ...@@ -415,7 +415,7 @@
{#if dragged} {#if dragged}
<div <div
class="fixed {$showSidebar class="fixed {$showSidebar
? 'left-0 lg:left-[260px] lg:w-[calc(100%-260px)]' ? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
: 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none" : 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none"
id="dropzone" id="dropzone"
role="region" role="region"
...@@ -431,9 +431,9 @@ ...@@ -431,9 +431,9 @@
</div> </div>
{/if} {/if}
<div class="fixed bottom-0 {$showSidebar ? 'left-0 lg:left-[260px]' : 'left-0'} right-0"> <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="px-2.5 lg:px-16 -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center"> <div class="px-2.5 md:px-16 -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col max-w-5xl w-full"> <div class="flex flex-col max-w-5xl w-full">
<div class="relative"> <div class="relative">
{#if autoScroll === false && messages.length > 0} {#if autoScroll === false && messages.length > 0}
...@@ -538,7 +538,7 @@ ...@@ -538,7 +538,7 @@
</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 lg:px-16 mx-auto inset-x-0"> <div class="max-w-6xl px-2.5 md:px-16 mx-auto inset-x-0">
<div class=" pb-2"> <div class=" pb-2">
<input <input
bind:this={filesInputElement} bind:this={filesInputElement}
...@@ -757,7 +757,7 @@ ...@@ -757,7 +757,7 @@
<textarea <textarea
id="chat-textarea" id="chat-textarea"
bind:this={chatTextAreaElement} bind:this={chatTextAreaElement}
class="scrollbar-none dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled class="scrollbar-hidden dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
? '' ? ''
: ' pl-4'} rounded-xl resize-none h-[48px]" : ' pl-4'} rounded-xl resize-none h-[48px]"
placeholder={chatInputPlaceholder !== '' placeholder={chatInputPlaceholder !== ''
...@@ -768,7 +768,7 @@ ...@@ -768,7 +768,7 @@
bind:value={prompt} bind:value={prompt}
on:keypress={(e) => { on:keypress={(e) => {
if ( if (
window.innerWidth > 1024 || !$mobile ||
!( !(
'ontouchstart' in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
...@@ -1118,12 +1118,12 @@ ...@@ -1118,12 +1118,12 @@
</div> </div>
<style> <style>
.scrollbar-none:active::-webkit-scrollbar-thumb, .scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-none:focus::-webkit-scrollbar-thumb, .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-none:hover::-webkit-scrollbar-thumb { .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible; visibility: visible;
} }
.scrollbar-none::-webkit-scrollbar-thumb { .scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden; visibility: hidden;
} }
</style> </style>
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4"> <div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4">
<div <div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-none" class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden"
> >
{#each mergedDocuments as document, documentIdx} {#each mergedDocuments as document, documentIdx}
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
......
<div class=" self-center font-bold mb-0.5 capitalize line-clamp-1"> <div class=" self-center font-bold mb-0.5 line-clamp-1">
<slot /> <slot />
</div> </div>
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
export let src = '/user.png'; export let src = '/user.png';
</script> </script>
<div class=" mr-4"> <div class=" mr-3">
<img {src} class=" max-w-[28px] object-cover rounded-full" alt="profile" draggable="false" /> <img {src} class=" w-8 object-cover rounded-full" alt="profile" draggable="false" />
</div> </div>
...@@ -338,7 +338,7 @@ ...@@ -338,7 +338,7 @@
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)} ($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
/> />
<div class="w-full overflow-hidden"> <div class="w-full overflow-hidden pl-1">
<Name> <Name>
{#if message.model in modelfiles} {#if message.model in modelfiles}
{modelfiles[message.model]?.title} {modelfiles[message.model]?.title}
...@@ -347,8 +347,10 @@ ...@@ -347,8 +347,10 @@
{/if} {/if}
{#if message.timestamp} {#if message.timestamp}
<span class=" invisible group-hover:visible text-gray-400 text-xs font-medium"> <span
{dayjs(message.timestamp * 1000).format($i18n.t('DD/MM/YYYY HH:mm'))} class=" self-center invisible group-hover:visible text-gray-400 text-xs font-medium uppercase"
>
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
</span> </span>
{/if} {/if}
</Name> </Name>
...@@ -469,7 +471,7 @@ ...@@ -469,7 +471,7 @@
{/if} {/if}
{#if edit === true} {#if edit === true}
<div class=" w-full"> <div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={editTextAreaElement} bind:this={editTextAreaElement}
...@@ -481,23 +483,25 @@ ...@@ -481,23 +483,25 @@
}} }}
/> />
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium"> <div class=" mt-2 mb-1 flex justify-end space-x-1.5 text-sm font-medium">
<button <button
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg" id="close-edit-message-button"
class=" px-4 py-2 bg-gray-900 hover:bg-gray-850 text-gray-100 transition rounded-3xl"
on:click={() => { on:click={() => {
editMessageConfirmHandler(); cancelEditMessage();
}} }}
> >
{$i18n.t('Save')} {$i18n.t('Cancel')}
</button> </button>
<button <button
class=" px-4 py-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-100 transition outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg" id="save-edit-message-button"
class="px-4 py-2 bg-white hover:bg-gray-100 text-gray-800 transition rounded-3xl"
on:click={() => { on:click={() => {
cancelEditMessage(); editMessageConfirmHandler();
}} }}
> >
{$i18n.t('Cancel')} {$i18n.t('Save')}
</button> </button>
</div> </div>
</div> </div>
...@@ -594,47 +598,53 @@ ...@@ -594,47 +598,53 @@
class=" flex justify-start space-x-1 overflow-x-auto buttons text-gray-700 dark:text-gray-500" class=" flex justify-start space-x-1 overflow-x-auto buttons text-gray-700 dark:text-gray-500"
> >
{#if siblings.length > 1} {#if siblings.length > 1}
<div class="flex self-center min-w-fit"> <div class="flex self-center">
<button <button
class="self-center dark:hover:text-white hover:text-black transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => { on:click={() => {
showPreviousMessage(message); showPreviousMessage(message);
}} }}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="none"
fill="currentColor" viewBox="0 0 24 24"
class="w-4 h-4" stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
> >
<path <path
fill-rule="evenodd" stroke-linecap="round"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" stroke-linejoin="round"
clip-rule="evenodd" d="M15.75 19.5 8.25 12l7.5-7.5"
/> />
</svg> </svg>
</button> </button>
<div class="text-xs font-bold self-center min-w-fit dark:text-gray-100"> <div
{siblings.indexOf(message.id) + 1} / {siblings.length} class="text-sm tracking-widest font-semibold self-center dark:text-gray-100"
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div> </div>
<button <button
class="self-center dark:hover:text-white hover:text-black transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => { on:click={() => {
showNextMessage(message); showNextMessage(message);
}} }}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="none"
fill="currentColor" viewBox="0 0 24 24"
class="w-4 h-4" stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
> >
<path <path
fill-rule="evenodd" stroke-linecap="round"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" stroke-linejoin="round"
clip-rule="evenodd" d="m8.25 4.5 7.5 7.5-7.5 7.5"
/> />
</svg> </svg>
</button> </button>
......
...@@ -54,49 +54,55 @@ ...@@ -54,49 +54,55 @@
}; };
</script> </script>
<div class=" flex w-full"> <div class=" flex w-full user-message">
<ProfileImage {#if !($settings?.chatBubble ?? true)}
src={message.user <ProfileImage
? $modelfiles.find((modelfile) => modelfile.tagName === message.user)?.imageUrl ?? '/user.png' src={message.user
: user?.profile_image_url ?? '/user.png'} ? $modelfiles.find((modelfile) => modelfile.tagName === message.user)?.imageUrl ??
/> '/user.png'
: user?.profile_image_url ?? '/user.png'}
/>
{/if}
<div class="w-full overflow-hidden"> <div class="w-full overflow-hidden">
<div class="user-message"> {#if !($settings?.chatBubble ?? true)}
<Name> <div>
{#if message.user} <Name>
{#if $modelfiles.map((modelfile) => modelfile.tagName).includes(message.user)} {#if message.user}
{$modelfiles.find((modelfile) => modelfile.tagName === message.user)?.title} {#if $modelfiles.map((modelfile) => modelfile.tagName).includes(message.user)}
{$modelfiles.find((modelfile) => modelfile.tagName === message.user)?.title}
{:else}
{$i18n.t('You')}
<span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
{/if}
{:else if $settings.showUsername}
{user.name}
{:else} {:else}
{$i18n.t('You')} {$i18n.t('You')}
<span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
{/if} {/if}
{:else if $settings.showUsername}
{user.name} {#if message.timestamp}
{:else} <span
{$i18n.t('You')} class=" invisible group-hover:visible text-gray-400 text-xs font-medium uppercase"
{/if} >
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
{#if message.timestamp} </span>
<span class=" invisible group-hover:visible text-gray-400 text-xs font-medium"> {/if}
{dayjs(message.timestamp * 1000).format($i18n.t('DD/MM/YYYY HH:mm'))} </Name>
</span> </div>
{/if} {/if}
</Name>
</div>
<div <div
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 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-6 prose-li:-mb-4 whitespace-pre-line" class="prose chat-{message.role} w-full max-w-full flex flex-col justify-end dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 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-6 prose-li:-mb-4 whitespace-pre-line"
> >
{#if message.files} {#if message.files}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"> <div class="mt-2.5 mb-1 w-full flex flex-col justify-end overflow-x-auto gap-1 flex-wrap">
{#each message.files as file} {#each message.files as file}
<div> <div class={$settings?.chatBubble ?? true ? 'self-end' : ''}>
{#if file.type === 'image'} {#if file.type === 'image'}
<img src={file.url} alt="input" class=" max-h-96 rounded-lg" draggable="false" /> <img src={file.url} alt="input" class=" max-h-96 rounded-lg" draggable="false" />
{:else if file.type === 'doc'} {:else if file.type === 'doc'}
<button <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 text-left" class="h-16 w-72 flex items-center space-x-3 px-2.5 dark:bg-gray-850 rounded-xl border border-gray-200 dark:border-none text-left"
type="button" type="button"
on:click={() => { on:click={() => {
if (file?.url) { if (file?.url) {
...@@ -132,7 +138,7 @@ ...@@ -132,7 +138,7 @@
</button> </button>
{:else if file.type === 'collection'} {:else if file.type === 'collection'}
<button <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 text-left" class="h-16 w-72 flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none text-left"
type="button" type="button"
> >
<div class="p-2.5 bg-red-400 text-white rounded-lg"> <div class="p-2.5 bg-red-400 text-white rounded-lg">
...@@ -198,7 +204,7 @@ ...@@ -198,7 +204,7 @@
{/if} {/if}
{#if edit === true} {#if edit === true}
<div class=" w-full"> <div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement} bind:this={messageEditTextAreaElement}
...@@ -222,81 +228,100 @@ ...@@ -222,81 +228,100 @@
}} }}
/> />
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium"> <div class=" mt-2 mb-1 flex justify-end space-x-1.5 text-sm font-medium">
<button <button
id="save-edit-message-button" id="close-edit-message-button"
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg" class=" px-4 py-2 bg-gray-900 hover:bg-gray-850 text-gray-100 transition rounded-3xl"
on:click={() => { on:click={() => {
editMessageConfirmHandler(); cancelEditMessage();
}} }}
> >
{$i18n.t('Save & Submit')} {$i18n.t('Cancel')}
</button> </button>
<button <button
id="close-edit-message-button" id="save-edit-message-button"
class=" px-4 py-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-100 transition outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg" class="px-4 py-2 bg-white hover:bg-gray-100 text-gray-800 transition rounded-3xl"
on:click={() => { on:click={() => {
cancelEditMessage(); editMessageConfirmHandler();
}} }}
> >
{$i18n.t('Cancel')} {$i18n.t('Send')}
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<div class="w-full"> <div class="w-full">
<pre id="user-message">{message.content}</pre> <div class="flex {$settings?.chatBubble ?? true ? 'justify-end' : ''} mb-2">
<div
class="rounded-3xl {$settings?.chatBubble ?? true
? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${
message.files ? 'rounded-tr-lg' : ''
}`
: ''} "
>
<pre id="user-message">{message.content}</pre>
</div>
</div>
<div class=" flex justify-start space-x-1 text-gray-700 dark:text-gray-500"> <div
{#if siblings.length > 1} class=" flex {$settings?.chatBubble ?? true
<div class="flex self-center"> ? 'justify-end'
<button : ''} space-x-1 text-gray-700 dark:text-gray-500"
class="self-center dark:hover:text-white hover:text-black transition" >
on:click={() => { {#if !($settings?.chatBubble ?? true)}
showPreviousMessage(message); {#if siblings.length > 1}
}} <div class="flex self-center">
> <button
<svg class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
xmlns="http://www.w3.org/2000/svg" on:click={() => {
viewBox="0 0 20 20" showPreviousMessage(message);
fill="currentColor" }}
class="w-4 h-4"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" fill="none"
clip-rule="evenodd" viewBox="0 0 24 24"
/> stroke="currentColor"
</svg> stroke-width="2.5"
</button> class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
</button>
<div class="text-xs font-bold self-center dark:text-gray-100"> <div class="text-sm tracking-widest font-semibold self-center dark:text-gray-100">
{siblings.indexOf(message.id) + 1} / {siblings.length} {siblings.indexOf(message.id) + 1}/{siblings.length}
</div> </div>
<button <button
class="self-center dark:hover:text-white hover:text-black transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => { on:click={() => {
showNextMessage(message); showNextMessage(message);
}} }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" fill="none"
clip-rule="evenodd" viewBox="0 0 24 24"
/> stroke="currentColor"
</svg> stroke-width="2.5"
</button> class="size-3.5"
</div> >
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/if}
{/if} {/if}
{#if !readOnly} {#if !readOnly}
<Tooltip content={$i18n.t('Edit')} placement="bottom"> <Tooltip content={$i18n.t('Edit')} placement="bottom">
<button <button
...@@ -372,6 +397,60 @@ ...@@ -372,6 +397,60 @@
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{#if $settings?.chatBubble ?? true}
{#if siblings.length > 1}
<div class="flex self-center">
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
</button>
<div class="text-sm tracking-widest font-semibold self-center dark:text-gray-100">
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/if}
{/if}
</div> </div>
</div> </div>
{/if} {/if}
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { Collapsible } from 'bits-ui'; import { Collapsible } from 'bits-ui';
import { setDefaultModels } from '$lib/apis/configs'; import { setDefaultModels } from '$lib/apis/configs';
import { models, showSettings, settings, user } from '$lib/stores'; import { models, showSettings, settings, user, mobile } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Selector from './ModelSelector/Selector.svelte'; import Selector from './ModelSelector/Selector.svelte';
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
} }
</script> </script>
<div class="flex flex-col mt-0.5 w-full"> <div class="flex flex-col w-full items-center md:items-start">
{#each selectedModels as selectedModel, selectedModelIdx} {#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex w-full max-w-fit"> <div class="flex w-full max-w-fit">
<div class="overflow-hidden w-full"> <div class="overflow-hidden w-full">
...@@ -108,7 +108,7 @@ ...@@ -108,7 +108,7 @@
{/each} {/each}
</div> </div>
{#if showSetDefault} {#if showSetDefault && !$mobile}
<div class="text-left mt-0.5 ml-1 text-[0.7rem] text-gray-500"> <div class="text-left mt-0.5 ml-1 text-[0.7rem] text-gray-500">
<button on:click={saveDefaultModel}> {$i18n.t('Set as default')}</button> <button on:click={saveDefaultModel}> {$i18n.t('Set as default')}</button>
</div> </div>
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
import { cancelOllamaRequest, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; import { cancelOllamaRequest, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { user, MODEL_DOWNLOAD_POOL, models } from '$lib/stores'; import { user, MODEL_DOWNLOAD_POOL, models, mobile } from '$lib/stores';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { capitalizeFirstLetter, getModels, splitStream } from '$lib/utils'; import { capitalizeFirstLetter, getModels, splitStream } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
export let items = [{ value: 'mango', label: 'Mango' }]; export let items = [{ value: 'mango', label: 'Mango' }];
export let className = ' w-[32rem]'; export let className = ' w-[30rem]';
let show = false; let show = false;
...@@ -201,10 +201,11 @@ ...@@ -201,10 +201,11 @@
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" /> <ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</div> </div>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
class=" z-40 {className} max-w-[calc(100vw-1rem)] justify-start rounded-lg bg-white dark:bg-gray-900 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/50 outline-none " class=" z-40 {className} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/50 outline-none "
transition={flyAndScale} transition={flyAndScale}
side={'bottom-start'} side={$mobile ? 'bottom' : 'bottom-start'}
sideOffset={4} sideOffset={4}
> >
<slot> <slot>
...@@ -224,11 +225,11 @@ ...@@ -224,11 +225,11 @@
<hr class="border-gray-100 dark:border-gray-800" /> <hr class="border-gray-100 dark:border-gray-800" />
{/if} {/if}
<div class="px-3 my-2 max-h-72 overflow-y-auto scrollbar-none"> <div class="px-3 my-2 max-h-64 overflow-y-auto scrollbar-hidden">
{#each filteredItems as item} {#each filteredItems as item}
<button <button
aria-label="model-item" aria-label="model-item"
class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg cursor-pointer data-[highlighted]:bg-muted" class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
on:click={() => { on:click={() => {
value = item.value; value = item.value;
...@@ -312,7 +313,7 @@ ...@@ -312,7 +313,7 @@
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user.role === 'admin'} {#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user.role === 'admin'}
<button <button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg cursor-pointer data-[highlighted]:bg-muted" class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
on:click={() => { on:click={() => {
pullModelHandler(); pullModelHandler();
}} }}
...@@ -406,12 +407,12 @@ ...@@ -406,12 +407,12 @@
</DropdownMenu.Root> </DropdownMenu.Root>
<style> <style>
.scrollbar-none:active::-webkit-scrollbar-thumb, .scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-none:focus::-webkit-scrollbar-thumb, .scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-none:hover::-webkit-scrollbar-thumb { .scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible; visibility: visible;
} }
.scrollbar-none::-webkit-scrollbar-thumb { .scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden; visibility: hidden;
} }
</style> </style>
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
</script> </script>
<div class="flex flex-col h-full justify-between text-sm"> <div class="flex flex-col h-full justify-between text-sm">
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]"> <div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[25rem]">
<input <input
id="profile-image-input" id="profile-image-input"
bind:this={profileImageInputElement} bind:this={profileImageInputElement}
......
...@@ -84,7 +84,7 @@ ...@@ -84,7 +84,7 @@
{#if keepAlive !== null} {#if keepAlive !== null}
<div class="flex mt-1 space-x-2"> <div class="flex mt-1 space-x-2">
<input <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" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text" type="text"
placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")} placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")}
bind:value={keepAlive} bind:value={keepAlive}
......
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