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

View File

@ -30,6 +30,7 @@ class Folder(Base):
items = Column(JSON, nullable=True) items = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True) meta = Column(JSON, nullable=True)
is_expanded = Column(Boolean, default=False) is_expanded = Column(Boolean, default=False)
system_prompt = Column(Text, nullable=True)
created_at = Column(BigInteger) created_at = Column(BigInteger)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
@ -42,6 +43,7 @@ class FolderModel(BaseModel):
items: Optional[dict] = None items: Optional[dict] = None
meta: Optional[dict] = None meta: Optional[dict] = None
is_expanded: bool = False is_expanded: bool = False
system_prompt: Optional[str] = None
created_at: int created_at: int
updated_at: int updated_at: int
@ -55,6 +57,7 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel): class FolderForm(BaseModel):
name: str name: str
system_prompt: Optional[str] = None
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@ -70,6 +73,7 @@ class FolderTable:
"user_id": user_id, "user_id": user_id,
"name": name, "name": name,
"parent_id": parent_id, "parent_id": parent_id,
"system_prompt": None,
"created_at": int(time.time()), "created_at": int(time.time()),
"updated_at": int(time.time()), "updated_at": int(time.time()),
} }
@ -236,6 +240,49 @@ class FolderTable:
log.error(f"update_folder: {e}") log.error(f"update_folder: {e}")
return 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( def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True self, id: str, user_id: str, delete_chats=True
) -> bool: ) -> 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, detail=ERROR_MESSAGES.NOT_FOUND,
) )
############################ ############################
# Update Folder Name By Id # Update Folder Details By Id
############################ ############################
@router.post("/{id}/update") @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) id: str, form_data: FolderForm, user=Depends(get_verified_user)
): ):
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder: 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: try:
folder = Folders.update_folder_name_by_id_and_user_id( updated_folder = Folders.update_folder_details_by_id_and_user_id(
id, user.id, form_data.name id=id,
user_id=user.id,
name=form_data.name,
system_prompt=form_data.system_prompt,
) )
if updated_folder:
return 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: except Exception as e:
log.exception(e) log.exception(e)
log.error(f"Error updating folder: {id}") 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.users import UserModel
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.socket.main import ( from open_webui.socket.main import (
sio, sio,
@ -165,6 +167,29 @@ async def generate_chat_completion(
bypass_filter: bool = False, bypass_filter: bool = False,
): ):
log.debug(f"generate_chat_completion: {form_data}") 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: if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True bypass_filter = True

View File

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

View File

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

View File

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

View File

@ -2,7 +2,9 @@
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked'; 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 { onMount, getContext } from 'svelte';
import { blur, fade } from 'svelte/transition'; import { blur, fade } from 'svelte/transition';
@ -80,6 +82,27 @@
</Tooltip> </Tooltip>
{/if} {/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 <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" 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(); 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 { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
@ -16,6 +16,9 @@
import EyeSlash from '$lib/components/icons/EyeSlash.svelte'; import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import MessageInput from './MessageInput.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'); const i18n = getContext('i18n');
export let transparentBackground = false; export let transparentBackground = false;
@ -103,6 +106,27 @@
</Tooltip> </Tooltip>
{/if} {/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 <div
class="w-full text-3xl text-gray-800 dark:text-gray-100 text-center flex items-center gap-4 font-primary" 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, socket,
config, config,
isApp, isApp,
models models,
pendingFolderId,
pendingFolderName,
folders,
refreshSidebar
} from '$lib/stores'; } from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -77,7 +81,6 @@
let chatListLoading = false; let chatListLoading = false;
let allChatsLoaded = false; let allChatsLoaded = false;
let folders = {};
let newFolderId = null; let newFolderId = null;
const initFolders = async () => { const initFolders = async () => {
@ -86,15 +89,15 @@
return []; return [];
}); });
folders = {}; let newFolders = {};
// First pass: Initialize all folder entries // First pass: Initialize all folder entries
for (const folder of folderList) { for (const folder of folderList) {
// Ensure folder is added to folders with its data // 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) { if (newFolderId && folder.id === newFolderId) {
folders[folder.id].new = true; newFolders[folder.id].new = true;
newFolderId = null; newFolderId = null;
} }
} }
@ -103,30 +106,31 @@
for (const folder of folderList) { for (const folder of folderList) {
if (folder.parent_id) { if (folder.parent_id) {
// Ensure the parent folder is initialized if it doesn't exist // Ensure the parent folder is initialized if it doesn't exist
if (!folders[folder.parent_id]) { if (!newFolders[folder.parent_id]) {
folders[folder.parent_id] = {}; // Create a placeholder if not already present 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 // Initialize childrenIds array if it doesn't exist and add the current folder id
folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds newFolders[folder.parent_id].childrenIds = newFolders[folder.parent_id].childrenIds
? [...folders[folder.parent_id].childrenIds, folder.id] ? [...newFolders[folder.parent_id].childrenIds, folder.id]
: [folder.id]; : [folder.id];
// Sort the children by updated_at field // Sort the children by updated_at field
folders[folder.parent_id].childrenIds.sort((a, b) => { newFolders[folder.parent_id].childrenIds.sort((a, b) => {
return folders[b].updated_at - folders[a].updated_at; 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 === '') { if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.')); toast.error($i18n.t('Folder name cannot be empty.'));
return; 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 (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// If a folder with the same name already exists, append a number to the name // If a folder with the same name already exists, append a number to the name
let i = 1; let i = 1;
@ -138,18 +142,17 @@
name = `${name} ${i}`; name = `${name} ${i}`;
} }
// Add a dummy folder to the list to show the user that the folder is being created // Add a dummy folder to the list to show the user that the folder is being created
const tempId = uuidv4(); const tempId = uuidv4();
folders = { folders.set({
...folders, ...$folders,
tempId: { tempId: {
id: tempId, id: tempId,
name: name, name: name,
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now() updated_at: Date.now()
} }
}; });
const res = await createNewFolder(localStorage.token, name).catch((error) => { const res = await createNewFolder(localStorage.token, name).catch((error) => {
toast.error(`${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 () => { const initChannels = async () => {
await channels.set(await getChannels(localStorage.token)); await channels.set(await getChannels(localStorage.token));
}; };
@ -181,6 +197,8 @@
scrollPaginationEnabled.set(true); scrollPaginationEnabled.set(true);
}; };
refreshSidebar.set(initChatList);
const loadMoreChats = async () => { const loadMoreChats = async () => {
chatListLoading = true; chatListLoading = true;
@ -751,7 +769,7 @@
initChatList(); initChatList();
} }
} else if (type === 'folder') { } else if (type === 'folder') {
if (folders[id].parent_id === null) { if ($folders[id].parent_id === null) {
return; return;
} }
@ -763,7 +781,7 @@
); );
if (res) { if (res) {
await initFolders(); initChatList();
} }
} }
}} }}
@ -844,9 +862,9 @@
</div> </div>
{/if} {/if}
{#if folders} {#if $folders}
<Folders <Folders
{folders} folders={$folders}
on:import={(e) => { on:import={(e) => {
const { folderId, items } = e.detail; const { folderId, items } = e.detail;
importChatHandler(items, false, folderId); importChatHandler(items, false, folderId);
@ -857,6 +875,7 @@
on:change={async () => { on:change={async () => {
initChatList(); initChatList();
}} }}
on:createChatInFolder={createChatInFolderHandler}
/> />
{/if} {/if}

View File

@ -1,11 +1,17 @@
<script lang="ts"> <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(); const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte'; const i18n = getContext('i18n');
export let folders = {}; export let folders = {};
let folderList = []; let folderList = [];
// Get the list of folders that have no parent, sorted by name alphabetically // Get the list of folders that have no parent, sorted by name alphabetically
$: folderList = Object.keys(folders) $: folderList = Object.keys(folders)
.filter((key) => folders[key].parent_id === null) .filter((key) => folders[key].parent_id === null)
@ -15,8 +21,63 @@
sensitivity: 'base' 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> </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)} {#each folderList as folderId (folderId)}
<RecursiveFolder <RecursiveFolder
className="" className=""
@ -31,5 +92,7 @@
on:change={(e) => { on:change={(e) => {
dispatch('change', e.detail); dispatch('change', e.detail);
}} }}
on:editFolder={handleEditFolderEvent}
on:createChatInFolder={handleCreateChatInFolder}
/> />
{/each} {/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 <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" 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={() => { on:click={() => {
dispatch('rename'); dispatch('editFolder');
}} }}
> >
<Pencil strokeWidth="2" /> <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>
<DropdownMenu.Item <DropdownMenu.Item

View File

@ -1,6 +1,5 @@
<script> <script>
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte'; import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -12,13 +11,18 @@
import ChevronRight from '../../icons/ChevronRight.svelte'; import ChevronRight from '../../icons/ChevronRight.svelte';
import Collapsible from '../../common/Collapsible.svelte'; import Collapsible from '../../common/Collapsible.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte'; import DragGhost from '$lib/components/common/DragGhost.svelte';
import Plus from '../../icons/Plus.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte'; import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.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 { import {
deleteFolderById, deleteFolderById,
updateFolderIsExpandedById, updateFolderIsExpandedById,
updateFolderNameById,
updateFolderParentIdById updateFolderParentIdById
} from '$lib/apis/folders'; } from '$lib/apis/folders';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -28,6 +32,7 @@
importChat, importChat,
updateChatFolderIdById updateChatFolderIdById
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import ChatItem from './ChatItem.svelte'; import ChatItem from './ChatItem.svelte';
import FolderMenu from './Folders/FolderMenu.svelte'; import FolderMenu from './Folders/FolderMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -43,13 +48,9 @@
let folderElement; let folderElement;
let edit = false;
let draggedOver = false; let draggedOver = false;
let dragged = false; let dragged = false;
let name = '';
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -172,6 +173,7 @@
const onDragStart = (event) => { const onDragStart = (event) => {
event.stopPropagation(); event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0); event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred // Set the data to be transferred
@ -196,7 +198,6 @@
const onDragEnd = (event) => { const onDragEnd = (event) => {
event.stopPropagation(); event.stopPropagation();
folderElement.style.opacity = '1'; // Reset visual cue after drag folderElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false; dragged = false;
}; };
@ -216,17 +217,11 @@
folderElement.addEventListener('dragend', onDragEnd); folderElement.addEventListener('dragend', onDragEnd);
} }
if (folders[folderId]?.new) {
delete folders[folderId].new;
await tick();
editHandler();
}
}); });
onDestroy(() => { onDestroy(() => {
if (folderElement) { if (folderElement) {
folderElement.addEventListener('dragover', onDragOver); folderElement.removeEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop); folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave); 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 isExpandedUpdateHandler = async () => {
const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch( const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch(
(error) => { (error) => {
@ -300,21 +265,6 @@
$: isExpandedUpdateDebounceHandler(open); $: 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 exportHandler = async () => {
const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => { const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
@ -383,9 +333,6 @@
<button <button
id="folder-{folderId}-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" 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"> <div class="text-gray-300 dark:text-gray-600">
{#if open} {#if open}
@ -396,64 +343,41 @@
</div> </div>
<div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1"> <div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1">
{#if edit} {folders[folderId].name}
<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}
</div> </div>
<button <div class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center gap-1 dark:text-gray-300">
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300" <!-- Plus Button -->
on:pointerup={(e) => { <button
class="p-0.5 dark:hover:bg-gray-850 hover:bg-gray-200 rounded-lg touch-auto" on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
dispatch('createChatInFolder', { folderId });
}} }}
> on:pointerup={(e) => {
<FolderMenu e.stopPropagation();
on:rename={() => { }}
// Requires a timeout to prevent the click event from closing the dropdown title={$i18n.t('Create new chat in folder')}
setTimeout(() => { >
editHandler(); <Plus className="size-3.5" strokeWidth="2.5" />
}, 200); </button>
}}
on:delete={() => { <!-- Three-dot Menu -->
showDeleteConfirm = true; <FolderMenu
}} on:editFolder={() => {
on:export={() => { dispatch('editFolder', folders[folderId]);
exportHandler(); }}
}} on:delete={() => {
> showDeleteConfirm = true;
<button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}> }}
<EllipsisHorizontal className="size-4" strokeWidth="2.5" /> on:export={() => {
</button> exportHandler();
</FolderMenu> }}
</button> >
<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> </button>
</div> </div>
@ -486,6 +410,12 @@
on:change={(e) => { on:change={(e) => {
dispatch('change', e.detail); dispatch('change', e.detail);
}} }}
on:editFolder={(e) => {
dispatch('editFolder', e.detail);
}}
on:createChatInFolder={(e) => {
dispatch('createChatInFolder', e.detail);
}}
/> />
{/each} {/each}
{/if} {/if}

View File

@ -46,6 +46,11 @@ export const TTSWorker = writable(null);
export const chatId = writable(''); export const chatId = writable('');
export const chatTitle = 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 channels = writable([]);
export const chats = writable(null); export const chats = writable(null);
export const pinnedChats = writable([]); export const pinnedChats = writable([]);