Compare commits

...

15 commits

Author SHA1 Message Date
Tim Baek
f18092889c
Merge cf6a1300ca into 6f1486ffd0 2025-12-10 07:00:04 +00:00
Timothy Jaeryang Baek
cf6a1300ca enh: experimental chat usage stats endpoint
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-10 02:00:00 -05:00
Timothy Jaeryang Baek
a934dc997e refac: drop legacy kb support 2025-12-10 01:07:12 -05:00
Timothy Jaeryang Baek
ed2db0d04b refac 2025-12-10 00:58:08 -05:00
Timothy Jaeryang Baek
4ecacda28c refac 2025-12-10 00:55:31 -05:00
Timothy Jaeryang Baek
94a8439105 feat/enh: kb file pagination 2025-12-10 00:53:41 -05:00
Timothy Jaeryang Baek
7b0b16ebbd refac 2025-12-09 23:57:46 -05:00
Timothy Jaeryang Baek
49d54c5821 refac 2025-12-09 23:33:48 -05:00
Timothy Jaeryang Baek
0eafc09965 refac: styling 2025-12-09 22:28:38 -05:00
Tim Baek
6f1486ffd0
Merge pull request #19466 from open-webui/dev
Some checks failed
Python CI / Format Backend (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Release / release (push) Has been cancelled
Deploy to HuggingFace Spaces / check-secret (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Deploy to HuggingFace Spaces / deploy (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
0.6.41
2025-12-02 17:28:46 -05:00
Tim Baek
140605e660
Merge pull request #19462 from open-webui/dev
Some checks failed
Release to PyPI / release (push) Has been cancelled
Release / release (push) Has been cancelled
Deploy to HuggingFace Spaces / check-secret (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Deploy to HuggingFace Spaces / deploy (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
0.6.40
2025-11-25 06:01:33 -05:00
Tim Baek
9899293f05
Merge pull request #19448 from open-webui/dev
0.6.39
2025-11-25 05:31:34 -05:00
Tim Baek
e3faec62c5
Merge pull request #19416 from open-webui/dev
Some checks are pending
Release / release (push) Waiting to run
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Release to PyPI / release (push) Waiting to run
0.6.38
2025-11-24 07:00:31 -05:00
Tim Baek
fc05e0a6c5
Merge pull request #19405 from open-webui/dev
Some checks are pending
Release / release (push) Waiting to run
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Release to PyPI / release (push) Waiting to run
chore: format
2025-11-23 22:16:33 -05:00
Tim Baek
fe6783c166
Merge pull request #19030 from open-webui/dev
0.6.37
2025-11-23 22:10:05 -05:00
18 changed files with 717 additions and 496 deletions

View file

@ -238,6 +238,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.hash = hash file.hash = hash
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
@ -249,6 +250,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data} file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception as e: except Exception as e:
@ -260,6 +262,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta} file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception: except Exception:

View file

@ -7,9 +7,14 @@ import uuid
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import File, FileModel, FileMetadataResponse from open_webui.models.files import (
File,
FileModel,
FileMetadataResponse,
FileModelResponse,
)
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -21,6 +26,7 @@ from sqlalchemy import (
Text, Text,
JSON, JSON,
UniqueConstraint, UniqueConstraint,
or_,
) )
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel):
access_control: Optional[dict] = None access_control: Optional[dict] = None
class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
class KnowledgeTable: class KnowledgeTable:
def insert_new_knowledge( def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm self, user_id: str, form_data: KnowledgeForm
@ -232,6 +247,88 @@ class KnowledgeTable:
except Exception: except Exception:
return [] return []
def search_files_by_id(
self,
knowledge_id: str,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
) -> KnowledgeFileListResponse:
try:
with get_db() as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(KnowledgeFile.user_id == user_id)
elif view_option == "shared":
query = query.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=files, total=total)
except Exception as e:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
try: try:
with get_db() as db: with get_db() as db:

View file

@ -302,9 +302,6 @@ class NoteTable:
else: else:
query = query.order_by(Note.updated_at.desc()) query = query.order_by(Note.updated_at.desc())
for key, value in filter.items():
query = query.filter(getattr(Note, key).ilike(f"%{value}%"))
# Count BEFORE pagination # Count BEFORE pagination
total = query.count() total = query.count()

View file

@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle from open_webui.utils.misc import throttle

View file

@ -3,6 +3,7 @@ import logging
from typing import Optional from typing import Optional
from open_webui.utils.misc import get_message_list
from open_webui.socket.main import get_event_emitter from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import ( from open_webui.models.chats import (
ChatForm, ChatForm,
@ -66,6 +67,64 @@ def get_session_user_chat_list(
) )
############################
# GetChatList
############################
@router.get("/stats/usage", response_model=list[ChatTitleIdResponse])
def get_session_user_chat_usage(
user=Depends(get_verified_user),
):
try:
chats = Chats.get_chats_by_user_id(user.id)
chat_stats = []
for chat in chats:
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
if messages_map and message_id:
try:
message_list = get_message_list(messages_map, message_id)
message_count = len(message_list)
last_assistant_message = next(
(
message
for message in reversed(message_list)
if message["role"] == "assistant"
),
None,
)
model_id = (
last_assistant_message.get("model", None)
if last_assistant_message
else None
)
chat_stats.append(
{
"id": chat.id,
"model_id": model_id,
"message_count": message_count,
"tags": chat.meta.get("tags", []),
"model_ids": chat.chat.get("models", []),
"updated_at": chat.updated_at,
"created_at": chat.created_at,
}
)
except Exception as e:
pass
return chat_stats
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# DeleteAllChats # DeleteAllChats
############################ ############################

View file

@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool
import logging import logging
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
KnowledgeFileListResponse,
Knowledges, Knowledges,
KnowledgeForm, KnowledgeForm,
KnowledgeResponse, KnowledgeResponse,
@ -264,6 +265,59 @@ async def update_knowledge_by_id(
) )
############################
# GetKnowledgeFilesById
############################
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
async def get_knowledge_files_by_id(
id: str,
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
page = max(page, 1)
limit = 30
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Knowledges.search_files_by_id(
id, user.id, filter=filter, skip=skip, limit=limit
)
############################ ############################
# AddFileToKnowledge # AddFileToKnowledge
############################ ############################

View file

@ -803,3 +803,7 @@ body {
position: relative; position: relative;
z-index: 0; z-index: 0;
} }
#note-content-container .ProseMirror {
padding-bottom: 2rem; /* space for the bottom toolbar */
}

