diff --git a/backend/open_webui/migrations/versions/3c9b0ca343fd_add_knowledge_directory_table.py b/backend/open_webui/migrations/versions/3c9b0ca343fd_add_knowledge_directory_table.py new file mode 100644 index 0000000000..a9673b4e1c --- /dev/null +++ b/backend/open_webui/migrations/versions/3c9b0ca343fd_add_knowledge_directory_table.py @@ -0,0 +1,62 @@ +"""add knowledge_directory table + +Revision ID: 3c9b0ca343fd +Revises: a0b1c2d3e4f5 +Create Date: 2026-05-13 21:58:40.832482 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '3c9b0ca343fd' +down_revision: Union[str, None] = 'a0b1c2d3e4f5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create knowledge_directory table + op.create_table( + 'knowledge_directory', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('knowledge_id', sa.Text(), nullable=False), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'), + ) + op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id']) + op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id']) + + # Add directory_id column to knowledge_file + with op.batch_alter_table('knowledge_file') as batch: + batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True)) + batch.create_foreign_key( + 'fk_knowledge_file_directory_id', + 'knowledge_directory', + ['directory_id'], + ['id'], + ondelete='SET NULL', + ) + batch.create_index('ix_knowledge_file_directory_id', ['directory_id']) + + +def downgrade() -> None: + # Remove directory_id from knowledge_file + with op.batch_alter_table('knowledge_file') as batch: + batch.drop_index('ix_knowledge_file_directory_id') + batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey') + batch.drop_column('directory_id') + + # Drop knowledge_directory table + op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory') + op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory') + op.drop_table('knowledge_directory') diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 3a7ceb629e..75825a10bc 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -20,6 +20,7 @@ from sqlalchemy import ( BigInteger, Column, ForeignKey, + Index, String, Text, UniqueConstraint, @@ -56,6 +57,25 @@ class Knowledge(Base): updated_at = Column(BigInteger) +class KnowledgeDirectory(Base): + __tablename__ = 'knowledge_directory' + + id = Column(Text, unique=True, primary_key=True) + knowledge_id = Column(Text, ForeignKey('knowledge.id', ondelete='CASCADE'), nullable=False) + parent_id = Column(Text, ForeignKey('knowledge_directory.id', ondelete='CASCADE'), nullable=True) + name = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + UniqueConstraint('knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'), + Index('ix_knowledge_directory_knowledge_id', 'knowledge_id'), + Index('ix_knowledge_directory_parent_id', 'parent_id'), + ) + + class KnowledgeModel(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -80,18 +100,23 @@ class KnowledgeFile(Base): knowledge_id = Column(Text, ForeignKey('knowledge.id', ondelete='CASCADE'), nullable=False) file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False) + directory_id = Column(Text, ForeignKey('knowledge_directory.id', ondelete='SET NULL'), nullable=True) user_id = Column(Text, nullable=False) created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) - __table_args__ = (UniqueConstraint('knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'),) + __table_args__ = ( + UniqueConstraint('knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'), + Index('ix_knowledge_file_directory_id', 'directory_id'), + ) class KnowledgeFileModel(BaseModel): id: str knowledge_id: str file_id: str + directory_id: Optional[str] = None user_id: str created_at: int # timestamp in epoch @@ -100,6 +125,24 @@ class KnowledgeFileModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class KnowledgeDirectoryModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + knowledge_id: str + parent_id: Optional[str] = None + name: str + user_id: str + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + +class KnowledgeDirectoryForm(BaseModel): + name: str + parent_id: Optional[str] = None + + #################### # Forms #################### @@ -132,6 +175,8 @@ class KnowledgeListResponse(BaseModel): class KnowledgeFileListResponse(BaseModel): items: list[FileUserResponse] + directories: list[KnowledgeDirectoryModel] = Field(default_factory=list) + breadcrumbs: list[KnowledgeDirectoryModel] = Field(default_factory=list) total: int @@ -468,6 +513,14 @@ class KnowledgeTable: .filter(KnowledgeFile.knowledge_id == knowledge_id) ) + # Filter by directory_id (None = root level) + directory_id = filter.get('directory_id') if filter else None + if directory_id: + stmt = stmt.filter(KnowledgeFile.directory_id == directory_id) + elif filter and 'directory_id' in filter: + # Explicit None = root level only + stmt = stmt.filter(KnowledgeFile.directory_id.is_(None)) + # Default sort: updated_at descending primary_sort = File.updated_at.desc() @@ -522,7 +575,19 @@ class KnowledgeTable: ) ) - return KnowledgeFileListResponse(items=files, total=total) + return KnowledgeFileListResponse( + items=files, + directories=await self.get_directories( + knowledge_id, + parent_id=filter.get('directory_id') if filter else None, + db=db, + ), + breadcrumbs=await self.get_directory_breadcrumbs( + filter.get('directory_id') if filter else None, + db=db, + ), + total=total, + ) except Exception as e: print(e) return KnowledgeFileListResponse(items=[], total=0) @@ -554,6 +619,7 @@ class KnowledgeTable: knowledge_id: str, file_id: str, user_id: str, + directory_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> Optional[KnowledgeFileModel]: async with get_async_db_context(db) as db: @@ -562,6 +628,7 @@ class KnowledgeTable: 'id': str(uuid.uuid4()), 'knowledge_id': knowledge_id, 'file_id': file_id, + 'directory_id': directory_id, 'user_id': user_id, 'created_at': int(time.time()), 'updated_at': int(time.time()), @@ -686,5 +753,269 @@ class KnowledgeTable: except Exception: return False + # ── Directory CRUD ──────────────────────────────────────────────── + + async def create_directory( + self, + knowledge_id: str, + name: str, + user_id: str, + parent_id: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> Optional[KnowledgeDirectoryModel]: + async with get_async_db_context(db) as db: + try: + now = int(time.time()) + directory = KnowledgeDirectory( + id=str(uuid.uuid4()), + knowledge_id=knowledge_id, + parent_id=parent_id, + name=name, + user_id=user_id, + created_at=now, + updated_at=now, + ) + db.add(directory) + await db.commit() + await db.refresh(directory) + return KnowledgeDirectoryModel.model_validate(directory) + except Exception as e: + log.exception(e) + return None + + async def get_directories( + self, + knowledge_id: str, + parent_id: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> list[KnowledgeDirectoryModel]: + """List directories at a given level (parent_id=None for root).""" + async with get_async_db_context(db) as db: + stmt = select(KnowledgeDirectory).filter( + KnowledgeDirectory.knowledge_id == knowledge_id + ) + if parent_id: + stmt = stmt.filter(KnowledgeDirectory.parent_id == parent_id) + else: + stmt = stmt.filter(KnowledgeDirectory.parent_id.is_(None)) + + stmt = stmt.order_by(KnowledgeDirectory.name.asc()) + result = await db.execute(stmt) + return [KnowledgeDirectoryModel.model_validate(d) for d in result.scalars().all()] + + async def get_directory_by_id( + self, directory_id: str, db: Optional[AsyncSession] = None + ) -> Optional[KnowledgeDirectoryModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(KnowledgeDirectory).filter_by(id=directory_id) + ) + directory = result.scalars().first() + return KnowledgeDirectoryModel.model_validate(directory) if directory else None + + async def get_directory_breadcrumbs( + self, + directory_id: Optional[str], + db: Optional[AsyncSession] = None, + ) -> list[KnowledgeDirectoryModel]: + """Walk up the parent chain to build breadcrumbs (root first).""" + if not directory_id: + return [] + + async with get_async_db_context(db) as db: + breadcrumbs = [] + current_id = directory_id + seen = set() + + while current_id and current_id not in seen: + seen.add(current_id) + result = await db.execute( + select(KnowledgeDirectory).filter_by(id=current_id) + ) + directory = result.scalars().first() + if not directory: + break + breadcrumbs.append(KnowledgeDirectoryModel.model_validate(directory)) + current_id = directory.parent_id + + breadcrumbs.reverse() # root first + return breadcrumbs + + async def rename_directory( + self, + directory_id: str, + name: str, + db: Optional[AsyncSession] = None, + ) -> Optional[KnowledgeDirectoryModel]: + async with get_async_db_context(db) as db: + try: + await db.execute( + update(KnowledgeDirectory) + .filter_by(id=directory_id) + .values(name=name, updated_at=int(time.time())) + ) + await db.commit() + return await self.get_directory_by_id(directory_id, db=db) + except Exception as e: + log.exception(e) + return None + + async def move_directory( + self, + directory_id: str, + new_parent_id: Optional[str], + db: Optional[AsyncSession] = None, + ) -> Optional[KnowledgeDirectoryModel]: + """Move a directory to a new parent, with cycle detection.""" + async with get_async_db_context(db) as db: + try: + # Cycle detection: walk up from new_parent_id to ensure + # we don't encounter directory_id + if new_parent_id: + current = new_parent_id + seen = set() + while current and current not in seen: + if current == directory_id: + return None # Would create a cycle + seen.add(current) + result = await db.execute( + select(KnowledgeDirectory.parent_id).filter_by(id=current) + ) + row = result.first() + current = row[0] if row else None + + await db.execute( + update(KnowledgeDirectory) + .filter_by(id=directory_id) + .values(parent_id=new_parent_id, updated_at=int(time.time())) + ) + await db.commit() + return await self.get_directory_by_id(directory_id, db=db) + except Exception as e: + log.exception(e) + return None + + async def update_directory( + self, + directory_id: str, + name: Optional[str] = None, + parent_id: Optional[str] = '__unset__', + db: Optional[AsyncSession] = None, + ) -> Optional[KnowledgeDirectoryModel]: + """Update directory name and/or parent. Pass parent_id=None to move to root.""" + # Handle move if parent_id is being changed + if parent_id != '__unset__': + result = await self.move_directory(directory_id, parent_id, db=db) + if result is None: + return None # Cycle detected or error + + if name is not None: + return await self.rename_directory(directory_id, name, db=db) + + return await self.get_directory_by_id(directory_id, db=db) + + async def delete_directory( + self, + directory_id: str, + move_files_to_parent: bool = True, + db: Optional[AsyncSession] = None, + ) -> bool: + """ + Delete a directory. + - If move_files_to_parent=True: files move to parent dir (or root) + - If move_files_to_parent=False: files are also deleted + """ + async with get_async_db_context(db) as db: + try: + # Get the directory to find its parent + result = await db.execute( + select(KnowledgeDirectory).filter_by(id=directory_id) + ) + directory = result.scalars().first() + if not directory: + return False + + parent_id = directory.parent_id + + if move_files_to_parent: + # Move files in this directory to its parent (or root) + await db.execute( + update(KnowledgeFile) + .filter_by(directory_id=directory_id) + .values(directory_id=parent_id) + ) + # Recursively move files from all subdirectories too + await self._move_files_from_subtree(directory_id, parent_id, db=db) + else: + # Delete files in this directory and all subdirectories + await self._delete_files_in_subtree(directory_id, db=db) + + # CASCADE on parent_id will handle deleting subdirectories + await db.execute( + delete(KnowledgeDirectory).filter_by(id=directory_id) + ) + await db.commit() + return True + except Exception as e: + log.exception(e) + return False + + async def _move_files_from_subtree( + self, + directory_id: str, + target_directory_id: Optional[str], + db: AsyncSession, + ) -> None: + """Recursively move all files from a directory subtree to the target.""" + result = await db.execute( + select(KnowledgeDirectory.id).filter_by(parent_id=directory_id) + ) + child_ids = [row[0] for row in result.all()] + + for child_id in child_ids: + await db.execute( + update(KnowledgeFile) + .filter_by(directory_id=child_id) + .values(directory_id=target_directory_id) + ) + await self._move_files_from_subtree(child_id, target_directory_id, db=db) + + async def _delete_files_in_subtree( + self, + directory_id: str, + db: AsyncSession, + ) -> None: + """Recursively delete all files from a directory subtree.""" + await db.execute( + delete(KnowledgeFile).filter_by(directory_id=directory_id) + ) + result = await db.execute( + select(KnowledgeDirectory.id).filter_by(parent_id=directory_id) + ) + child_ids = [row[0] for row in result.all()] + for child_id in child_ids: + await self._delete_files_in_subtree(child_id, db=db) + + async def move_file_to_directory( + self, + knowledge_id: str, + file_id: str, + directory_id: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> bool: + """Move a file to a different directory within the same KB.""" + async with get_async_db_context(db) as db: + try: + await db.execute( + update(KnowledgeFile) + .filter_by(knowledge_id=knowledge_id, file_id=file_id) + .values(directory_id=directory_id, updated_at=int(time.time())) + ) + await db.commit() + return True + except Exception as e: + log.exception(e) + return False + Knowledges = KnowledgeTable() diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index b35f5b793d..ccf99bde58 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -15,6 +15,8 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.files import FileMetadataResponse, FileModel, Files from open_webui.models.groups import Groups from open_webui.models.knowledge import ( + KnowledgeDirectoryForm, + KnowledgeDirectoryModel, KnowledgeFileListResponse, KnowledgeForm, KnowledgeResponse, @@ -558,6 +560,7 @@ async def get_knowledge_files_by_id( view_option: str | None = None, order_by: str | None = None, direction: str | None = None, + directory_id: str | None = Query(None, description='Filter by directory ID. Pass empty string for root.'), page: int | None = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), @@ -599,6 +602,9 @@ async def get_knowledge_files_by_id( filter['order_by'] = order_by if direction: filter['direction'] = direction + # directory_id filtering: present in filter = scope to that directory (None = root) + if directory_id is not None: + filter['directory_id'] = directory_id if directory_id else None return await Knowledges.search_files_by_id(id, user.id, filter=filter, skip=skip, limit=limit, db=db) @@ -610,6 +616,7 @@ async def get_knowledge_files_by_id( class KnowledgeFileIdForm(BaseModel): file_id: str + directory_id: Optional[str] = None @router.post('/{id}/file/add', response_model=KnowledgeFilesResponse | None) @@ -673,7 +680,13 @@ async def add_file_to_knowledge_by_id( ) # Add file to knowledge base - await Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db) + await Knowledges.add_file_to_knowledge_by_id( + knowledge_id=id, + file_id=form_data.file_id, + user_id=user.id, + directory_id=form_data.directory_id, + db=db, + ) except Exception as e: log.debug(e) raise HTTPException( @@ -1117,3 +1130,178 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Asyn media_type='application/zip', headers={'Content-Disposition': content_disposition}, ) + + +############################ +# Directory endpoints +############################ + + +class KnowledgeDirectoryCreateForm(BaseModel): + name: str + parent_id: Optional[str] = None + + +class KnowledgeDirectoryUpdateForm(BaseModel): + name: Optional[str] = None + parent_id: Optional[str] = '__unset__' + + +class KnowledgeFileMoveForm(BaseModel): + file_id: str + directory_id: Optional[str] = None + + +async def _verify_knowledge_write_access( + id: str, user, db: AsyncSession +): + """Verify the user has write access to the knowledge base. Returns the knowledge model.""" + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) + if not knowledge: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if ( + knowledge.user_id != user.id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='write', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + return knowledge + + +@router.post('/{id}/dirs/create', response_model=KnowledgeDirectoryModel) +async def create_knowledge_directory( + id: str, + form_data: KnowledgeDirectoryCreateForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await _verify_knowledge_write_access(id, user, db) + + directory = await Knowledges.create_directory( + knowledge_id=id, + name=form_data.name, + user_id=user.id, + parent_id=form_data.parent_id, + db=db, + ) + if not directory: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to create directory. A directory with this name may already exist at this level.', + ) + return directory + + +@router.post('/{id}/dirs/{dir_id}/update', response_model=KnowledgeDirectoryModel) +async def update_knowledge_directory( + id: str, + dir_id: str, + form_data: KnowledgeDirectoryUpdateForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await _verify_knowledge_write_access(id, user, db) + + # Verify directory belongs to this knowledge base + directory = await Knowledges.get_directory_by_id(dir_id, db=db) + if not directory or directory.knowledge_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + result = await Knowledges.update_directory( + directory_id=dir_id, + name=form_data.name, + parent_id=form_data.parent_id, + db=db, + ) + if not result: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to update directory. This may be caused by a naming conflict or circular move.', + ) + return result + + +@router.delete('/{id}/dirs/{dir_id}/delete') +async def delete_knowledge_directory( + id: str, + dir_id: str, + move_files: bool = Query(True, description='If true, move contained files to parent. If false, delete them.'), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await _verify_knowledge_write_access(id, user, db) + + # Verify directory belongs to this knowledge base + directory = await Knowledges.get_directory_by_id(dir_id, db=db) + if not directory or directory.knowledge_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + success = await Knowledges.delete_directory( + directory_id=dir_id, + move_files_to_parent=move_files, + db=db, + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to delete directory.', + ) + return {'status': True} + + +@router.post('/{id}/file/move') +async def move_file_in_knowledge( + id: str, + form_data: KnowledgeFileMoveForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await _verify_knowledge_write_access(id, user, db) + + # Verify file belongs to this knowledge base + if not await Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # If target directory is set, verify it belongs to this knowledge base + if form_data.directory_id: + directory = await Knowledges.get_directory_by_id(form_data.directory_id, db=db) + if not directory or directory.knowledge_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Target directory not found.', + ) + + success = await Knowledges.move_file_to_directory( + knowledge_id=id, + file_id=form_data.file_id, + directory_id=form_data.directory_id, + db=db, + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to move file.', + ) + return {'status': True} + diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index f314bae634..85e5c6e844 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -201,7 +201,8 @@ export const searchKnowledgeFilesById = async ( viewOption?: string | null = null, orderBy?: string | null = null, direction?: string | null = null, - page: number = 1 + page: number = 1, + directoryId?: string | null = undefined ) => { let error = null; @@ -211,6 +212,10 @@ export const searchKnowledgeFilesById = async ( if (orderBy) searchParams.append('order_by', orderBy); if (direction) searchParams.append('direction', direction); searchParams.append('page', page.toString()); + // directoryId: undefined = don't filter, null = root, string = specific dir + if (directoryId !== undefined) { + searchParams.append('directory_id', directoryId ?? ''); + } const res = await fetch( `${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`, @@ -322,9 +327,17 @@ export const updateKnowledgeAccessGrants = async ( return res; }; -export const addFileToKnowledgeById = async (token: string, id: string, fileId: string) => { +export const addFileToKnowledgeById = async ( + token: string, + id: string, + fileId: string, + directoryId?: string | null +) => { let error = null; + const body: Record = { file_id: fileId }; + if (directoryId) body.directory_id = directoryId; + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/file/add`, { method: 'POST', headers: { @@ -332,9 +345,7 @@ export const addFileToKnowledgeById = async (token: string, id: string, fileId: 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify({ - file_id: fileId - }) + body: JSON.stringify(body) }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -544,3 +555,151 @@ export const exportKnowledgeById = async (token: string, id: string) => { return res; }; + +// ── Directory API ─────────────────────────────────────────────────── + +export const createKnowledgeDirectory = async ( + token: string, + id: string, + name: string, + parentId?: string | null +) => { + let error = null; + + const body: Record = { name }; + if (parentId) body.parent_id = parentId; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/dirs/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateKnowledgeDirectory = async ( + token: string, + id: string, + dirId: string, + form: { name?: string; parent_id?: string | null } +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/dirs/${dirId}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteKnowledgeDirectory = async ( + token: string, + id: string, + dirId: string, + moveFiles: boolean = true +) => { + let error = null; + + const searchParams = new URLSearchParams(); + searchParams.append('move_files', moveFiles.toString()); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/knowledge/${id}/dirs/${dirId}/delete?${searchParams.toString()}`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const moveFileInKnowledge = async ( + token: string, + id: string, + fileId: string, + directoryId?: string | null +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/file/move`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + file_id: fileId, + directory_id: directoryId ?? null + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 64438e23fc..9d5756c0a1 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -32,7 +32,11 @@ updateFileFromKnowledgeById, updateKnowledgeById, updateKnowledgeAccessGrants, - searchKnowledgeFilesById + searchKnowledgeFilesById, + createKnowledgeDirectory, + updateKnowledgeDirectory, + deleteKnowledgeDirectory, + moveFileInKnowledge } from '$lib/apis/knowledge'; import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval'; @@ -44,8 +48,11 @@ import AddContentMenu from './KnowledgeBase/AddContentMenu.svelte'; import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte'; + import NewDirectoryModal from './KnowledgeBase/NewDirectoryModal.svelte'; + import KnowledgeBreadcrumbs from './KnowledgeBase/KnowledgeBreadcrumbs.svelte'; import SyncConfirmDialog from '../../common/ConfirmDialog.svelte'; + import ConfirmDialog from '../../common/ConfirmDialog.svelte'; import Drawer from '$lib/components/common/Drawer.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; @@ -63,6 +70,7 @@ let showAddWebpageModal = false; let showAddTextContentModal = false; + let showNewDirectoryModal = false; let showSyncConfirmModal = false; let showAccessControlModal = false; @@ -101,6 +109,15 @@ let fileItems = null; let fileItemsTotal = null; + // Directory state + let currentDirectoryId: string | null = null; + let directoryItems = []; + let breadcrumbs = []; + + let showDeleteDirectoryConfirm = false; + let pendingDeleteDirectoryId: string | null = null; + let deleteDirectoryContents = true; + const reset = () => { currentPage = 1; }; @@ -156,7 +173,8 @@ viewOption, sortKey, direction, - currentPage + currentPage, + currentDirectoryId ).catch(() => { return null; }); @@ -164,6 +182,8 @@ if (res) { fileItems = res.items; fileItemsTotal = res.total; + directoryItems = res.directories ?? []; + breadcrumbs = res.breadcrumbs ?? []; } return res; }; @@ -531,7 +551,12 @@ }; const addFileHandler = async (fileId) => { - const res = await addFileToKnowledgeById(localStorage.token, id, fileId).catch((e) => { + const res = await addFileToKnowledgeById( + localStorage.token, + id, + fileId, + currentDirectoryId + ).catch((e) => { toast.error(`${e}`); return null; }); @@ -545,6 +570,88 @@ } }; + // Directory handlers + const navigateToDirectory = (directoryId: string | null) => { + currentDirectoryId = directoryId; + currentPage = 1; + selectedFileId = null; + selectedFile = null; + getItemsPage(); + }; + + const createDirectoryHandler = async (name: string) => { + const res = await createKnowledgeDirectory( + localStorage.token, + knowledge.id, + name, + currentDirectoryId + ).catch((e) => { + toast.error(`${e}`); + return null; + }); + + if (res) { + toast.success($i18n.t('Directory created.')); + getItemsPage(); + } + }; + + const renameDirectoryHandler = async (dirId: string, name: string) => { + const res = await updateKnowledgeDirectory(localStorage.token, knowledge.id, dirId, { + name + }).catch((e) => { + toast.error(`${e}`); + return null; + }); + + if (res) { + toast.success($i18n.t('Directory renamed.')); + getItemsPage(); + } + }; + + const confirmDeleteDirectory = (dirId: string) => { + pendingDeleteDirectoryId = dirId; + showDeleteDirectoryConfirm = true; + }; + + const deleteDirectoryHandler = async (moveFiles: boolean) => { + if (!pendingDeleteDirectoryId) return; + + const res = await deleteKnowledgeDirectory( + localStorage.token, + knowledge.id, + pendingDeleteDirectoryId, + moveFiles + ).catch((e) => { + toast.error(`${e}`); + return null; + }); + + if (res) { + toast.success($i18n.t('Directory deleted.')); + getItemsPage(); + } + pendingDeleteDirectoryId = null; + }; + + const moveFileToDirectoryHandler = async (fileId: string, directoryId: string | null) => { + const res = await moveFileInKnowledge( + localStorage.token, + knowledge.id, + fileId, + directoryId + ).catch((e) => { + toast.error(`${e}`); + return null; + }); + + if (res) { + toast.success($i18n.t('File moved.')); + getItemsPage(); + } + }; + const deleteFileHandler = async (fileId) => { try { console.log('Starting file deletion process for:', fileId); @@ -806,6 +913,13 @@ }} /> + { + createDirectoryHandler(e.detail.name); + }} +/> + { if (data.type === 'directory') { uploadDirectoryHandler(); + } else if (data.type === 'new_directory') { + showNewDirectoryModal = true; } else if (data.type === 'web') { showAddWebpageModal = true; } else if (data.type === 'text') { @@ -959,6 +1075,12 @@ + {#if currentDirectoryId !== null} +
+ navigateToDirectory(dirId)} /> +
+ {/if} +
- {#if fileItems.length > 0} + {#if fileItems.length > 0 || directoryItems.length > 0}
{ @@ -1044,6 +1167,11 @@ deleteFileHandler(fileId); }} + onNavigateDirectory={(dirId) => navigateToDirectory(dirId)} + onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)} + onDeleteDirectory={(id) => confirmDeleteDirectory(id)} + onMoveFileToDirectory={(fileId, dirId) => + moveFileToDirectoryHandler(fileId, dirId)} />
@@ -1133,3 +1261,26 @@ {/if}
+ + { + deleteDirectoryHandler(!deleteDirectoryContents); + }} + on:cancel={() => { + pendingDeleteDirectoryId = null; + }} +> +
+ {$i18n.t(`Are you sure you want to delete this directory?`)} +
+ +
+ + +
+ {$i18n.t('Delete all contents inside this directory')} +
+
+
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte index 1b5a8bafe1..62b9f4f4e4 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte @@ -7,6 +7,7 @@ import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte'; import BarsArrowUp from '$lib/components/icons/BarsArrowUp.svelte'; import FolderOpen from '$lib/components/icons/FolderOpen.svelte'; + import NewFolderAlt from '$lib/components/icons/NewFolderAlt.svelte'; import ArrowPath from '$lib/components/icons/ArrowPath.svelte'; import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; @@ -54,6 +55,19 @@
+ + +
+ +
+ + + + {#if writeAccess} +
+ + + +
+
+ + +
+
+
+
+ {/if} +
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte index 56646f57d4..fa11fcf41c 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte @@ -17,21 +17,50 @@ import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; + import DirectoryRow from './DirectoryRow.svelte'; export let knowledge = null; export let selectedFileId = null; export let files = []; + export let directories = []; export let onClick = (fileId) => {}; export let onDelete = (fileId) => {}; + export let onNavigateDirectory = (directoryId: string) => {}; + export let onRenameDirectory = (id: string, name: string) => {}; + export let onDeleteDirectory = (id: string) => {}; + export let onMoveFileToDirectory = (fileId: string, directoryId: string) => {};
+ + {#each directories as dir (dir.id)} + onNavigateDirectory(id)} + onRename={(id, name) => onRenameDirectory(id, name)} + onDelete={(id) => onDeleteDirectory(id)} + onFileDrop={(fileId, directoryId) => onMoveFileToDirectory(fileId, directoryId)} + /> + {/each} + + {#each files as file (file?.id ?? file?.itemId ?? file?.tempId)}
{ + const fileId = file?.id ?? file?.tempId; + if (fileId) { + e.dataTransfer?.setData( + 'application/x-kb-file-move', + JSON.stringify({ fileId }) + ); + } + }} >
{#if file?.status !== 'uploading'} diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/KnowledgeBreadcrumbs.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/KnowledgeBreadcrumbs.svelte new file mode 100644 index 0000000000..d3f152c214 --- /dev/null +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/KnowledgeBreadcrumbs.svelte @@ -0,0 +1,44 @@ + + +
+ + + {#each breadcrumbs as crumb, i} + + + {/each} +
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte new file mode 100644 index 0000000000..c556eee8e8 --- /dev/null +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte @@ -0,0 +1,79 @@ + + + +
+ +
+

{$i18n.t('New Directory')}

+ +
+ + +
+
+
{$i18n.t('Name')}
+ { + if (e.key === 'Enter') submitHandler(); + }} + /> +
+
+ + +
+ + +
+
+