This commit is contained in:
Classic298 2025-06-21 20:01:09 +00:00 committed by GitHub
commit 8b675f9cb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 486 additions and 175 deletions

View File

@ -0,0 +1,22 @@
"""Add system prompt to folder
Revision ID: e7f8a9b2c5d1
Revises: 9f0c9cd09105
Create Date: 2025-06-21 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f8a9b2c5d1'
down_revision: Union[str, None] = '9f0c9cd09105'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('folder', sa.Column('system_prompt', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('folder', 'system_prompt')

View File

@ -66,6 +66,7 @@ class ChatModel(BaseModel):
class ChatForm(BaseModel):
chat: dict
folder_id: Optional[str] = None
class ChatImportForm(ChatForm):
@ -118,6 +119,7 @@ class ChatTable:
else "New Chat"
),
"chat": form_data.chat,
"folder_id": form_data.folder_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}

View File

@ -30,6 +30,7 @@ class Folder(Base):
items = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
is_expanded = Column(Boolean, default=False)
system_prompt = Column(Text, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -42,6 +43,7 @@ class FolderModel(BaseModel):
items: Optional[dict] = None
meta: Optional[dict] = None
is_expanded: bool = False
system_prompt: Optional[str] = None
created_at: int
updated_at: int
@ -55,6 +57,7 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel):
name: str
system_prompt: Optional[str] = None
model_config = ConfigDict(extra="allow")
@ -70,6 +73,7 @@ class FolderTable:
"user_id": user_id,
"name": name,
"parent_id": parent_id,
"system_prompt": None,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
@ -236,6 +240,49 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def update_folder_details_by_id_and_user_id(
self,
id: str,
user_id: str,
name: str,
system_prompt: Optional[str],
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
# Check if a folder with the new name already exists at the same parent level
# Exclude the current folder being renamed from the check
if name != folder.name:
existing_folder = (
db.query(Folder)
.filter(
Folder.name == name,
Folder.parent_id == folder.parent_id,
Folder.user_id == user_id,
Folder.id != id,
)
.first()
)
if existing_folder:
log.warning(
f"Folder with name '{name}' already exists for user_id '{user_id}' at parent_id '{folder.parent_id}'"
)
return None
folder.name = name
folder.system_prompt = system_prompt
folder.updated_at = int(time.time())
db.commit()
db.refresh(folder)
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"Error updating folder details: {e}")
db.rollback()
return None
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:

View File

@ -105,33 +105,33 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Name By Id
# Update Folder Details By Id
############################
@router.post("/{id}/update")
async def update_folder_name_by_id(
async def update_folder_by_id(
id: str, form_data: FolderForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.update_folder_name_by_id_and_user_id(
id, user.id, form_data.name
updated_folder = Folders.update_folder_details_by_id_and_user_id(
id=id,
user_id=user.id,
name=form_data.name,
system_prompt=form_data.system_prompt,
)
return folder
if updated_folder:
return updated_folder
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(
"Could not update folder. It might be due to a name conflict or invalid data."
),
)
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")

View File

@ -15,6 +15,8 @@ from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.users import UserModel
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.socket.main import (
sio,
@ -165,6 +167,29 @@ async def generate_chat_completion(
bypass_filter: bool = False,
):
log.debug(f"generate_chat_completion: {form_data}")
metadata = form_data.get("metadata", {})
if hasattr(request.state, "metadata"):
metadata.update(request.state.metadata)
form_data["metadata"] = metadata
chat_id = metadata.get("chat_id")
if chat_id and user:
chat = Chats.get_chat_by_id(chat_id)
if chat and chat.folder_id:
folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id)
if folder and folder.system_prompt:
folder_system_message = {
"role": "system",
"content": folder.system_prompt,
}
# Ensure messages list exists
if "messages" not in form_data:
form_data["messages"] = []
form_data["messages"] = [folder_system_message] + form_data["messages"]
log.debug(
f"Prepended system prompt from folder {folder.id} to chat {chat_id}"
)
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True

View File

@ -4,6 +4,8 @@ import { getTimeRange } from '$lib/utils';
export const createNewChat = async (token: string, chat: object) => {
let error = null;
const { folder_id, ...chatData } = chat as any;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
method: 'POST',
headers: {
@ -12,7 +14,8 @@ export const createNewChat = async (token: string, chat: object) => {
authorization: `Bearer ${token}`
},
body: JSON.stringify({
chat: chat
chat: chatData,
...(folder_id && { folder_id: folder_id })
})
})
.then(async (res) => {

View File

@ -92,7 +92,11 @@ export const getFolderById = async (token: string, id: string) => {
return res;
};
export const updateFolderNameById = async (token: string, id: string, name: string) => {
export const updateFolderById = async (
token: string,
id: string,
folderData: { name: string; system_prompt?: string }
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
@ -102,9 +106,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: name
})
body: JSON.stringify(folderData)
})
.then(async (res) => {
if (!res.ok) throw await res.json();

View File

@ -36,7 +36,11 @@
chatTitle,
showArtifacts,
tools,
toolServers
toolServers,
pendingFolderId,
pendingFolderName,
folders,
refreshSidebar
} from '$lib/stores';
import {
convertMessagesToHistory,
@ -65,6 +69,7 @@
getTagsById,
updateChatById
} from '$lib/apis/chats';
import { getFolders } from '$lib/apis/folders';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { createOpenAITextStream } from '$lib/apis/streaming';
@ -308,11 +313,9 @@
}
} else if (type === 'chat:title') {
chatTitle.set(data);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await $refreshSidebar();
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
await $refreshSidebar();
} else if (type === 'source' || type === 'citation') {
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
@ -708,7 +711,8 @@
//////////////////////////
const initNewChat = async () => {
const availableModels = $models
const availableModels = $models
.filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id);
@ -1958,14 +1962,17 @@
history: history,
messages: createMessagesList(history, history.currentId),
tags: [],
folder_id: $pendingFolderId,
timestamp: Date.now()
});
_chatId = chat.id;
await chatId.set(_chatId);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
currentChatPage.set(1);
// Clear the pending folder after creating the chat
$refreshSidebar();
pendingFolderId.set(null);
pendingFolderName.set(null);
window.history.replaceState(history.state, '', `/c/${_chatId}`);
} else {

View File

@ -2,7 +2,9 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
import { config, user, models as _models, temporaryChatEnabled, pendingFolderId, pendingFolderName } from '$lib/stores';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { onMount, getContext } from 'svelte';
import { blur, fade } from 'svelte/transition';
@ -80,6 +82,27 @@
</Tooltip>
{/if}
{#if $pendingFolderName}
<div class="mt-4 mb-4 px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg max-w-xl">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<FolderOpen className="size-4" />
<span class="text-sm font-medium">
{$i18n.t('Next chat will be created in folder:')}
<strong>{$pendingFolderName}</strong>
</span>
<button
class="ml-auto text-blue-500 hover:text-blue-700"
on:click={() => {
pendingFolderId.set(null);
pendingFolderName.set(null);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
{/if}
<div
class=" mt-2 mb-4 text-3xl text-gray-800 dark:text-gray-100 font-medium text-left flex items-center gap-4 font-primary"
>

View File

@ -7,7 +7,7 @@
const dispatch = createEventDispatcher();
import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
import { config, user, models as _models, temporaryChatEnabled, pendingFolderId, pendingFolderName } from '$lib/stores';
import { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
@ -16,6 +16,9 @@
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import MessageInput from './MessageInput.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let transparentBackground = false;
@ -103,6 +106,27 @@
</Tooltip>
{/if}
{#if $pendingFolderName}
<div class="mb-2 px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mx-auto max-w-xl">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<FolderOpen className="size-4" />
<span class="text-sm font-medium">
{$i18n.t('Next chat will be created in folder:')}
<strong>{$pendingFolderName}</strong>
</span>
<button
class="ml-auto text-blue-500 hover:text-blue-700"
on:click={() => {
pendingFolderId.set(null);
pendingFolderName.set(null);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
{/if}
<div
class="w-full text-3xl text-gray-800 dark:text-gray-100 text-center flex items-center gap-4 font-primary"
>

View File

@ -22,7 +22,11 @@
socket,
config,
isApp,
models
models,
pendingFolderId,
pendingFolderName,
folders,
refreshSidebar
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -77,7 +81,6 @@
let chatListLoading = false;
let allChatsLoaded = false;
let folders = {};
let newFolderId = null;
const initFolders = async () => {
@ -86,15 +89,15 @@
return [];
});
folders = {};
let newFolders = {};
// First pass: Initialize all folder entries
for (const folder of folderList) {
// Ensure folder is added to folders with its data
folders[folder.id] = { ...(folders[folder.id] || {}), ...folder };
newFolders[folder.id] = { ...(newFolders[folder.id] || {}), ...folder };
if (newFolderId && folder.id === newFolderId) {
folders[folder.id].new = true;
newFolders[folder.id].new = true;
newFolderId = null;
}
}
@ -103,30 +106,31 @@
for (const folder of folderList) {
if (folder.parent_id) {
// Ensure the parent folder is initialized if it doesn't exist
if (!folders[folder.parent_id]) {
folders[folder.parent_id] = {}; // Create a placeholder if not already present
if (!newFolders[folder.parent_id]) {
newFolders[folder.parent_id] = {}; // Create a placeholder if not already present
}
// Initialize childrenIds array if it doesn't exist and add the current folder id
folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds
? [...folders[folder.parent_id].childrenIds, folder.id]
newFolders[folder.parent_id].childrenIds = newFolders[folder.parent_id].childrenIds
? [...newFolders[folder.parent_id].childrenIds, folder.id]
: [folder.id];
// Sort the children by updated_at field
folders[folder.parent_id].childrenIds.sort((a, b) => {
return folders[b].updated_at - folders[a].updated_at;
newFolders[folder.parent_id].childrenIds.sort((a, b) => {
return newFolders[b].updated_at - newFolders[a].updated_at;
});
}
}
folders.set(newFolders);
};
const createFolder = async (name = 'Untitled') => {
const createFolder = async (name = '📁 Untitled Folder') => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
const rootFolders = Object.values($folders).filter((folder) => folder.parent_id === null);
if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// If a folder with the same name already exists, append a number to the name
let i = 1;
@ -138,18 +142,17 @@
name = `${name} ${i}`;
}
// Add a dummy folder to the list to show the user that the folder is being created
const tempId = uuidv4();
folders = {
...folders,
folders.set({
...$folders,
tempId: {
id: tempId,
name: name,
created_at: Date.now(),
updated_at: Date.now()
}
};
});
const res = await createNewFolder(localStorage.token, name).catch((error) => {
toast.error(`${error}`);
@ -162,6 +165,19 @@
}
};
const createChatInFolderHandler = async (event) => {
const { folderId } = event.detail;
pendingFolderId.set(folderId);
pendingFolderName.set($folders[folderId].name);
await goto('/');
if ($mobile) {
showSidebar.set(false);
}
};
const initChannels = async () => {
await channels.set(await getChannels(localStorage.token));
};
@ -181,6 +197,8 @@
scrollPaginationEnabled.set(true);
};
refreshSidebar.set(initChatList);
const loadMoreChats = async () => {
chatListLoading = true;
@ -751,7 +769,7 @@
initChatList();
}
} else if (type === 'folder') {
if (folders[id].parent_id === null) {
if ($folders[id].parent_id === null) {
return;
}
@ -763,7 +781,7 @@
);
if (res) {
await initFolders();
initChatList();
}
}
}}
@ -844,9 +862,9 @@
</div>
{/if}
{#if folders}
{#if $folders}
<Folders
{folders}
folders={$folders}
on:import={(e) => {
const { folderId, items } = e.detail;
importChatHandler(items, false, folderId);
@ -857,6 +875,7 @@
on:change={async () => {
initChatList();
}}
on:createChatInFolder={createChatInFolderHandler}
/>
{/if}

View File

@ -1,11 +1,17 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import RecursiveFolder from './RecursiveFolder.svelte';
import EditFolderModal from './Folders/EditFolderModal.svelte';
import { updateFolderById } from '$lib/apis/folders';
const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte';
const i18n = getContext('i18n');
export let folders = {};
let folderList = [];
// Get the list of folders that have no parent, sorted by name alphabetically
$: folderList = Object.keys(folders)
.filter((key) => folders[key].parent_id === null)
@ -15,8 +21,63 @@
sensitivity: 'base'
})
);
let showEditFolderModal = false;
let currentEditingFolder = null;
const handleEditFolderEvent = (event) => {
const folderToEdit = event.detail;
if (folderToEdit && folderToEdit.id) {
currentEditingFolder = { ...folderToEdit };
showEditFolderModal = true;
} else {
console.error('EditFolder event did not provide valid folder data:', event.detail);
toast.error($i18n.t('Failed to open edit dialog: Invalid folder data.'));
}
};
const handleSaveFolderEdit = async (event) => {
const { id, name, system_prompt } = event.detail;
try {
const updatedFolder = await updateFolderById(localStorage.token, id, {
name,
system_prompt
});
if (updatedFolder && updatedFolder.id) {
toast.success($i18n.t('Folder updated successfully'));
showEditFolderModal = false;
currentEditingFolder = null;
dispatch('update', updatedFolder);
} else {
const errorDetail = updatedFolder?.detail || $i18n.t('Unknown error');
toast.error(`${$i18n.t('Failed to update folder')}: ${errorDetail}`);
}
} catch (error) {
console.error('Failed to update folder:', error);
const errorDetail = error?.detail || error?.message || $i18n.t('Unknown error');
toast.error(`${$i18n.t('Failed to update folder')}: ${errorDetail}`);
}
};
const handleCreateChatInFolder = (event) => {
dispatch('createChatInFolder', event.detail);
};
</script>
{#if showEditFolderModal && currentEditingFolder}
<EditFolderModal
bind:show={showEditFolderModal}
folder={currentEditingFolder}
on:saveFolder={handleSaveFolderEdit}
on:close={() => {
showEditFolderModal = false;
currentEditingFolder = null;
}}
/>
{/if}
{#each folderList as folderId (folderId)}
<RecursiveFolder
className=""
@ -31,5 +92,7 @@
on:change={(e) => {
dispatch('change', e.detail);
}}
on:editFolder={handleEditFolderEvent}
on:createChatInFolder={handleCreateChatInFolder}
/>
{/each}

View File

@ -0,0 +1,139 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { user } from '$lib/stores';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
export let show = false;
export let folder = { id: '', name: '', system_prompt: '' };
let currentName = '';
let currentSystemPrompt = '';
let loading = false;
let initialized = false;
// Initialize values once when modal opens
$: if (show && folder && !initialized) {
currentName = folder.name || '';
currentSystemPrompt = folder.system_prompt || '';
initialized = true;
}
// Reset when modal closes
$: if (!show) {
initialized = false;
loading = false;
}
// Auto-focus name input when modal opens
$: if (show && initialized) {
setTimeout(() => {
document.getElementById('folderName')?.focus();
}, 150);
}
const handleSubmit = () => {
if (!currentName.trim()) return;
loading = true;
dispatch('saveFolder', {
id: folder.id,
name: currentName.trim(),
system_prompt: currentSystemPrompt.trim() || null
});
};
const handleClose = () => {
loading = false;
initialized = false;
dispatch('close');
};
</script>
<Modal bind:show on:close={handleClose} size="md">
<div>
<div class="flex justify-between items-center dark:text-gray-100 px-5 pt-4 pb-1.5">
<div class="text-lg font-medium self-center font-primary">
{$i18n.t('Edit Folder')}
</div>
<button
class="self-center p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
on:click={handleClose}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<form
class="flex flex-col w-full px-6 pb-6 md:space-y-5 dark:text-gray-200"
on:submit|preventDefault={handleSubmit}
>
<div class="px-1 space-y-4">
<div>
<label for="folderName" class="block mb-1 text-xs text-gray-500">
{$i18n.t('Folder Name')}
</label>
<input
id="folderName"
class="w-full text-sm bg-transparent placeholder:text-gray-400 dark:placeholder:text-gray-600 outline-none border border-gray-300 dark:border-gray-700 rounded-lg p-2 focus:ring-1 focus:ring-blue-500"
type="text"
bind:value={currentName}
placeholder={$i18n.t('Enter folder name')}
required
/>
</div>
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
<div>
<label for="systemPrompt" class="block mb-1 text-xs text-gray-500">
{$i18n.t('System Prompt (Optional)')}
</label>
<Textarea
id="systemPrompt"
bind:value={currentSystemPrompt}
placeholder={$i18n.t('Enter a system prompt for all chats in this folder...')}
className="w-full text-sm bg-transparent placeholder:text-gray-400 dark:placeholder:text-gray-600 outline-none border border-gray-300 dark:border-gray-700 rounded-lg p-2 focus:ring-1 focus:ring-blue-500 min-h-[120px] max-h-[600px] overflow-y-auto resize-none"
rows={5}
/>
</div>
{/if}
</div>
<div class="flex justify-end items-center pt-4 text-sm font-medium gap-2">
<button
type="button"
class="px-3.5 py-1.5 text-sm font-medium dark:bg-gray-700 dark:hover:bg-gray-600 bg-gray-100 hover:bg-gray-200 text-black dark:text-white transition rounded-full"
on:click={handleClose}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-200 transition rounded-full flex items-center"
type="submit"
disabled={loading}
>
{#if loading}
<div class="mr-2 self-center">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-dasharray="32"
stroke-dashoffset="32"
/>
</svg>
</div>
{/if}
{$i18n.t('Save')}
</button>
</div>
</form>
</div>
</Modal>

View File

@ -38,11 +38,11 @@
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('rename');
dispatch('editFolder');
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
<div class="flex items-center">{$i18n.t('Edit Folder')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item

View File

@ -1,6 +1,5 @@
<script>
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@ -12,13 +11,18 @@
import ChevronRight from '../../icons/ChevronRight.svelte';
import Collapsible from '../../common/Collapsible.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Plus from '../../icons/Plus.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { pendingFolderId, pendingFolderName, mobile, showSidebar } from '$lib/stores';
import {
deleteFolderById,
updateFolderIsExpandedById,
updateFolderNameById,
updateFolderParentIdById
} from '$lib/apis/folders';
import { toast } from 'svelte-sonner';
@ -28,6 +32,7 @@
importChat,
updateChatFolderIdById
} from '$lib/apis/chats';
import ChatItem from './ChatItem.svelte';
import FolderMenu from './Folders/FolderMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -43,13 +48,9 @@
let folderElement;
let edit = false;
let draggedOver = false;
let dragged = false;
let name = '';
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
@ -172,6 +173,7 @@
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred
@ -196,7 +198,6 @@
const onDragEnd = (event) => {
event.stopPropagation();
folderElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
};
@ -216,17 +217,11 @@
folderElement.addEventListener('dragend', onDragEnd);
}
if (folders[folderId]?.new) {
delete folders[folderId].new;
await tick();
editHandler();
}
});
onDestroy(() => {
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
folderElement.removeEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave);
@ -250,36 +245,6 @@
}
};
const nameUpdateHandler = async () => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
if (name === folders[folderId].name) {
edit = false;
return;
}
const currentName = folders[folderId].name;
name = name.trim();
folders[folderId].name = name;
const res = await updateFolderNameById(localStorage.token, folderId, name).catch((error) => {
toast.error(`${error}`);
folders[folderId].name = currentName;
return null;
});
if (res) {
folders[folderId].name = name;
toast.success($i18n.t('Folder name updated successfully'));
dispatch('update');
}
};
const isExpandedUpdateHandler = async () => {
const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch(
(error) => {
@ -300,21 +265,6 @@
$: isExpandedUpdateDebounceHandler(open);
const editHandler = async () => {
console.log('Edit');
await tick();
name = folders[folderId].name;
edit = true;
await tick();
const input = document.getElementById(`folder-${folderId}-input`);
if (input) {
input.focus();
}
};
const exportHandler = async () => {
const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
@ -383,9 +333,6 @@
<button
id="folder-{folderId}-button"
class="relative w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:dblclick={() => {
editHandler();
}}
>
<div class="text-gray-300 dark:text-gray-600">
{#if open}
@ -396,64 +343,41 @@
</div>
<div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1">
{#if edit}
<input
id="folder-{folderId}-input"
type="text"
bind:value={name}
on:focus={(e) => {
e.target.select();
}}
on:blur={() => {
nameUpdateHandler();
edit = false;
}}
on:click={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:mousedown={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
nameUpdateHandler();
edit = false;
}
}}
class="w-full h-full bg-transparent text-gray-500 dark:text-gray-500 outline-hidden"
/>
{:else}
{folders[folderId].name}
{/if}
{folders[folderId].name}
</div>
<button
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
on:pointerup={(e) => {
<div class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center gap-1 dark:text-gray-300">
<!-- Plus Button -->
<button
class="p-0.5 dark:hover:bg-gray-850 hover:bg-gray-200 rounded-lg touch-auto" on:click={(e) => {
e.stopPropagation();
dispatch('createChatInFolder', { folderId });
}}
>
<FolderMenu
on:rename={() => {
// Requires a timeout to prevent the click event from closing the dropdown
setTimeout(() => {
editHandler();
}, 200);
}}
on:delete={() => {
showDeleteConfirm = true;
}}
on:export={() => {
exportHandler();
}}
>
<button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}>
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</button>
</FolderMenu>
</button>
on:pointerup={(e) => {
e.stopPropagation();
}}
title={$i18n.t('Create new chat in folder')}
>
<Plus className="size-3.5" strokeWidth="2.5" />
</button>
<!-- Three-dot Menu -->
<FolderMenu
on:editFolder={() => {
dispatch('editFolder', folders[folderId]);
}}
on:delete={() => {
showDeleteConfirm = true;
}}
on:export={() => {
exportHandler();
}}
>
<button class="p-0.5 dark:hover:bg-gray-850 hover:bg-gray-200 rounded-lg touch-auto" on:click={(e) => {}}>
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</button>
</FolderMenu>
</div>
</button>
</div>
@ -486,6 +410,12 @@
on:change={(e) => {
dispatch('change', e.detail);
}}
on:editFolder={(e) => {
dispatch('editFolder', e.detail);
}}
on:createChatInFolder={(e) => {
dispatch('createChatInFolder', e.detail);
}}
/>
{/each}
{/if}

View File

@ -46,6 +46,11 @@ export const TTSWorker = writable(null);
export const chatId = writable('');
export const chatTitle = writable('');
export const refreshSidebar = writable(() => {});
export const pendingFolderId = writable(null);
export const pendingFolderName = writable(null);
export const folders = writable({});
export const channels = writable([]);
export const chats = writable(null);
export const pinnedChats = writable([]);