View file

@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
return res; return res;
}; };
export const searchKnowledgeFilesById = async (
token: string,
id: string,
query?: string | null = null,
viewOption?: string | null = null,
orderBy?: string | null = null,
direction?: string | null = null,
page: number = 1
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
searchParams.append('page', page.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type KnowledgeUpdateForm = { type KnowledgeUpdateForm = {
name?: string; name?: string;
description?: string; description?: string;

View file

@ -1228,7 +1228,7 @@
<div class="px-2.5"> <div class="px-2.5">
<div <div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pb-1 px-1 resize-none h-fit max-h-96 overflow-auto relative {files.length === class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pb-1 px-1 resize-none h-fit max-h-96 overflow-auto {files.length ===
0 0
? atSelectedModel !== undefined ? atSelectedModel !== undefined
? 'pt-1.5' ? 'pt-1.5'

View file

@ -80,41 +80,6 @@
}; };
onMount(async () => { onMount(async () => {
let legacy_documents = knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = knowledge let collections = knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -154,19 +119,7 @@
title: folder.name title: folder.name
})); }));
items = [ items = [...folder_items, ...collections, ...collection_files];
...folder_items,
...collections,
...collection_files,
...legacy_collections,
...legacy_documents
].map((item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });

View file

@ -24,41 +24,6 @@
await knowledge.set(await getKnowledgeBases(localStorage.token)); await knowledge.set(await getKnowledgeBases(localStorage.token));
} }
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = $knowledge let collections = $knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -91,15 +56,7 @@
] ]
: []; : [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map( items = [...collections, ...collection_files];
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
await tick(); await tick();
loaded = true; loaded = true;

View file

@ -44,7 +44,12 @@
: ' text-gray-500 dark:text-gray-400'}" : ' text-gray-500 dark:text-gray-400'}"
type="button" type="button"
on:click={() => { on:click={() => {
value = item.value; if (value === item.value) {
value = null;
} else {
value = item.value;
}
open = false; open = false;
onChange(value); onChange(value);
}} }}

View file

@ -169,7 +169,7 @@
export let documentId = ''; export let documentId = '';
export let className = 'input-prose min-h-fit'; export let className = 'input-prose min-h-fit h-full';
export let placeholder = $i18n.t('Type here...'); export let placeholder = $i18n.t('Type here...');
let _placeholder = placeholder; let _placeholder = placeholder;
@ -1156,5 +1156,5 @@
<div <div
bind:this={element} bind:this={element}
class="relative w-full min-w-full h-full {className} {!editable ? 'cursor-not-allowed' : ''}" class="relative w-full min-w-full {className} {!editable ? 'cursor-not-allowed' : ''}"
/> />

View file

@ -1137,7 +1137,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
<div <div
class=" flex-1 w-full h-full overflow-auto px-3.5 pb-20 relative pt-2.5" class=" flex-1 w-full h-full overflow-auto px-3.5 relative"
id="note-content-container" id="note-content-container"
> >
{#if editing} {#if editing}
@ -1152,7 +1152,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
bind:this={inputElement} bind:this={inputElement}
bind:editor bind:editor
id={`note-${note.id}`} id={`note-${note.id}`}
className="input-prose-sm px-0.5" className="input-prose-sm px-0.5 h-[calc(100%-2rem)]"
json={true} json={true}
bind:value={note.data.content.json} bind:value={note.data.content.json}
html={note.data?.content?.html} html={note.data?.content?.html}
@ -1250,8 +1250,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
{/if} {/if}
</div> </div>
<div class="absolute z-20 bottom-0 right-0 p-3.5 max-w-full w-full flex"> <div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
<div class="flex gap-1 w-full min-w-full justify-between"> <div class="flex flex-col gap-2 justify-end">
{#if recording} {#if recording}
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<VoiceRecording <VoiceRecording
@ -1276,6 +1276,39 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
/> />
</div> </div>
{:else} {:else}
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
<RecordMenu <RecordMenu
onRecord={async () => { onRecord={async () => {
displayMediaRecord = false; displayMediaRecord = false;
@ -1331,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
</Tooltip> </Tooltip>
</RecordMenu> </RecordMenu>
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -31,7 +31,8 @@
removeFileFromKnowledgeById, removeFileFromKnowledgeById,
resetKnowledgeById, resetKnowledgeById,
updateFileFromKnowledgeById, updateFileFromKnowledgeById,
updateKnowledgeById updateKnowledgeById,
searchKnowledgeFilesById
} from '$lib/apis/knowledge'; } from '$lib/apis/knowledge';
import { blobToFile } from '$lib/utils'; import { blobToFile } from '$lib/utils';
@ -43,22 +44,25 @@
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte'; import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte'; import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Drawer from '$lib/components/common/Drawer.svelte'; import Drawer from '$lib/components/common/Drawer.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte';
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
let largeScreen = true; let largeScreen = true;
let pane; let pane;
let showSidepanel = true; let showSidepanel = true;
let minSize = 0;
let showAddTextContentModal = false;
let showSyncConfirmModal = false;
let showAccessControlModal = false;
let minSize = 0;
type Knowledge = { type Knowledge = {
id: string; id: string;
name: string; name: string;
@ -71,52 +75,89 @@
let id = null; let id = null;
let knowledge: Knowledge | null = null; let knowledge: Knowledge | null = null;
let query = ''; let knowledgeId = null;
let showAddTextContentModal = false; let selectedFileId = null;
let showSyncConfirmModal = false; let selectedFile = null;
let showAccessControlModal = false; let selectedFileContent = '';
let inputFiles = null; let inputFiles = null;
let filteredItems = []; let query = '';
$: if (knowledge && knowledge.files) { let viewOption = null;
fuse = new Fuse(knowledge.files, { let sortKey = null;
keys: ['meta.name', 'meta.description'] let direction = null;
});
let currentPage = 1;
let fileItems = null;
let fileItemsTotal = null;
const reset = () => {
currentPage = 1;
};
const init = async () => {
reset();
await getItemsPage();
};
$: if (
knowledgeId !== null &&
query !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
direction !== undefined &&
currentPage !== undefined
) {
getItemsPage();
} }
$: if (fuse) { $: if (
filteredItems = query query !== undefined &&
? fuse.search(query).map((e) => { viewOption !== undefined &&
return e.item; sortKey !== undefined &&
}) direction !== undefined
: (knowledge?.files ?? []); ) {
reset();
} }
let selectedFile = null; const getItemsPage = async () => {
let selectedFileId = null; if (knowledgeId === null) return;
let selectedFileContent = '';
// Add cache object fileItems = null;
let fileContentCache = new Map(); fileItemsTotal = null;
$: if (selectedFileId) { if (sortKey === null) {
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId); direction = null;
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
} }
} else {
selectedFile = null;
}
let fuse = null; const res = await searchKnowledgeFilesById(
let debounceTimeout = null; localStorage.token,
let mediaQuery; knowledge.id,
let dragged = false; query,
let isSaving = false; viewOption,
sortKey,
direction,
currentPage
).catch(() => {
return null;
});
if (res) {
fileItems = res.items;
fileItemsTotal = res.total;
}
return res;
};
const fileSelectHandler = async (file) => {
try {
selectedFile = file;
selectedFileContent = selectedFile?.data?.content || '';
} catch (e) {
toast.error($i18n.t('Failed to load file content.'));
}
};
const createFileFromText = (name, content) => { const createFileFromText = (name, content) => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
@ -163,8 +204,7 @@
return; return;
} }
knowledge.files = [...(knowledge.files ?? []), fileItem]; fileItems = [...(fileItems ?? []), fileItem];
try { try {
// If the file is an audio file, provide the language for STT. // If the file is an audio file, provide the language for STT.
let metadata = null; let metadata = null;
@ -184,7 +224,7 @@
if (uploadedFile) { if (uploadedFile) {
console.log(uploadedFile); console.log(uploadedFile);
knowledge.files = knowledge.files.map((item) => { fileItems = fileItems.map((item) => {
if (item.itemId === tempItemId) { if (item.itemId === tempItemId) {
item.id = uploadedFile.id; item.id = uploadedFile.id;
} }
@ -197,7 +237,7 @@
if (uploadedFile.error) { if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error); console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error); toast.warning(uploadedFile.error);
knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id); fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
} else { } else {
await addFileHandler(uploadedFile.id); await addFileHandler(uploadedFile.id);
} }
@ -413,7 +453,7 @@
toast.success($i18n.t('File added successfully.')); toast.success($i18n.t('File added successfully.'));
} else { } else {
toast.error($i18n.t('Failed to add file.')); toast.error($i18n.t('Failed to add file.'));
knowledge.files = knowledge.files.filter((file) => file.id !== fileId); fileItems = fileItems.filter((file) => file.id !== fileId);
} }
}; };
@ -436,32 +476,38 @@
} }
}; };
let debounceTimeout = null;
let mediaQuery;
let dragged = false;
let isSaving = false;
const updateFileContentHandler = async () => { const updateFileContentHandler = async () => {
if (isSaving) { if (isSaving) {
console.log('Save operation already in progress, skipping...'); console.log('Save operation already in progress, skipping...');
return; return;
} }
isSaving = true; isSaving = true;
try { try {
const fileId = selectedFile.id; const res = await updateFileDataContentById(
const content = selectedFileContent;
// Clear the cache for this file since we're updating it
fileContentCache.delete(fileId);
const res = await updateFileDataContentById(localStorage.token, fileId, content).catch(
(e) => {
toast.error(`${e}`);
}
);
const updatedKnowledge = await updateFileFromKnowledgeById(
localStorage.token, localStorage.token,
id, selectedFile.id,
fileId selectedFileContent
).catch((e) => { ).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null;
}); });
if (res && updatedKnowledge) {
knowledge = updatedKnowledge; if (res) {
toast.success($i18n.t('File content updated successfully.')); toast.success($i18n.t('File content updated successfully.'));
selectedFileId = null;
selectedFile = null;
selectedFileContent = '';
await init();
} }
} finally { } finally {
isSaving = false; isSaving = false;
@ -504,29 +550,6 @@
} }
}; };
const fileSelectHandler = async (file) => {
try {
selectedFile = file;
// Check cache first
if (fileContentCache.has(file.id)) {
selectedFileContent = fileContentCache.get(file.id);
return;
}
const response = await getFileById(localStorage.token, file.id);
if (response) {
selectedFileContent = response.data.content;
// Cache the content
fileContentCache.set(file.id, response.data.content);
} else {
toast.error($i18n.t('No content found in file.'));
}
} catch (e) {
toast.error($i18n.t('Failed to load file content.'));
}
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@ -627,7 +650,6 @@
} }
id = $page.params.id; id = $page.params.id;
const res = await getKnowledgeById(localStorage.token, id).catch((e) => { const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null; return null;
@ -635,6 +657,7 @@
if (res) { if (res) {
knowledge = res; knowledge = res;
knowledgeId = knowledge?.id;
} else { } else {
goto('/workspace/knowledge'); goto('/workspace/knowledge');
} }
@ -705,32 +728,42 @@
}} }}
/> />
<div class="flex flex-col w-full h-full translate-y-1" id="collection-container"> <div class="flex flex-col w-full h-full min-h-full" id="collection-container">
{#if id && knowledge} {#if id && knowledge}
<AccessControlModal <AccessControlModal
bind:show={showAccessControlModal} bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control} bind:accessControl={knowledge.access_control}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePu={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
onChange={() => { onChange={() => {
changeDebounceHandler(); changeDebounceHandler();
}} }}
accessRoles={['read', 'write']} accessRoles={['read', 'write']}
/> />
<div class="w-full mb-2.5"> <div class="w-full px-2">
<div class=" flex w-full"> <div class=" flex w-full">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1"> <div class="flex items-center justify-between w-full">
<div class="w-full"> <div class="w-full flex justify-between items-center">
<input <input
type="text" type="text"
class="text-left w-full font-medium text-2xl font-primary bg-transparent outline-hidden" class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1"
bind:value={knowledge.name} bind:value={knowledge.name}
placeholder={$i18n.t('Knowledge Name')} placeholder={$i18n.t('Knowledge Name')}
on:input={() => { on:input={() => {
changeDebounceHandler(); changeDebounceHandler();
}} }}
/> />
<div class="shrink-0 mr-2.5">
{#if (knowledge?.files ?? []).length}
<div class="text-xs text-gray-500">
{$i18n.t('{{count}} files', {
count: (knowledge?.files ?? []).length
})}
</div>
{/if}
</div>
</div> </div>
<div class="self-center shrink-0"> <div class="self-center shrink-0">
@ -750,7 +783,7 @@
</div> </div>
</div> </div>
<div class="flex w-full px-1"> <div class="flex w-full">
<input <input
type="text" type="text"
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden" class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
@ -765,204 +798,205 @@
</div> </div>
</div> </div>
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3"> <div
{#if largeScreen} class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
<div class="flex-1 flex justify-start w-full h-full max-h-full"> >
{#if selectedFile} <div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class=" flex flex-col w-full"> <div class="flex flex-1 items-center">
<div class="shrink-0 mb-2 flex items-center"> <div class=" self-center ml-1 mr-3">
{#if !showSidepanel} <Search className="size-3.5" />
<div class="-translate-x-2"> </div>
<button <input
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850" class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
on:click={() => { bind:value={query}
pane.expand(); placeholder={`${$i18n.t('Search Collection')}`}
}} on:focus={() => {
> selectedFileId = null;
<ChevronLeft strokeWidth="2.5" /> }}
</button> />
</div>
{/if}
<div class=" flex-1 text-xl font-medium"> <div>
<a <AddContentMenu
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1" on:upload={(e) => {
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'} if (e.detail.type === 'directory') {
target="_blank" uploadDirectoryHandler();
> } else if (e.detail.type === 'text') {
{decodeString(selectedFile?.meta?.name)} showAddTextContentModal = true;
</a> } else {
</div> document.getElementById('files-input').click();
}
<div> }}
<button on:sync={(e) => {
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" showSyncConfirmModal = true;
disabled={isSaving} }}
on:click={() => { />
updateFileContentHandler(); </div>
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
<div
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden"
>
{#key selectedFile.id}
<textarea
class="w-full h-full outline-none resize-none"
bind:value={selectedFileContent}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
{:else}
<div class="h-full flex w-full">
<div class="m-auto text-xs text-center text-gray-200 dark:text-gray-700">
{$i18n.t('Drag and drop a file to upload or select a file to view')}
</div>
</div>
{/if}
</div> </div>
{:else if !largeScreen && selectedFileId !== null} </div>
<Drawer
className="h-full" <div class="px-3 flex justify-between">
show={selectedFileId !== null} <div
onClose={() => { class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
selectedFileId = null; on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}} }}
> >
<div class="flex flex-col justify-start h-full max-h-full p-2"> <div
<div class=" flex flex-col w-full h-full max-h-full"> class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
<div class="shrink-0 mt-1 mb-2 flex items-center"> >
<div class="mr-2"> <DropdownOptions
<button align="start"
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850" className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
on:click={() => { bind:value={viewOption}
selectedFileId = null; items={[
}} { value: null, label: $i18n.t('All') },
> { value: 'created', label: $i18n.t('Created by you') },
<ChevronLeft strokeWidth="2.5" /> { value: 'shared', label: $i18n.t('Shared with you') }
</button> ]}
</div> onChange={(value) => {
<div class=" flex-1 text-xl line-clamp-1"> if (value) {
{selectedFile?.meta?.name} localStorage.workspaceViewOption = value;
</div> } else {
delete localStorage.workspaceViewOption;
}
}}
/>
<div> <DropdownOptions
<button align="start"
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" bind:value={sortKey}
disabled={isSaving} placeholder={$i18n.t('Sort')}
on:click={() => { items={[
updateFileContentHandler(); { value: 'name', label: $i18n.t('Name') },
}} { value: 'created_at', label: $i18n.t('Created At') },
> { value: 'updated_at', label: $i18n.t('Updated At') }
{$i18n.t('Save')} ]}
{#if isSaving} />
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
<div {#if sortKey}
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden" <DropdownOptions
> align="start"
{#key selectedFile.id} bind:value={direction}
<textarea items={[
class="w-full h-full outline-none resize-none" { value: 'asc', label: $i18n.t('Asc') },
bind:value={selectedFileContent} { value: null, label: $i18n.t('Desc') }
placeholder={$i18n.t('Add content here')} ]}
/> />
{/key}
</div>
</div>
</div>
</Drawer>
{/if}
<div
class="{largeScreen ? 'shrink-0 w-72 max-w-72' : 'flex-1'}
flex
py-2
rounded-2xl
border
border-gray-50
h-full
dark:border-gray-850"
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex mb-0.5">
<div class=" self-center ml-1 mr-3">
<Search />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
on:focus={() => {
selectedFileId = null;
}}
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
</div>
</div>
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
small
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
{:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div>
{$i18n.t('No content found')}
</div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
{#if fileItems !== null && fileItemsTotal !== null}
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
<div class="flex-1 flex">
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col min-h-full">
{#if fileItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={fileItems}
{selectedFileId}
onClick={(fileId) => {
selectedFileId = fileId;
if (fileItems) {
const file = fileItems.find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
}
}}
onDelete={(fileId) => {
selectedFileId = null;
selectedFile = null;
deleteFileHandler(fileId);
}}
/>
</div>
{#if fileItemsTotal > 30}
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
{/if}
{:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div>
{$i18n.t('No content found')}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="shrink-0 flex items-center p-2">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-lg line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div>
<button
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
{#key selectedFile.id}
<textarea
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
bind:value={selectedFileContent}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</Drawer>
{/if}
</div>
{:else}
<div class="my-10">
<Spinner className="size-4" />
</div>
{/if}
</div> </div>
{:else} {:else}
<Spinner className="size-5" /> <Spinner className="size-5" />

View file

@ -50,14 +50,14 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-44 rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={4} sideOffset={4}
side="bottom" side="bottom"
align="end" align="end"
transition={flyAndScale} transition={flyAndScale}
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'files' }); dispatch('upload', { type: 'files' });
}} }}
@ -67,7 +67,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'directory' }); dispatch('upload', { type: 'directory' });
}} }}
@ -83,7 +83,7 @@
className="w-full" className="w-full"
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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-xl"
on:click={() => { on:click={() => {
dispatch('sync', { type: 'directory' }); dispatch('sync', { type: 'directory' });
}} }}
@ -94,7 +94,7 @@
</Tooltip> </Tooltip>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'text' }); dispatch('upload', { type: 'text' });
}} }}

