mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
Compare commits
42 commits
1c3f20563f
...
86005b3b80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86005b3b80 | ||
|
|
cf6a1300ca | ||
|
|
a934dc997e | ||
|
|
ed2db0d04b | ||
|
|
4ecacda28c | ||
|
|
94a8439105 | ||
|
|
7b0b16ebbd | ||
|
|
49d54c5821 | ||
|
|
0eafc09965 | ||
|
|
6a75620fcb | ||
|
|
205c711120 | ||
|
|
3af96c9d4e | ||
|
|
6e0badde67 | ||
|
|
b29e7fd0be | ||
|
|
02df867843 | ||
|
|
00c2b6ca40 | ||
|
|
65d4b22c7c | ||
|
|
a4fe823893 | ||
|
|
103ff0c5e4 | ||
|
|
4363df175d | ||
|
|
5f5a8fa7bd | ||
|
|
93feadd93b | ||
|
|
23faf18ae4 | ||
|
|
ab10e84f96 | ||
|
|
606ab164ba | ||
|
|
9ba90414eb | ||
|
|
fda3b287d4 | ||
|
|
00b61ee25c | ||
|
|
7e3d7b8a00 | ||
|
|
098d27c487 | ||
|
|
b78d28e5cc | ||
|
|
6046e52c76 | ||
|
|
0790cc8ef4 | ||
|
|
0422b5e29d | ||
|
|
d10a71c298 | ||
|
|
96c7c948a1 | ||
|
|
fc68071e1d | ||
|
|
140605e660 | ||
|
|
9899293f05 | ||
|
|
e3faec62c5 | ||
|
|
fc05e0a6c5 | ||
|
|
fe6783c166 |
31 changed files with 1648 additions and 952 deletions
|
|
@ -374,6 +374,21 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
|||
|
||||
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
|
||||
|
||||
ENABLE_WRAP_TOOL_RESULT = (
|
||||
os.environ.get("ENABLE_WRAP_TOOL_RESULT", "True").lower() == "true"
|
||||
)
|
||||
|
||||
TOOL_RESULT_INDENT_SIZE = os.environ.get("TOOL_RESULT_INDENT_SIZE", 2)
|
||||
|
||||
if TOOL_RESULT_INDENT_SIZE == "":
|
||||
TOOL_RESULT_INDENT_SIZE = 2
|
||||
else:
|
||||
try:
|
||||
TOOL_RESULT_INDENT_SIZE = int(TOOL_RESULT_INDENT_SIZE)
|
||||
except Exception:
|
||||
TOOL_RESULT_INDENT_SIZE = 2
|
||||
|
||||
|
||||
####################################
|
||||
# REDIS
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.hash = hash
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return FileModel.model_validate(file)
|
||||
|
|
@ -249,6 +250,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.data = {**(file.data if file.data else {}), **data}
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception as e:
|
||||
|
|
@ -260,6 +262,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.meta = {**(file.meta if file.meta else {}), **meta}
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ import uuid
|
|||
from open_webui.internal.db import Base, get_db
|
||||
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.users import Users, UserResponse
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
|
@ -21,6 +26,7 @@ from sqlalchemy import (
|
|||
Text,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
or_,
|
||||
)
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
|
@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel):
|
|||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class FileUserResponse(FileModelResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class KnowledgeFileListResponse(BaseModel):
|
||||
items: list[FileUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class KnowledgeTable:
|
||||
def insert_new_knowledge(
|
||||
self, user_id: str, form_data: KnowledgeForm
|
||||
|
|
@ -232,6 +247,88 @@ class KnowledgeTable:
|
|||
except Exception:
|
||||
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]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
|
|
|
|||
|
|
@ -96,11 +96,86 @@ class NoteTable:
|
|||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
# Public access
|
||||
conditions = []
|
||||
|
||||
# Handle read_only permission separately
|
||||
if permission == "read_only":
|
||||
# For read_only, we want items where:
|
||||
# 1. User has explicit read permission (via groups or user-level)
|
||||
# 2. BUT does NOT have write permission
|
||||
# 3. Public items are NOT considered read_only
|
||||
|
||||
read_conditions = []
|
||||
|
||||
# Group-level read permission
|
||||
if group_ids:
|
||||
group_read_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_read_conditions.append(
|
||||
Note.access_control["read"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_read_conditions.append(
|
||||
cast(
|
||||
Note.access_control["read"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_read_conditions:
|
||||
read_conditions.append(or_(*group_read_conditions))
|
||||
|
||||
# Combine read conditions
|
||||
if read_conditions:
|
||||
has_read = or_(*read_conditions)
|
||||
else:
|
||||
# If no read conditions, return empty result
|
||||
return query.filter(False)
|
||||
|
||||
# Now exclude items where user has write permission
|
||||
write_exclusions = []
|
||||
|
||||
# Exclude items owned by user (they have implicit write)
|
||||
if user_id:
|
||||
write_exclusions.append(Note.user_id != user_id)
|
||||
|
||||
# Exclude items where user has explicit write permission via groups
|
||||
if group_ids:
|
||||
group_write_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_write_conditions.append(
|
||||
Note.access_control["write"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_write_conditions.append(
|
||||
cast(
|
||||
Note.access_control["write"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_write_conditions:
|
||||
# User should NOT have write permission
|
||||
write_exclusions.append(~or_(*group_write_conditions))
|
||||
|
||||
# Exclude public items (items without access_control)
|
||||
write_exclusions.append(Note.access_control.isnot(None))
|
||||
write_exclusions.append(cast(Note.access_control, String) != "null")
|
||||
|
||||
# Combine: has read AND does not have write AND not public
|
||||
if write_exclusions:
|
||||
query = query.filter(and_(has_read, *write_exclusions))
|
||||
else:
|
||||
query = query.filter(has_read)
|
||||
|
||||
return query
|
||||
|
||||
# Original logic for other permissions (read, write, etc.)
|
||||
# Public access conditions
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
|
|
@ -109,7 +184,7 @@ class NoteTable:
|
|||
]
|
||||
)
|
||||
|
||||
# User-level permission
|
||||
# User-level permission (owner has all permissions)
|
||||
if user_id:
|
||||
conditions.append(Note.user_id == user_id)
|
||||
|
||||
|
|
@ -191,11 +266,16 @@ class NoteTable:
|
|||
query = query.filter(Note.user_id != user_id)
|
||||
|
||||
# Apply access control filtering
|
||||
if "permission" in filter:
|
||||
permission = filter["permission"]
|
||||
else:
|
||||
permission = "write"
|
||||
|
||||
query = self._has_permission(
|
||||
db,
|
||||
query,
|
||||
filter,
|
||||
permission="write",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
|
|
@ -222,9 +302,6 @@ class NoteTable:
|
|||
else:
|
||||
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
|
||||
total = query.count()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.models.chats import Chats
|
||||
from open_webui.models.groups import Groups, GroupMember
|
||||
from open_webui.models.channels import ChannelMember
|
||||
|
||||
|
||||
from open_webui.utils.misc import throttle
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
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.models.chats import (
|
||||
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
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||
import logging
|
||||
|
||||
from open_webui.models.knowledge import (
|
||||
KnowledgeFileListResponse,
|
||||
Knowledges,
|
||||
KnowledgeForm,
|
||||
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
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ async def search_notes(
|
|||
request: Request,
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
permission: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
|
|
@ -108,6 +109,8 @@ async def search_notes(
|
|||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
if permission:
|
||||
filter["permission"] = permission
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
|
|
@ -132,7 +135,6 @@ async def search_notes(
|
|||
async def create_new_note(
|
||||
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
|
||||
):
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
|
|
@ -156,7 +158,11 @@ async def create_new_note(
|
|||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[NoteModel])
|
||||
class NoteResponse(NoteModel):
|
||||
write_access: bool = False
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[NoteResponse])
|
||||
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
|
|
@ -180,7 +186,15 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
return note
|
||||
write_access = (
|
||||
user.role == "admin"
|
||||
or (user.id == note.user_id)
|
||||
or has_access(
|
||||
user.id, type="write", access_control=note.access_control, strict=False
|
||||
)
|
||||
)
|
||||
|
||||
return NoteResponse(**note.model_dump(), write_access=write_access)
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ from open_webui.env import (
|
|||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_REALTIME_CHAT_SAVE,
|
||||
ENABLE_QUERIES_CACHE,
|
||||
ENABLE_WRAP_TOOL_RESULT,
|
||||
TOOL_RESULT_INDENT_SIZE,
|
||||
)
|
||||
from open_webui.constants import TASKS
|
||||
|
||||
|
|
@ -275,15 +277,23 @@ def process_tool_result(
|
|||
)
|
||||
tool_result.remove(item)
|
||||
|
||||
if isinstance(tool_result, list):
|
||||
if isinstance(tool_result, list) and ENABLE_WRAP_TOOL_RESULT:
|
||||
tool_result = {"results": tool_result}
|
||||
|
||||
if isinstance(tool_result, dict) or isinstance(tool_result, list):
|
||||
tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
|
||||
tool_result = dump_tool_result_to_json(tool_result, ensure_ascii=False)
|
||||
|
||||
return tool_result, tool_result_files, tool_result_embeds
|
||||
|
||||
|
||||
def dump_tool_result_to_json(model, ensure_ascii=True):
|
||||
indent_size = None if TOOL_RESULT_INDENT_SIZE == 0 else TOOL_RESULT_INDENT_SIZE
|
||||
separators = None if indent_size and indent_size > 0 else (",", ":")
|
||||
return json.dumps(
|
||||
model, indent=indent_size, separators=separators, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
|
||||
async def chat_completion_tools_handler(
|
||||
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
|
||||
) -> tuple[dict, dict]:
|
||||
|
|
@ -2070,9 +2080,9 @@ async def process_chat_response(
|
|||
|
||||
if tool_result is not None:
|
||||
tool_result_embeds = result.get("embeds", "")
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(dump_tool_result_to_json(tool_arguments))}" result="{html.escape(dump_tool_result_to_json(tool_result, ensure_ascii=True))}" files="{html.escape(dump_tool_result_to_json(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(dump_tool_result_to_json(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
else:
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(dump_tool_result_to_json(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
|
||||
if not raw:
|
||||
content = f"{content}{tool_calls_display_content}"
|
||||
|
|
@ -2088,7 +2098,7 @@ async def process_chat_response(
|
|||
"arguments", ""
|
||||
)
|
||||
|
||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(dump_tool_result_to_json(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
|
||||
if not raw:
|
||||
content = f"{content}{tool_calls_display_content}"
|
||||
|
|
|
|||
|
|
@ -803,3 +803,7 @@ body {
|
|||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#note-content-container .ProseMirror {
|
||||
padding-bottom: 2rem; /* space for the bottom toolbar */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
|
|||
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 = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const searchNotes = async (
|
|||
token: string = '',
|
||||
query: string | null = null,
|
||||
viewOption: string | null = null,
|
||||
permission: string | null = null,
|
||||
sortKey: string | null = null,
|
||||
page: number | null = null
|
||||
) => {
|
||||
|
|
@ -109,6 +110,10 @@ export const searchNotes = async (
|
|||
searchParams.append('view_option', viewOption);
|
||||
}
|
||||
|
||||
if (permission !== null) {
|
||||
searchParams.append('permission', permission);
|
||||
}
|
||||
|
||||
if (sortKey !== null) {
|
||||
searchParams.append('order_by', sortKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
<div class="">
|
||||
<button
|
||||
type="button"
|
||||
class=" px-3 py-1.5 gap-1 rounded-xl bg-black dark:text-white dark:bg-gray-850/50 text-black transition font-medium text-xs flex items-center justify-center"
|
||||
class=" px-3 py-1.5 gap-1 rounded-xl bg-gray-100/50 dark:text-white dark:bg-gray-850/50 text-black transition font-medium text-xs flex items-center justify-center"
|
||||
on:click={onAdd}
|
||||
>
|
||||
<Plus className="size-3.5 " />
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
getMessageContentParts,
|
||||
createMessagesList,
|
||||
getPromptVariables,
|
||||
processDetails,
|
||||
processDetailsAndExtractToolCalls,
|
||||
removeAllDetails,
|
||||
getCodeBlockContents
|
||||
} from '$lib/utils';
|
||||
|
|
@ -1873,45 +1873,79 @@
|
|||
params?.stream_response ??
|
||||
true;
|
||||
|
||||
let messages = [
|
||||
params?.system || $settings.system
|
||||
? {
|
||||
role: 'system',
|
||||
content: `${params?.system ?? $settings?.system ?? ''}`
|
||||
}
|
||||
: undefined,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
content: processDetails(message.content)
|
||||
}))
|
||||
].filter((message) => message);
|
||||
let messages = [];
|
||||
if (params?.system || $settings.system) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `${params?.system ?? $settings?.system ?? ''}`
|
||||
});
|
||||
}
|
||||
|
||||
messages = messages
|
||||
.map((message, idx, arr) => ({
|
||||
role: message.role,
|
||||
...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) &&
|
||||
message.role === 'user'
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message?.merged?.content ?? message.content
|
||||
},
|
||||
...message.files
|
||||
.filter((file) => file.type === 'image')
|
||||
.map((file) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: file.url
|
||||
}
|
||||
}))
|
||||
]
|
||||
}
|
||||
: {
|
||||
content: message?.merged?.content ?? message.content
|
||||
})
|
||||
}))
|
||||
.filter((message) => message?.role === 'user' || message?.content?.trim());
|
||||
for (const message of _messages) {
|
||||
let content = message?.merged?.content ?? message?.content;
|
||||
content = message?.role !== 'user' ? content?.trim() : content;
|
||||
let processedMessages = processDetailsAndExtractToolCalls(content ?? '');
|
||||
|
||||
let nonToolMesssage = null;
|
||||
let toolCallIndex = 0;
|
||||
|
||||
for (const processedMessage of processedMessages) {
|
||||
if (typeof processedMessage == 'string') {
|
||||
nonToolMesssage = {
|
||||
role: message?.role,
|
||||
content: processedMessage
|
||||
};
|
||||
|
||||
if (
|
||||
message?.role === 'user' &&
|
||||
(message.files?.filter((file) => file.type === 'image').length > 0 ?? false)
|
||||
) {
|
||||
nonToolMesssage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: nonToolMesssage.content
|
||||
},
|
||||
...message.files
|
||||
.filter((file) => file.type === 'image')
|
||||
.map((file) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: file.url
|
||||
}
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
messages.push(nonToolMesssage);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nonToolMesssage) {
|
||||
nonToolMesssage = {
|
||||
role: message?.role,
|
||||
content: ''
|
||||
};
|
||||
messages.push(nonToolMesssage);
|
||||
}
|
||||
|
||||
nonToolMesssage.tool_calls ??= [];
|
||||
nonToolMesssage.tool_calls.push({
|
||||
index: toolCallIndex++,
|
||||
id: processedMessage.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: processedMessage.name,
|
||||
arguments: processedMessage.arguments
|
||||
}
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: processedMessage.id,
|
||||
content: processedMessage.result
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toolIds = [];
|
||||
const toolServerIds = [];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
|
||||
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
|
||||
import dayjs from '$lib/dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
|
||||
|
||||
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
|
||||
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
|
||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import {
|
||||
|
|
@ -49,6 +57,9 @@
|
|||
|
||||
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
||||
|
||||
import { createNoteHandler } from '../notes/utils';
|
||||
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
|
||||
|
||||
import InputMenu from './MessageInput/InputMenu.svelte';
|
||||
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
|
||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||
|
|
@ -60,11 +71,9 @@
|
|||
import Image from '../common/Image.svelte';
|
||||
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import Headphone from '../icons/Headphone.svelte';
|
||||
import GlobeAlt from '../icons/GlobeAlt.svelte';
|
||||
import Photo from '../icons/Photo.svelte';
|
||||
import Wrench from '../icons/Wrench.svelte';
|
||||
import CommandLine from '../icons/CommandLine.svelte';
|
||||
import Sparkles from '../icons/Sparkles.svelte';
|
||||
|
||||
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
||||
|
|
@ -74,12 +83,13 @@
|
|||
import Component from '../icons/Component.svelte';
|
||||
import PlusAlt from '../icons/PlusAlt.svelte';
|
||||
|
||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||
|
||||
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
|
||||
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
|
||||
import Knobs from '../icons/Knobs.svelte';
|
||||
import ValvesModal from '../workspace/common/ValvesModal.svelte';
|
||||
import PageEdit from '../icons/PageEdit.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import InputModal from '../common/InputModal.svelte';
|
||||
import Expand from '../icons/Expand.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -109,6 +119,8 @@
|
|||
export let webSearchEnabled = false;
|
||||
export let codeInterpreterEnabled = false;
|
||||
|
||||
let inputContent = null;
|
||||
|
||||
let showInputVariablesModal = false;
|
||||
let inputVariablesModalCallback = (variableValues) => {};
|
||||
let inputVariables = {};
|
||||
|
|
@ -410,6 +422,8 @@
|
|||
|
||||
let inputFiles;
|
||||
|
||||
let showInputModal = false;
|
||||
|
||||
let dragged = false;
|
||||
let shiftKey = false;
|
||||
|
||||
|
|
@ -730,6 +744,25 @@
|
|||
});
|
||||
};
|
||||
|
||||
const createNote = async () => {
|
||||
if (inputContent?.md.trim() === '' && inputContent?.html.trim() === '') {
|
||||
toast.error($i18n.t('Cannot create an empty note.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await createNoteHandler(
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
inputContent?.md,
|
||||
inputContent?.html
|
||||
);
|
||||
|
||||
if (res) {
|
||||
// Clear the input content saved in session storage.
|
||||
sessionStorage.removeItem('chat-input');
|
||||
goto(`/notes/${res.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -955,6 +988,20 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<InputModal
|
||||
bind:show={showInputModal}
|
||||
bind:value={prompt}
|
||||
bind:inputContent
|
||||
onChange={(content) => {
|
||||
console.log(content);
|
||||
chatInputElement?.setContent(content?.json ?? null);
|
||||
}}
|
||||
onClose={async () => {
|
||||
await tick();
|
||||
chatInputElement?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if loaded}
|
||||
<div class="w-full font-primary">
|
||||
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
|
||||
|
|
@ -1189,14 +1236,33 @@
|
|||
: ''}"
|
||||
id="chat-input-container"
|
||||
>
|
||||
{#if prompt.split('\n').length > 2}
|
||||
<div class="fixed top-0 right-0 z-20">
|
||||
<div class="mt-2.5 mr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
|
||||
aria-label="Expand input"
|
||||
on:click={async () => {
|
||||
showInputModal = true;
|
||||
}}
|
||||
>
|
||||
<Expand />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if suggestions}
|
||||
{#key $settings?.richTextInput ?? true}
|
||||
{#key $settings?.showFormattingToolbar ?? false}
|
||||
<RichTextInput
|
||||
bind:this={chatInputElement}
|
||||
id="chat-input"
|
||||
onChange={(e) => {
|
||||
prompt = e.md;
|
||||
editable={!showInputModal}
|
||||
onChange={(content) => {
|
||||
prompt = content.md;
|
||||
inputContent = content;
|
||||
command = getCommand();
|
||||
}}
|
||||
json={true}
|
||||
|
|
@ -1620,57 +1686,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="self-end flex space-x-1 mr-1 shrink-0">
|
||||
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
||||
<!-- {$i18n.t('Record voice')} -->
|
||||
<Tooltip content={$i18n.t('Dictate')}>
|
||||
<button
|
||||
id="voice-input-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
`Permission denied when accessing microphone: {{error}}`,
|
||||
{
|
||||
error: err
|
||||
}
|
||||
)
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
aria-label="Voice Input"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 translate-y-[0.5px]"
|
||||
>
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
|
||||
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
||||
<div class=" flex items-center">
|
||||
<Tooltip content={$i18n.t('Stop')}>
|
||||
|
|
@ -1695,95 +1711,163 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
||||
<div class=" flex items-center">
|
||||
<!-- {$i18n.t('Call')} -->
|
||||
<Tooltip content={$i18n.t('Voice mode')}>
|
||||
{:else}
|
||||
{#if prompt !== '' && !history?.currentId && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
|
||||
<Tooltip content={$i18n.t('Create note')} className=" flex items-center">
|
||||
<button
|
||||
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
||||
id="send-message-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center"
|
||||
type="button"
|
||||
disabled={prompt === '' && files.length === 0}
|
||||
on:click={() => {
|
||||
createNote();
|
||||
}}
|
||||
>
|
||||
<PageEdit className="size-4.5 translate-y-[0.5px]" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
||||
<!-- {$i18n.t('Record voice')} -->
|
||||
<Tooltip content={$i18n.t('Dictate')}>
|
||||
<button
|
||||
id="voice-input-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
if (selectedModels.length > 1) {
|
||||
toast.error($i18n.t('Select only one model to call'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($config.audio.stt.engine === 'web') {
|
||||
toast.error(
|
||||
$i18n.t('Call feature is not supported when using Web STT engine')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
// check if user has access to getUserMedia
|
||||
try {
|
||||
let stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
// If the user grants the permission, proceed to show the call overlay
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
`Permission denied when accessing microphone: {{error}}`,
|
||||
{
|
||||
error: err
|
||||
}
|
||||
)
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
stream = null;
|
||||
|
||||
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
||||
// If the user has not initialized the TTS worker, initialize it
|
||||
if (!$TTSWorker) {
|
||||
await TTSWorker.set(
|
||||
new KokoroWorker({
|
||||
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
||||
})
|
||||
);
|
||||
|
||||
await $TTSWorker.init();
|
||||
}
|
||||
}
|
||||
|
||||
showCallOverlay.set(true);
|
||||
showControls.set(true);
|
||||
} catch (err) {
|
||||
// If the user denies the permission or an error occurs, show an error message
|
||||
toast.error(
|
||||
$i18n.t('Permission denied when accessing media devices')
|
||||
);
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
aria-label={$i18n.t('Voice mode')}
|
||||
>
|
||||
<Voice className="size-5" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" flex items-center">
|
||||
<Tooltip content={$i18n.t('Send message')}>
|
||||
<button
|
||||
id="send-message-button"
|
||||
class="{!(prompt === '' && files.length === 0)
|
||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
||||
type="submit"
|
||||
disabled={prompt === '' && files.length === 0}
|
||||
aria-label="Voice Input"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
class="size-5 translate-y-[0.5px]"
|
||||
>
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||
clip-rule="evenodd"
|
||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
||||
<div class=" flex items-center">
|
||||
<!-- {$i18n.t('Call')} -->
|
||||
<Tooltip content={$i18n.t('Voice mode')}>
|
||||
<button
|
||||
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
if (selectedModels.length > 1) {
|
||||
toast.error($i18n.t('Select only one model to call'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($config.audio.stt.engine === 'web') {
|
||||
toast.error(
|
||||
$i18n.t('Call feature is not supported when using Web STT engine')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
// check if user has access to getUserMedia
|
||||
try {
|
||||
let stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
// If the user grants the permission, proceed to show the call overlay
|
||||
|
||||
if (stream) {
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
stream = null;
|
||||
|
||||
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
||||
// If the user has not initialized the TTS worker, initialize it
|
||||
if (!$TTSWorker) {
|
||||
await TTSWorker.set(
|
||||
new KokoroWorker({
|
||||
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
||||
})
|
||||
);
|
||||
|
||||
await $TTSWorker.init();
|
||||
}
|
||||
}
|
||||
|
||||
showCallOverlay.set(true);
|
||||
showControls.set(true);
|
||||
} catch (err) {
|
||||
// If the user denies the permission or an error occurs, show an error message
|
||||
toast.error(
|
||||
$i18n.t('Permission denied when accessing media devices')
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-label={$i18n.t('Voice mode')}
|
||||
>
|
||||
<Voice className="size-5" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" flex items-center">
|
||||
<Tooltip content={$i18n.t('Send message')}>
|
||||
<button
|
||||
id="send-message-button"
|
||||
class="{!(prompt === '' && files.length === 0)
|
||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
||||
type="submit"
|
||||
disabled={prompt === '' && files.length === 0}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,41 +80,6 @@
|
|||
};
|
||||
|
||||
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
|
||||
.filter((item) => !item?.meta?.document)
|
||||
.map((item) => ({
|
||||
|
|
@ -154,19 +119,7 @@
|
|||
title: folder.name
|
||||
}));
|
||||
|
||||
items = [
|
||||
...folder_items,
|
||||
...collections,
|
||||
...collection_files,
|
||||
...legacy_collections,
|
||||
...legacy_documents
|
||||
].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
||||
};
|
||||
});
|
||||
|
||||
items = [...folder_items, ...collections, ...collection_files];
|
||||
fuse = new Fuse(items, {
|
||||
keys: ['name', 'description']
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,41 +24,6 @@
|
|||
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
|
||||
.filter((item) => !item?.meta?.document)
|
||||
.map((item) => ({
|
||||
|
|
@ -91,15 +56,7 @@
|
|||
]
|
||||
: [];
|
||||
|
||||
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
|
||||
(item) => {
|
||||
return {
|
||||
...item,
|
||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
items = [...collections, ...collection_files];
|
||||
await tick();
|
||||
|
||||
loaded = true;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@
|
|||
: ' text-gray-500 dark:text-gray-400'}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
value = item.value;
|
||||
if (value === item.value) {
|
||||
value = null;
|
||||
} else {
|
||||
value = item.value;
|
||||
}
|
||||
|
||||
open = false;
|
||||
onChange(value);
|
||||
}}
|
||||
|
|
|
|||
79
src/lib/components/common/InputModal.svelte
Normal file
79
src/lib/components/common/InputModal.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
import Drawer from './Drawer.svelte';
|
||||
import RichTextInput from './RichTextInput.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let id = 'input-modal';
|
||||
|
||||
export let show = false;
|
||||
export let value = null;
|
||||
export let inputContent = null;
|
||||
|
||||
export let autocomplete = false;
|
||||
export let generateAutoCompletion = null;
|
||||
|
||||
export let onChange = () => {};
|
||||
export let onClose = () => {};
|
||||
|
||||
let inputElement;
|
||||
</script>
|
||||
|
||||
<Drawer bind:show>
|
||||
<div class="flex h-full min-h-screen flex-col">
|
||||
<div
|
||||
class=" sticky top-0 z-30 flex justify-between bg-white px-4.5 pt-3 pb-3 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<div class=" font-primary self-center text-lg">
|
||||
{$i18n.t('Input')}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label="Close"
|
||||
onclick={() => {
|
||||
show = false;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full px-4 dark:text-gray-200 min-h-full flex-1">
|
||||
<div class="flex-1 w-full min-h-full">
|
||||
<RichTextInput
|
||||
bind:this={inputElement}
|
||||
{id}
|
||||
onChange={(content) => {
|
||||
value = content.md;
|
||||
inputContent = content;
|
||||
|
||||
onChange(content);
|
||||
}}
|
||||
json={true}
|
||||
value={inputContent?.json}
|
||||
html={inputContent?.html}
|
||||
richText={$settings?.richTextInput ?? true}
|
||||
messageInput={true}
|
||||
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
||||
floatingMenuPlacement={'top-start'}
|
||||
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
||||
{autocomplete}
|
||||
{generateAutoCompletion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
|
@ -169,7 +169,7 @@
|
|||
|
||||
export let documentId = '';
|
||||
|
||||
export let className = 'input-prose';
|
||||
export let className = 'input-prose min-h-fit h-full';
|
||||
export let placeholder = $i18n.t('Type here...');
|
||||
let _placeholder = placeholder;
|
||||
|
||||
|
|
@ -1156,7 +1156,5 @@
|
|||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative w-full min-w-full h-full min-h-fit {className} {!editable
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
class="relative w-full min-w-full {className} {!editable ? 'cursor-not-allowed' : ''}"
|
||||
/>
|
||||
|
|
|
|||
21
src/lib/components/icons/Expand.svelte
Normal file
21
src/lib/components/icons/Expand.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke-width={strokeWidth}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M9 9L4 4M4 4V8M4 4H8" stroke-linecap="round" stroke-linejoin="round"></path><path
|
||||
d="M15 9L20 4M20 4V8M20 4H16"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path d="M9 15L4 20M4 20V16M4 20H8" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path><path d="M15 15L20 20M20 20V16M20 20H16" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path></svg
|
||||
>
|
||||
24
src/lib/components/icons/PagePlus.svelte
Normal file
24
src/lib/components/icons/PagePlus.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke-width={strokeWidth}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M9 12H12M15 12H12M12 12V9M12 12V15" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path><path
|
||||
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path
|
||||
d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path></svg
|
||||
>
|
||||
|
|
@ -157,6 +157,16 @@
|
|||
if (res) {
|
||||
note = res;
|
||||
files = res.data.files || [];
|
||||
|
||||
if (note?.write_access) {
|
||||
$socket?.emit('join-note', {
|
||||
note_id: id,
|
||||
auth: {
|
||||
token: localStorage.token
|
||||
}
|
||||
});
|
||||
$socket?.on('note-events', noteEventHandler);
|
||||
}
|
||||
} else {
|
||||
goto('/');
|
||||
return;
|
||||
|
|
@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
$socket?.emit('join-note', {
|
||||
note_id: id,
|
||||
auth: {
|
||||
token: localStorage.token
|
||||
}
|
||||
});
|
||||
$socket?.on('note-events', noteEventHandler);
|
||||
|
||||
if ($settings?.models) {
|
||||
selectedModelId = $settings?.models[0];
|
||||
|
|
@ -956,70 +959,72 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
{/if}
|
||||
|
||||
<div class="flex items-center gap-0.5 translate-x-1">
|
||||
{#if editor}
|
||||
<div>
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().undo().run();
|
||||
// versionNavigateHandler('prev');
|
||||
}}
|
||||
disabled={!editor.can().undo()}
|
||||
>
|
||||
<ArrowUturnLeft className="size-4" />
|
||||
</button>
|
||||
{#if note?.write_access}
|
||||
{#if editor}
|
||||
<div>
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().undo().run();
|
||||
// versionNavigateHandler('prev');
|
||||
}}
|
||||
disabled={!editor.can().undo()}
|
||||
>
|
||||
<ArrowUturnLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().redo().run();
|
||||
// versionNavigateHandler('next');
|
||||
}}
|
||||
disabled={!editor.can().redo()}
|
||||
>
|
||||
<ArrowUturnRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().redo().run();
|
||||
// versionNavigateHandler('next');
|
||||
}}
|
||||
disabled={!editor.can().redo()}
|
||||
>
|
||||
<ArrowUturnRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'chat') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'chat';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatBubbleOval />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'settings') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'settings';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdjustmentsHorizontalOutline />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'chat') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'chat';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatBubbleOval />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'settings') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'settings';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdjustmentsHorizontalOutline />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
downloadHandler(type);
|
||||
|
|
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-1 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit"
|
||||
class="flex gap-0.5 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit"
|
||||
>
|
||||
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit">
|
||||
<Calendar className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<!-- check for same date, yesterday, last week, and other -->
|
||||
|
||||
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
||||
|
|
@ -1099,17 +1102,21 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
||||
>
|
||||
<Users className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||
</button>
|
||||
{#if note?.write_access}
|
||||
<button
|
||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
||||
>
|
||||
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||
</button>
|
||||
{:else}
|
||||
<div>
|
||||
{$i18n.t('Read-Only Access')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editor}
|
||||
<div class="flex items-center gap-1 px-1 min-w-fit">
|
||||
|
|
@ -1130,7 +1137,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
</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"
|
||||
>
|
||||
{#if editing}
|
||||
|
|
@ -1145,7 +1152,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
bind:this={inputElement}
|
||||
bind:editor
|
||||
id={`note-${note.id}`}
|
||||
className="input-prose-sm px-0.5"
|
||||
className="input-prose-sm px-0.5 h-[calc(100%-2rem)]"
|
||||
json={true}
|
||||
bind:value={note.data.content.json}
|
||||
html={note.data?.content?.html}
|
||||
|
|
@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
image={true}
|
||||
{files}
|
||||
placeholder={$i18n.t('Write something...')}
|
||||
editable={versionIdx === null && !editing}
|
||||
editable={versionIdx === null && !editing && note?.write_access}
|
||||
onSelectionUpdate={({ editor }) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
||||
|
|
@ -1243,8 +1250,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="absolute z-20 bottom-0 right-0 p-3.5 max-w-full w-full flex">
|
||||
<div class="flex gap-1 w-full min-w-full justify-between">
|
||||
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
|
||||
<div class="flex flex-col gap-2 justify-end">
|
||||
{#if recording}
|
||||
<div class="flex-1 w-full">
|
||||
<VoiceRecording
|
||||
|
|
@ -1269,6 +1276,39 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
/>
|
||||
</div>
|
||||
{: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
|
||||
onRecord={async () => {
|
||||
displayMediaRecord = false;
|
||||
|
|
@ -1324,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
</div>
|
||||
</Tooltip>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -163,17 +163,33 @@
|
|||
await getItemsPage();
|
||||
};
|
||||
|
||||
$: if (loaded && query !== undefined && sortKey !== undefined && viewOption !== undefined) {
|
||||
$: if (
|
||||
loaded &&
|
||||
query !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
permission !== undefined &&
|
||||
viewOption !== undefined
|
||||
) {
|
||||
init();
|
||||
}
|
||||
|
||||
const getItemsPage = async () => {
|
||||
itemsLoading = true;
|
||||
const res = await searchNotes(localStorage.token, query, viewOption, sortKey, page).catch(
|
||||
() => {
|
||||
return [];
|
||||
}
|
||||
);
|
||||
|
||||
if (viewOption === 'created') {
|
||||
permission = null;
|
||||
}
|
||||
|
||||
const res = await searchNotes(
|
||||
localStorage.token,
|
||||
query,
|
||||
viewOption,
|
||||
permission,
|
||||
sortKey,
|
||||
page
|
||||
).catch(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
if (res) {
|
||||
console.log(res);
|
||||
|
|
@ -321,7 +337,7 @@
|
|||
>
|
||||
<Plus className="size-3" strokeWidth="2.5" />
|
||||
|
||||
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Note')}</div>
|
||||
<div class=" md:ml-1 text-xs">{$i18n.t('New Note')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -367,7 +383,7 @@
|
|||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-1.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
|
|
@ -386,6 +402,17 @@
|
|||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if [null, 'shared'].includes(viewOption)}
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={permission}
|
||||
items={[
|
||||
{ value: null, label: $i18n.t('Write') },
|
||||
{ value: 'read_only', label: $i18n.t('Read Only') }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -408,32 +435,174 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if (items ?? []).length > 0}
|
||||
{@const notes = groupNotes(items)}
|
||||
{#if items !== null && total !== null}
|
||||
{#if (items ?? []).length > 0}
|
||||
{@const notes = groupNotes(items)}
|
||||
|
||||
<div class="@container h-full py-2 px-2.5">
|
||||
<div class="">
|
||||
{#each Object.keys(notes) as timeRange}
|
||||
<div
|
||||
class="mb-3 w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
|
||||
>
|
||||
{$i18n.t(timeRange)}
|
||||
</div>
|
||||
<div class="@container h-full py-2.5 px-2.5">
|
||||
<div class="">
|
||||
{#each Object.keys(notes) as timeRange, idx}
|
||||
<div
|
||||
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
|
||||
>
|
||||
{$i18n.t(timeRange)}
|
||||
</div>
|
||||
|
||||
{#if displayOption === null}
|
||||
<div class="gap-1.5 flex flex-col">
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||
>
|
||||
<a href={`/notes/${note.id}`} class="w-full flex flex-col justify-between">
|
||||
<div class="flex-1">
|
||||
<div class=" flex items-center gap-2 self-center justify-between">
|
||||
<div class=" text-sm font-medium capitalize flex-1 w-full">
|
||||
{note.title}
|
||||
{#if displayOption === null}
|
||||
<div
|
||||
class="{Object.keys(notes).length - 1 !== idx
|
||||
? 'mb-3'
|
||||
: ''} gap-1.5 flex flex-col"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||
>
|
||||
<a href={`/notes/${note.id}`} class="w-full flex flex-col justify-between">
|
||||
<div class="flex-1">
|
||||
<div class=" flex items-center gap-2 self-center justify-between">
|
||||
<Tooltip
|
||||
content={note.title}
|
||||
className="flex-1"
|
||||
placement="top-start"
|
||||
>
|
||||
<div
|
||||
class=" text-sm font-medium capitalize flex-1 w-full line-clamp-1"
|
||||
>
|
||||
{note.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex shrink-0 items-center text-xs gap-2.5">
|
||||
<Tooltip content={dayjs(note.updated_at / 1000000).format('LLLL')}>
|
||||
<div>
|
||||
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class="shrink-0 text-gray-500">
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
note?.user?.name ??
|
||||
note?.user?.email ??
|
||||
$i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div>
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
selectedNote = note;
|
||||
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onCopyLink={async () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const res = await copyToClipboard(
|
||||
`${baseUrl}/notes/${note.id}`
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Copied link to clipboard'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to copy link'));
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if displayOption === 'grid'}
|
||||
<div
|
||||
class="{Object.keys(notes).length - 1 !== idx
|
||||
? 'mb-5'
|
||||
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||
>
|
||||
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class=" flex items-center gap-2 self-center mb-1 justify-between"
|
||||
>
|
||||
<div class=" font-semibold line-clamp-1 capitalize">
|
||||
{note.title}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
selectedNote = note;
|
||||
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onCopyLink={async () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const res = await copyToClipboard(
|
||||
`${baseUrl}/notes/${note.id}`
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Copied link to clipboard'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to copy link'));
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
|
||||
>
|
||||
{#if note.data?.content?.md}
|
||||
{note.data?.content?.md}
|
||||
{:else}
|
||||
{$i18n.t('No content')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center text-xs gap-2.5">
|
||||
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
|
||||
<div>
|
||||
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||
</div>
|
||||
|
|
@ -452,169 +621,55 @@
|
|||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div>
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
selectedNote = note;
|
||||
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onCopyLink={async () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const res = await copyToClipboard(
|
||||
`${baseUrl}/notes/${note.id}`
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Copied link to clipboard'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to copy link'));
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if displayOption === 'grid'}
|
||||
<div
|
||||
class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||
>
|
||||
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class=" flex items-center gap-2 self-center mb-1 justify-between">
|
||||
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
|
||||
|
||||
<div>
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
selectedNote = note;
|
||||
|
||||
downloadHandler(type);
|
||||
}}
|
||||
onCopyLink={async () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const res = await copyToClipboard(
|
||||
`${baseUrl}/notes/${note.id}`
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Copied link to clipboard'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to copy link'));
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
|
||||
>
|
||||
{#if note.data?.content?.md}
|
||||
{note.data?.content?.md}
|
||||
{:else}
|
||||
{$i18n.t('No content')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
|
||||
<div>
|
||||
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class="shrink-0 text-gray-500">
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !allItemsLoaded}
|
||||
<Loader
|
||||
on:visible={(e) => {
|
||||
if (!itemsLoading) {
|
||||
loadMoreItems();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
|
||||
{#if !allItemsLoaded}
|
||||
<Loader
|
||||
on:visible={(e) => {
|
||||
if (!itemsLoading) {
|
||||
loadMoreItems();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Spinner className=" size-4" />
|
||||
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||
<div class="py-20 text-center">
|
||||
<div class=" text-sm text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No Notes')}
|
||||
<div
|
||||
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
|
||||
>
|
||||
<Spinner className=" size-4" />
|
||||
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||
<div class="py-20 text-center">
|
||||
<div class=" text-sm text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No Notes')}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-gray-300 dark:text-gray-700">
|
||||
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
||||
<div class="mt-1 text-xs text-gray-300 dark:text-gray-700">
|
||||
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center py-10">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<Spinner className="size-5" />
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const downloadPdf = async (note) => {
|
|||
pdf.save(`${note.title}.pdf`);
|
||||
};
|
||||
|
||||
export const createNoteHandler = async (title: string, content?: string) => {
|
||||
export const createNoteHandler = async (title: string, md?: string, html?: string) => {
|
||||
// $i18n.t('New Note'),
|
||||
const res = await createNewNote(localStorage.token, {
|
||||
// YYYY-MM-DD
|
||||
|
|
@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => {
|
|||
data: {
|
||||
content: {
|
||||
json: null,
|
||||
html: content ?? '',
|
||||
md: content ?? ''
|
||||
html: html || md || '',
|
||||
md: md || ''
|
||||
}
|
||||
},
|
||||
meta: null,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
removeFileFromKnowledgeById,
|
||||
resetKnowledgeById,
|
||||
updateFileFromKnowledgeById,
|
||||
updateKnowledgeById
|
||||
updateKnowledgeById,
|
||||
searchKnowledgeFilesById
|
||||
} from '$lib/apis/knowledge';
|
||||
import { blobToFile } from '$lib/utils';
|
||||
|
||||
|
|
@ -43,22 +44,25 @@
|
|||
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.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 ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.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 DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
|
||||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
|
||||
let largeScreen = true;
|
||||
|
||||
let pane;
|
||||
let showSidepanel = true;
|
||||
let minSize = 0;
|
||||
|
||||
let showAddTextContentModal = false;
|
||||
let showSyncConfirmModal = false;
|
||||
let showAccessControlModal = false;
|
||||
|
||||
let minSize = 0;
|
||||
type Knowledge = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -71,52 +75,89 @@
|
|||
|
||||
let id = null;
|
||||
let knowledge: Knowledge | null = null;
|
||||
let query = '';
|
||||
let knowledgeId = null;
|
||||
|
||||
let showAddTextContentModal = false;
|
||||
let showSyncConfirmModal = false;
|
||||
let showAccessControlModal = false;
|
||||
let selectedFileId = null;
|
||||
let selectedFile = null;
|
||||
let selectedFileContent = '';
|
||||
|
||||
let inputFiles = null;
|
||||
|
||||
let filteredItems = [];
|
||||
$: if (knowledge && knowledge.files) {
|
||||
fuse = new Fuse(knowledge.files, {
|
||||
keys: ['meta.name', 'meta.description']
|
||||
});
|
||||
let query = '';
|
||||
let viewOption = null;
|
||||
let sortKey = null;
|
||||
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) {
|
||||
filteredItems = query
|
||||
? fuse.search(query).map((e) => {
|
||||
return e.item;
|
||||
})
|
||||
: (knowledge?.files ?? []);
|
||||
$: if (
|
||||
query !== undefined &&
|
||||
viewOption !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
direction !== undefined
|
||||
) {
|
||||
reset();
|
||||
}
|
||||
|
||||
let selectedFile = null;
|
||||
let selectedFileId = null;
|
||||
let selectedFileContent = '';
|
||||
const getItemsPage = async () => {
|
||||
if (knowledgeId === null) return;
|
||||
|
||||
// Add cache object
|
||||
let fileContentCache = new Map();
|
||||
fileItems = null;
|
||||
fileItemsTotal = null;
|
||||
|
||||
$: if (selectedFileId) {
|
||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
||||
if (file) {
|
||||
fileSelectHandler(file);
|
||||
} else {
|
||||
selectedFile = null;
|
||||
if (sortKey === null) {
|
||||
direction = null;
|
||||
}
|
||||
} else {
|
||||
selectedFile = null;
|
||||
}
|
||||
|
||||
let fuse = null;
|
||||
let debounceTimeout = null;
|
||||
let mediaQuery;
|
||||
let dragged = false;
|
||||
let isSaving = false;
|
||||
const res = await searchKnowledgeFilesById(
|
||||
localStorage.token,
|
||||
knowledge.id,
|
||||
query,
|
||||
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 blob = new Blob([content], { type: 'text/plain' });
|
||||
|
|
@ -163,8 +204,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
knowledge.files = [...(knowledge.files ?? []), fileItem];
|
||||
|
||||
fileItems = [...(fileItems ?? []), fileItem];
|
||||
try {
|
||||
// If the file is an audio file, provide the language for STT.
|
||||
let metadata = null;
|
||||
|
|
@ -184,7 +224,7 @@
|
|||
|
||||
if (uploadedFile) {
|
||||
console.log(uploadedFile);
|
||||
knowledge.files = knowledge.files.map((item) => {
|
||||
fileItems = fileItems.map((item) => {
|
||||
if (item.itemId === tempItemId) {
|
||||
item.id = uploadedFile.id;
|
||||
}
|
||||
|
|
@ -197,7 +237,7 @@
|
|||
if (uploadedFile.error) {
|
||||
console.warn('File upload 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 {
|
||||
await addFileHandler(uploadedFile.id);
|
||||
}
|
||||
|
|
@ -413,7 +453,7 @@
|
|||
toast.success($i18n.t('File added successfully.'));
|
||||
} else {
|
||||
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 () => {
|
||||
if (isSaving) {
|
||||
console.log('Save operation already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const fileId = selectedFile.id;
|
||||
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(
|
||||
const res = await updateFileDataContentById(
|
||||
localStorage.token,
|
||||
id,
|
||||
fileId
|
||||
selectedFile.id,
|
||||
selectedFileContent
|
||||
).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
return null;
|
||||
});
|
||||
if (res && updatedKnowledge) {
|
||||
knowledge = updatedKnowledge;
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('File content updated successfully.'));
|
||||
|
||||
selectedFileId = null;
|
||||
selectedFile = null;
|
||||
selectedFileContent = '';
|
||||
|
||||
await init();
|
||||
}
|
||||
} finally {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -627,7 +650,6 @@
|
|||
}
|
||||
|
||||
id = $page.params.id;
|
||||
|
||||
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
return null;
|
||||
|
|
@ -635,6 +657,7 @@
|
|||
|
||||
if (res) {
|
||||
knowledge = res;
|
||||
knowledgeId = knowledge?.id;
|
||||
} else {
|
||||
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}
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl={knowledge.access_control}
|
||||
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={() => {
|
||||
changeDebounceHandler();
|
||||
}}
|
||||
accessRoles={['read', 'write']}
|
||||
/>
|
||||
<div class="w-full mb-2.5">
|
||||
<div class="w-full px-2">
|
||||
<div class=" flex w-full">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between w-full px-0.5 mb-1">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<input
|
||||
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}
|
||||
placeholder={$i18n.t('Knowledge Name')}
|
||||
on:input={() => {
|
||||
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 class="self-center shrink-0">
|
||||
|
|
@ -750,7 +783,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full px-1">
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
type="text"
|
||||
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
||||
|
|
@ -765,204 +798,205 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3">
|
||||
{#if largeScreen}
|
||||
<div class="flex-1 flex justify-start w-full h-full max-h-full">
|
||||
{#if selectedFile}
|
||||
<div class=" flex flex-col w-full">
|
||||
<div class="shrink-0 mb-2 flex items-center">
|
||||
{#if !showSidepanel}
|
||||
<div class="-translate-x-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={() => {
|
||||
pane.expand();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
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="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={`${$i18n.t('Search Collection')}`}
|
||||
on:focus={() => {
|
||||
selectedFileId = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" flex-1 text-xl font-medium">
|
||||
<a
|
||||
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1"
|
||||
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
|
||||
target="_blank"
|
||||
>
|
||||
{decodeString(selectedFile?.meta?.name)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
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"
|
||||
disabled={isSaving}
|
||||
on:click={() => {
|
||||
updateFileContentHandler();
|
||||
}}
|
||||
>
|
||||
{$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>
|
||||
<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>
|
||||
{:else if !largeScreen && selectedFileId !== null}
|
||||
<Drawer
|
||||
className="h-full"
|
||||
show={selectedFileId !== null}
|
||||
onClose={() => {
|
||||
selectedFileId = null;
|
||||
</div>
|
||||
|
||||
<div class="px-3 flex justify-between">
|
||||
<div
|
||||
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||
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 class=" flex flex-col w-full h-full max-h-full">
|
||||
<div class="shrink-0 mt-1 mb-2 flex items-center">
|
||||
<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;
|
||||
}}
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class=" flex-1 text-xl line-clamp-1">
|
||||
{selectedFile?.meta?.name}
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
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"
|
||||
bind:value={viewOption}
|
||||
items={[
|
||||
{ value: null, label: $i18n.t('All') },
|
||||
{ value: 'created', label: $i18n.t('Created by you') },
|
||||
{ value: 'shared', label: $i18n.t('Shared with you') }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
localStorage.workspaceViewOption = value;
|
||||
} else {
|
||||
delete localStorage.workspaceViewOption;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
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"
|
||||
disabled={isSaving}
|
||||
on:click={() => {
|
||||
updateFileContentHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if isSaving}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={sortKey}
|
||||
placeholder={$i18n.t('Sort')}
|
||||
items={[
|
||||
{ value: 'name', label: $i18n.t('Name') },
|
||||
{ value: 'created_at', label: $i18n.t('Created At') },
|
||||
{ value: 'updated_at', label: $i18n.t('Updated At') }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{#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>
|
||||
</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 sortKey}
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={direction}
|
||||
items={[
|
||||
{ value: 'asc', label: $i18n.t('Asc') },
|
||||
{ value: null, label: $i18n.t('Desc') }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</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>
|
||||
{:else}
|
||||
<Spinner className="size-5" />
|
||||
|
|
|
|||
|
|
@ -50,14 +50,14 @@
|
|||
|
||||
<div slot="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}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<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={() => {
|
||||
dispatch('upload', { type: 'files' });
|
||||
}}
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
</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={() => {
|
||||
dispatch('upload', { type: 'directory' });
|
||||
}}
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
className="w-full"
|
||||
>
|
||||
<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={() => {
|
||||
dispatch('sync', { type: 'directory' });
|
||||
}}
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
</Tooltip>
|
||||
|
||||
<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={() => {
|
||||
dispatch('upload', { type: 'text' });
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,95 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import dayjs from '$lib/dayjs';
|
||||
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 files = [];
|
||||
|
||||
export let small = false;
|
||||
export let onClick = (fileId) => {};
|
||||
export let onDelete = (fileId) => {};
|
||||
</script>
|
||||
|
||||
<div class=" max-h-full flex flex-col w-full">
|
||||
{#each files as file}
|
||||
<div class="mt-1 px-2">
|
||||
<FileItem
|
||||
className="w-full"
|
||||
colorClassName="{selectedFileId === file.id
|
||||
? ' bg-gray-50 dark:bg-gray-850'
|
||||
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
{small}
|
||||
item={file}
|
||||
name={file?.name ?? file?.meta?.name}
|
||||
type="file"
|
||||
size={file?.size ?? file?.meta?.size ?? ''}
|
||||
loading={file.status === 'uploading'}
|
||||
dismissible
|
||||
on:click={() => {
|
||||
if (file.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('click', file.id);
|
||||
<div class=" max-h-full flex flex-col w-full gap-[0.5px]">
|
||||
{#each files as file (file?.id ?? file?.tempId)}
|
||||
<div
|
||||
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
|
||||
? ''
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
|
||||
>
|
||||
<button
|
||||
class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
console.log(file);
|
||||
onClick(file?.id ?? file?.tempId);
|
||||
}}
|
||||
on:dismiss={() => {
|
||||
if (file.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
>
|
||||
<div class="">
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.filter((item) => !item?.meta?.document)
|
||||
.map((item) => ({
|
||||
|
|
@ -118,13 +83,7 @@
|
|||
]
|
||||
: [];
|
||||
|
||||
items = [...notes, ...collections, ...legacy_collections].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
||||
};
|
||||
});
|
||||
|
||||
items = [...notes, ...collections, ...collection_files];
|
||||
fuse = new Fuse(items, {
|
||||
keys: ['name', 'description']
|
||||
});
|
||||
|
|
|
|||
|
|
@ -856,28 +856,77 @@ export const removeAllDetails = (content) => {
|
|||
return content;
|
||||
};
|
||||
|
||||
export const processDetails = (content) => {
|
||||
// This regex matches <details> tags with type="tool_calls" and captures their attributes
|
||||
const toolCallsDetailsRegex = /<details\s+type="tool_calls"([^>]*)>([\s\S]*?)<\/details>/gis;
|
||||
const detailsAttributesRegex = /(\w+)="([^"]*)"/g;
|
||||
|
||||
export const processDetailsAndExtractToolCalls = (content) => {
|
||||
content = removeDetails(content, ['reasoning', 'code_interpreter']);
|
||||
|
||||
// This regex matches <details> tags with type="tool_calls" and captures their attributes to convert them to a string
|
||||
const detailsRegex = /<details\s+type="tool_calls"([^>]*)>([\s\S]*?)<\/details>/gis;
|
||||
const matches = content.match(detailsRegex);
|
||||
if (matches) {
|
||||
// Split text and tool calls into messages array
|
||||
let messages = [];
|
||||
const matches = content.match(toolCallsDetailsRegex);
|
||||
if (matches && matches.length > 0) {
|
||||
let previousDetailsEndIndex = 0;
|
||||
for (const match of matches) {
|
||||
const attributesRegex = /(\w+)="([^"]*)"/g;
|
||||
let detailsStartIndex = content.indexOf(match, previousDetailsEndIndex);
|
||||
let assistantMessage = content.substr(
|
||||
previousDetailsEndIndex,
|
||||
detailsStartIndex - previousDetailsEndIndex
|
||||
);
|
||||
previousDetailsEndIndex = detailsStartIndex + match.length;
|
||||
|
||||
assistantMessage = assistantMessage.trim('\n');
|
||||
if (assistantMessage.length > 0) {
|
||||
messages.push(assistantMessage);
|
||||
}
|
||||
|
||||
const attributes = {};
|
||||
let attributeMatch;
|
||||
while ((attributeMatch = attributesRegex.exec(match)) !== null) {
|
||||
while ((attributeMatch = detailsAttributesRegex.exec(match)) !== null) {
|
||||
attributes[attributeMatch[1]] = attributeMatch[2];
|
||||
}
|
||||
|
||||
content = content.replace(match, `"${attributes.result}"`);
|
||||
if (!attributes.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let toolCall = {
|
||||
id: attributes.id,
|
||||
name: attributes.name,
|
||||
arguments: unescapeHtml(attributes.arguments ?? ''),
|
||||
result: unescapeHtml(attributes.result ?? '')
|
||||
};
|
||||
|
||||
toolCall.arguments = parseDoubleEncodedString(toolCall.arguments);
|
||||
toolCall.result = parseDoubleEncodedString(toolCall.result);
|
||||
|
||||
messages.push(toolCall);
|
||||
}
|
||||
|
||||
let finalAssistantMessage = content.substr(previousDetailsEndIndex);
|
||||
finalAssistantMessage = finalAssistantMessage.trim('\n');
|
||||
if (finalAssistantMessage.length > 0) {
|
||||
messages.push(finalAssistantMessage);
|
||||
}
|
||||
} else if (content.length > 0) {
|
||||
messages.push(content);
|
||||
}
|
||||
|
||||
return content;
|
||||
return messages;
|
||||
};
|
||||
|
||||
function parseDoubleEncodedString(value) {
|
||||
try {
|
||||
let parsedValue = JSON.parse(value);
|
||||
if (typeof parsedValue == 'string') {
|
||||
return parsedValue;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// This regular expression matches code blocks marked by triple backticks
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container">
|
||||
<div class=" flex-1 max-h-full overflow-y-auto @container">
|
||||
<Notes />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue