feat: notes

This commit is contained in:
Timothy Jaeryang Baek
2025-05-03 18:16:32 +04:00
parent 7d184c3a14
commit 7fee84c06e
17 changed files with 801 additions and 44 deletions

168
src/lib/apis/notes/index.ts Normal file
View File

@@ -0,0 +1,168 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
type NoteItem = {
title: string;
content: string;
access_control?: null | object;
};
export const createNewNote = async (token: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNotes = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View File

@@ -192,7 +192,7 @@
<svelte:head>
<title>
{$i18n.t('Functions')} | {$WEBUI_NAME}
{$i18n.t('Functions')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@@ -195,7 +195,7 @@
</script>
<svelte:head>
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
<title>#{channel?.name ?? 'Channel'} Open WebUI</title>
</svelte:head>
<div

View File

@@ -1921,7 +1921,7 @@
<svelte:head>
<title>
{$chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>

View File

@@ -47,8 +47,8 @@
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full px-1.5">
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full pl-1.5 pr-1">
<div
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div>

View File

@@ -38,6 +38,7 @@
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
export let className = '';
@@ -65,6 +66,7 @@
let showShareChatModal = false;
let confirmEdit = false;
let editInputFocused = false;
let chatTitle = title;
@@ -133,10 +135,6 @@
dispatch('change');
};
const focusEdit = async (node: HTMLInputElement) => {
node.focus();
};
let itemElement;
let dragged = false;
@@ -213,6 +211,20 @@
chatTitle = '';
}
};
const focusEditInput = async () => {
console.log('focusEditInput');
await tick();
const input = document.getElementById(`chat-title-input-${id}`);
if (input) {
input.focus();
}
};
$: if (confirmEdit) {
focusEditInput();
}
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
@@ -257,11 +269,23 @@
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
>
<input
use:focusEdit
bind:value={chatTitle}
id="chat-title-input-{id}"
bind:value={chatTitle}
class=" bg-transparent w-full outline-hidden mr-10"
on:keydown={chatTitleInputKeydownHandler}
on:focus={() => {
editInputFocused = true;
}}
on:blur={() => {
if (editInputFocused) {
if (chatTitle !== title) {
editChatTitle(id, chatTitle);
}
confirmEdit = false;
chatTitle = '';
}
}}
/>
</div>
{:else}
@@ -311,7 +335,7 @@
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute {className === 'pr-2'
? 'right-[8px]'
: 'right-0'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
to-transparent"
on:mouseenter={(e) => {
@@ -325,28 +349,9 @@
<div
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
>
<Tooltip content={$i18n.t('Confirm')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
editChatTitle(id, chatTitle);
confirmEdit = false;
chatTitle = '';
}}
>
<Check className=" size-3.5" strokeWidth="2.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Cancel')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
confirmEdit = false;
chatTitle = '';
}}
>
<XMark strokeWidth="2.5" />
<Tooltip content={$i18n.t('Generate')}>
<button class=" self-center dark:hover:text-white transition" on:click={() => {}}>
<Sparkles strokeWidth="2" />
</button>
</Tooltip>
</div>
@@ -377,7 +382,7 @@
</Tooltip>
</div>
{:else}
<div class="flex self-center space-x-1 z-10">
<div class="flex self-center z-10 items-end">
<ChatMenu
chatId={id}
cloneChatHandler={() => {
@@ -414,7 +419,7 @@
>
<button
aria-label="Chat Menu"
class=" self-center dark:hover:text-white transition"
class=" self-center dark:hover:text-white transition m-0"
on:click={() => {
dispatch('select');
}}

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import { getNotes } from '$lib/apis/notes';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Spinner from '../common/Spinner.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { capitalizeFirstLetter } from '$lib/utils';
const i18n = getContext('i18n');
let loaded = false;
let importFiles = '';
let query = '';
let notes = [];
let selectedNote = null;
let showDeleteConfirm = false;
const init = async () => {
notes = await getNotes(localStorage.token);
console.log(notes);
};
onMount(async () => {
await init();
loaded = true;
});
</script>
<svelte:head>
<title>
{$i18n.t('Notes')}{$WEBUI_NAME}
</title>
</svelte:head>
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete note?')}
on:confirm={() => {}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>.
</div>
</DeleteConfirmDialog>
{#if notes.length > 0}
<div class="flex flex-col gap-1 my-1.5">
<!-- <div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Notes')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{notes.length}</span>
</div>
</div> -->
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Notes')}
/>
</div>
</div>
</div>
<div class="mb-5 gap-2 grid lg:grid-cols-2 xl:grid-cols-3">
{#each notes as note}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a href={`/notes/${note.id}`}>
<div class=" flex-1 flex items-center gap-2 self-center">
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
</div>
<div class=" text-xs px-0.5">
<Tooltip
content={note?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
</div>
</a>
</div>
</div>
{/each}
</div>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="pb-20 text-center">
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')}
</div>
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
{$i18n.t('Create your first note by clicking on the plus button below.')}
</div>
</div>
</div>
{/if}
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
<div class="flex gap-0.5 justify-end w-full">
<Tooltip content={$i18n.t('Create Note')}>
<button
class="cursor-pointer p-2.5 flex rounded-full bg-gray-50 dark:bg-gray-850 hover:bg-gray-100 dark:hover:bg-gray-800 transition shadow-xl"
type="button"
on:click={async () => {}}
>
<Plus className="size-4.5" strokeWidth="2.5" />
</button>
</Tooltip>
<!-- <button
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
>
<SparklesSolid className="size-4" />
</button> -->
</div>
</div>
<!-- {#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-2">
<input
id="notes-import-input"
bind:files={importFiles}
type="file"
accept=".md"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
console.log(event.target.result);
};
reader.readAsText(importFiles[0]);
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
const notesImportInputElement = document.getElementById('notes-import-input');
if (notesImportInputElement) {
notesImportInputElement.click();
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
<div class=" self-center">
<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="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
{/if} -->
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}

View File

@@ -72,7 +72,7 @@
<svelte:head>
<title>
{$i18n.t('Knowledge')} | {$WEBUI_NAME}
{$i18n.t('Knowledge')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@@ -196,7 +196,7 @@
<svelte:head>
<title>
{$i18n.t('Models')} | {$WEBUI_NAME}
{$i18n.t('Models')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@@ -88,7 +88,7 @@
<svelte:head>
<title>
{$i18n.t('Prompts')} | {$WEBUI_NAME}
{$i18n.t('Prompts')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@@ -164,7 +164,7 @@
<svelte:head>
<title>
{$i18n.t('Tools')} | {$WEBUI_NAME}
{$i18n.t('Tools')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, showSidebar, functions, config } from '$lib/stores';
import { WEBUI_NAME, showSidebar, functions, config, user, showArchivedChats } from '$lib/stores';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
import { page } from '$app/stores';
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
const i18n = getContext('i18n');
@@ -25,9 +26,9 @@
? 'md:max-w-[calc(100%-260px)]'
: ''} max-w-full"
>
<nav class=" px-2.5 pt-1 backdrop-blur-xl w-full drag-region">
<nav class=" px-2 pt-1 backdrop-blur-xl w-full drag-region">
<div class=" flex items-center">
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center self-end">
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<button
id="sidebar-toggle-button"
class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
@@ -41,10 +42,50 @@
</div>
</button>
</div>
<div class="ml-2 py-0.5 self-center flex items-center justify-between w-full">
<div class="">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
>
<a class="min-w-fit rounded-full transition" href="/notes">
{$i18n.t('Notes')}
</a>
</div>
</div>
<div class=" self-center flex items-center gap-1">
{#if $user !== undefined && $user !== null}
<UserMenu
className="max-w-[200px]"
role={$user?.role}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={$user?.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</nav>
<div class=" flex-1 max-h-full overflow-y-auto">
<div class=" pb-1 px-[18px] flex-1 max-h-full overflow-y-auto">
<slot />
</div>
</div>

View File

@@ -0,0 +1,5 @@
<script>
import Notes from '$lib/components/notes/Notes.svelte';
</script>
<Notes />