View file

@ -1,45 +1,95 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import dayjs from '$lib/dayjs';
const dispatch = createEventDispatcher(); import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import FileItem from '$lib/components/common/FileItem.svelte'; dayjs.extend(duration);
dayjs.extend(relativeTime);
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { capitalizeFirstLetter, formatFileSize } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let selectedFileId = null; export let selectedFileId = null;
export let files = []; export let files = [];
export let small = false; export let onClick = (fileId) => {};
export let onDelete = (fileId) => {};
</script> </script>
<div class=" max-h-full flex flex-col w-full"> <div class=" max-h-full flex flex-col w-full gap-[0.5px]">
{#each files as file} {#each files as file (file?.id ?? file?.tempId)}
<div class="mt-1 px-2"> <div
<FileItem class=" flex cursor-pointer w-full px-1.5 py-0.5 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
className="w-full" ? ''
colorClassName="{selectedFileId === file.id : 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
? ' bg-gray-50 dark:bg-gray-850' >
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition" <button
{small} class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
item={file} type="button"
name={file?.name ?? file?.meta?.name} on:click={async () => {
type="file" console.log(file);
size={file?.size ?? file?.meta?.size ?? ''} onClick(file?.id ?? file?.tempId);
loading={file.status === 'uploading'}
dismissible
on:click={() => {
if (file.status === 'uploading') {
return;
}
dispatch('click', file.id);
}} }}
on:dismiss={() => { >
if (file.status === 'uploading') { <div class="">
return; <div class="flex gap-2 items-center line-clamp-1">
} <div class="shrink-0">
{#if file?.status !== 'uploading'}
<DocumentPage className="size-3" />
{:else}
<Spinner className="size-3" />
{/if}
</div>
dispatch('delete', file.id); <div class="line-clamp-1">
}} {file?.name ?? file?.meta?.name}
/> <span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tooltip content={dayjs(file.updated_at * 1000).format('LLLL')}>
<div>
{dayjs(file.updated_at * 1000).fromNow()}
</div>
</Tooltip>
<Tooltip
content={file?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
file?.user?.name ?? file?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
</div>
</button>
<div class="flex items-center">
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
onDelete(file?.id ?? file?.tempId);
}}
>
<XMark />
</button>
</Tooltip>
</div>
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -53,41 +53,6 @@
}; };
}); });
let legacy_documents = knowledgeItems
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = knowledgeItems let collections = knowledgeItems
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -118,13 +83,7 @@
] ]
: []; : [];
items = [...notes, ...collections, ...legacy_collections].map((item) => { items = [...notes, ...collections, ...collection_files];
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });