mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 20:35:19 +00:00
Compare commits
56 commits
12ecbedd6d
...
3e33716467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e33716467 | ||
|
|
4c4b9d19a1 | ||
|
|
7364b67455 | ||
|
|
3418f53d07 | ||
|
|
3b3e12b43a | ||
|
|
4d9a51ba33 | ||
|
|
4b4241273d | ||
|
|
db95e96688 | ||
|
|
99c820d607 | ||
|
|
282c541427 | ||
|
|
b364cf43d3 | ||
|
|
b9676cf36f | ||
|
|
258caaeced | ||
|
|
6e99b10163 | ||
|
|
a2a9a9bcf4 | ||
|
|
0addc1ea46 | ||
|
|
6812d3b9d1 | ||
|
|
ceae3d48e6 | ||
|
|
3ed1df2e53 | ||
|
|
68219d84a9 | ||
|
|
6068e23590 | ||
|
|
d7467a86e2 | ||
|
|
d098c57d4d | ||
|
|
693636d971 | ||
|
|
a6ef82c5ed | ||
|
|
79cfe29bb2 | ||
|
|
d1d42128e5 | ||
|
|
2bccf8350d | ||
|
|
c15201620d | ||
|
|
f31ca75892 | ||
|
|
a7993f6f4e | ||
|
|
ae47101dc6 | ||
|
|
cf6a1300ca | ||
|
|
a934dc997e | ||
|
|
ed2db0d04b | ||
|
|
4ecacda28c | ||
|
|
94a8439105 | ||
|
|
7b0b16ebbd | ||
|
|
49d54c5821 | ||
|
|
0eafc09965 | ||
|
|
6a75620fcb | ||
|
|
205c711120 | ||
|
|
3af96c9d4e | ||
|
|
6e0badde67 | ||
|
|
b29e7fd0be | ||
|
|
02df867843 | ||
|
|
00c2b6ca40 | ||
|
|
65d4b22c7c | ||
|
|
a4fe823893 | ||
|
|
103ff0c5e4 | ||
|
|
4363df175d | ||
|
|
307b37d5e2 | ||
|
|
9b24cddef6 | ||
|
|
1ea555a5ac | ||
|
|
c24b1207a0 | ||
|
|
44e41806f2 |
74 changed files with 4342 additions and 2040 deletions
|
|
@ -55,6 +55,9 @@ ARG USE_RERANKING_MODEL
|
||||||
ARG UID
|
ARG UID
|
||||||
ARG GID
|
ARG GID
|
||||||
|
|
||||||
|
# Python settings
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
## Basis ##
|
## Basis ##
|
||||||
ENV ENV=prod \
|
ENV ENV=prod \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
|
|
|
||||||
|
|
@ -1306,7 +1306,7 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
|
||||||
|
|
||||||
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
|
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
|
||||||
os.environ.get(
|
os.environ.get(
|
||||||
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
|
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False"
|
||||||
).lower()
|
).lower()
|
||||||
== "true"
|
== "true"
|
||||||
)
|
)
|
||||||
|
|
@ -1345,7 +1345,7 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
|
||||||
|
|
||||||
|
|
||||||
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
|
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
|
||||||
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
|
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower()
|
||||||
== "true"
|
== "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Add channel file table
|
||||||
|
|
||||||
|
Revision ID: 6283dc0e4d8d
|
||||||
|
Revises: 3e0e00844bb0
|
||||||
|
Create Date: 2025-12-10 15:11:39.424601
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "6283dc0e4d8d"
|
||||||
|
down_revision: Union[str, None] = "3e0e00844bb0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"channel_file",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"channel_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("channel.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"file_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
# indexes
|
||||||
|
sa.Index("ix_channel_file_channel_id", "channel_id"),
|
||||||
|
sa.Index("ix_channel_file_file_id", "file_id"),
|
||||||
|
sa.Index("ix_channel_file_user_id", "user_id"),
|
||||||
|
# unique constraints
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"channel_id", "file_id", name="uq_channel_file_channel_file"
|
||||||
|
), # prevent duplicate entries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("channel_file")
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Update channel file and knowledge table
|
||||||
|
|
||||||
|
Revision ID: 81cc2ce44d79
|
||||||
|
Revises: 6283dc0e4d8d
|
||||||
|
Create Date: 2025-12-10 16:07:58.001282
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "81cc2ce44d79"
|
||||||
|
down_revision: Union[str, None] = "6283dc0e4d8d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add message_id column to channel_file table
|
||||||
|
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"message_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey(
|
||||||
|
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add data column to knowledge table
|
||||||
|
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove message_id column from channel_file table
|
||||||
|
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("message_id")
|
||||||
|
|
||||||
|
# Remove data column from knowledge table
|
||||||
|
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("data")
|
||||||
|
|
@ -10,7 +10,18 @@ from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
JSON,
|
||||||
|
UniqueConstraint,
|
||||||
|
case,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
|
||||||
|
|
@ -137,6 +148,41 @@ class ChannelMemberModel(BaseModel):
|
||||||
updated_at: Optional[int] = None # timestamp in epoch (time_ns)
|
updated_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelFile(Base):
|
||||||
|
__tablename__ = "channel_file"
|
||||||
|
|
||||||
|
id = Column(Text, unique=True, primary_key=True)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
channel_id = Column(
|
||||||
|
Text, ForeignKey("channel.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
message_id = Column(
|
||||||
|
Text, ForeignKey("message.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
updated_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("channel_id", "file_id", name="uq_channel_file_channel_file"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelFileModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
|
||||||
|
channel_id: str
|
||||||
|
file_id: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
created_at: int # timestamp in epoch (time_ns)
|
||||||
|
updated_at: int # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
|
||||||
class ChannelWebhook(Base):
|
class ChannelWebhook(Base):
|
||||||
__tablename__ = "channel_webhook"
|
__tablename__ = "channel_webhook"
|
||||||
|
|
||||||
|
|
@ -642,6 +688,135 @@ class ChannelTable:
|
||||||
channel = db.query(Channel).filter(Channel.id == id).first()
|
channel = db.query(Channel).filter(Channel.id == id).first()
|
||||||
return ChannelModel.model_validate(channel) if channel else None
|
return ChannelModel.model_validate(channel) if channel else None
|
||||||
|
|
||||||
|
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
channel_files = (
|
||||||
|
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
|
||||||
|
)
|
||||||
|
channel_ids = [cf.channel_id for cf in channel_files]
|
||||||
|
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
|
||||||
|
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||||
|
|
||||||
|
def get_channels_by_file_id_and_user_id(
|
||||||
|
self, file_id: str, user_id: str
|
||||||
|
) -> list[ChannelModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
# 1. Determine which channels have this file
|
||||||
|
channel_file_rows = (
|
||||||
|
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
|
||||||
|
)
|
||||||
|
channel_ids = [row.channel_id for row in channel_file_rows]
|
||||||
|
|
||||||
|
if not channel_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. Load all channel rows that still exist
|
||||||
|
channels = (
|
||||||
|
db.query(Channel)
|
||||||
|
.filter(
|
||||||
|
Channel.id.in_(channel_ids),
|
||||||
|
Channel.deleted_at.is_(None),
|
||||||
|
Channel.archived_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not channels:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Preload user's group membership
|
||||||
|
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)]
|
||||||
|
|
||||||
|
allowed_channels = []
|
||||||
|
|
||||||
|
for channel in channels:
|
||||||
|
# --- Case A: group or dm => user must be an active member ---
|
||||||
|
if channel.type in ["group", "dm"]:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel.id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
ChannelMember.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if membership:
|
||||||
|
allowed_channels.append(ChannelModel.model_validate(channel))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Case B: standard channel => rely on ACL permissions ---
|
||||||
|
query = db.query(Channel).filter(Channel.id == channel.id)
|
||||||
|
|
||||||
|
query = self._has_permission(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
{"user_id": user_id, "group_ids": user_group_ids},
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = query.first()
|
||||||
|
if allowed:
|
||||||
|
allowed_channels.append(ChannelModel.model_validate(allowed))
|
||||||
|
|
||||||
|
return allowed_channels
|
||||||
|
|
||||||
|
def get_channel_by_id_and_user_id(
|
||||||
|
self, id: str, user_id: str
|
||||||
|
) -> Optional[ChannelModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
# Fetch the channel
|
||||||
|
channel: Channel = (
|
||||||
|
db.query(Channel)
|
||||||
|
.filter(
|
||||||
|
Channel.id == id,
|
||||||
|
Channel.deleted_at.is_(None),
|
||||||
|
Channel.archived_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If the channel is a group or dm, read access requires membership (active)
|
||||||
|
if channel.type in ["group", "dm"]:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
ChannelMember.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if membership:
|
||||||
|
return ChannelModel.model_validate(channel)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# For channels that are NOT group/dm, fall back to ACL-based read access
|
||||||
|
query = db.query(Channel).filter(Channel.id == id)
|
||||||
|
|
||||||
|
# Determine user groups
|
||||||
|
user_group_ids = [
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apply ACL rules
|
||||||
|
query = self._has_permission(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
{"user_id": user_id, "group_ids": user_group_ids},
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_allowed = query.first()
|
||||||
|
return (
|
||||||
|
ChannelModel.model_validate(channel_allowed)
|
||||||
|
if channel_allowed
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
def update_channel_by_id(
|
def update_channel_by_id(
|
||||||
self, id: str, form_data: ChannelForm
|
self, id: str, form_data: ChannelForm
|
||||||
) -> Optional[ChannelModel]:
|
) -> Optional[ChannelModel]:
|
||||||
|
|
@ -663,6 +838,65 @@ class ChannelTable:
|
||||||
db.commit()
|
db.commit()
|
||||||
return ChannelModel.model_validate(channel) if channel else None
|
return ChannelModel.model_validate(channel) if channel else None
|
||||||
|
|
||||||
|
def add_file_to_channel_by_id(
|
||||||
|
self, channel_id: str, file_id: str, user_id: str
|
||||||
|
) -> Optional[ChannelFileModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
channel_file = ChannelFileModel(
|
||||||
|
**{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ChannelFile(**channel_file.model_dump())
|
||||||
|
db.add(result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
if result:
|
||||||
|
return ChannelFileModel.model_validate(result)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_file_message_id_in_channel_by_id(
|
||||||
|
self, channel_id: str, file_id: str, message_id: str
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
channel_file = (
|
||||||
|
db.query(ChannelFile)
|
||||||
|
.filter_by(channel_id=channel_id, file_id=file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not channel_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
channel_file.message_id = message_id
|
||||||
|
channel_file.updated_at = int(time.time())
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(ChannelFile).filter_by(
|
||||||
|
channel_id=channel_id, file_id=file_id
|
||||||
|
).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def delete_channel_by_id(self, id: str):
|
def delete_channel_by_id(self, id: str):
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
db.query(Channel).filter(Channel.id == id).delete()
|
db.query(Channel).filter(Channel.id == id).delete()
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,49 @@ class ChatTitleIdResponse(BaseModel):
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatListResponse(BaseModel):
|
||||||
|
items: list[ChatModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatUsageStatsResponse(BaseModel):
|
||||||
|
id: str # chat id
|
||||||
|
|
||||||
|
models: dict = {} # models used in the chat with their usage counts
|
||||||
|
message_count: int # number of messages in the chat
|
||||||
|
|
||||||
|
history_models: dict = {} # models used in the chat history with their usage counts
|
||||||
|
history_message_count: int # number of messages in the chat history
|
||||||
|
history_user_message_count: int # number of user messages in the chat history
|
||||||
|
history_assistant_message_count: (
|
||||||
|
int # number of assistant messages in the chat history
|
||||||
|
)
|
||||||
|
|
||||||
|
average_response_time: (
|
||||||
|
float # average response time of assistant messages in seconds
|
||||||
|
)
|
||||||
|
average_user_message_content_length: (
|
||||||
|
float # average length of user message contents
|
||||||
|
)
|
||||||
|
average_assistant_message_content_length: (
|
||||||
|
float # average length of assistant message contents
|
||||||
|
)
|
||||||
|
|
||||||
|
tags: list[str] = [] # tags associated with the chat
|
||||||
|
|
||||||
|
last_message_at: int # timestamp of the last message
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatUsageStatsListResponse(BaseModel):
|
||||||
|
items: list[ChatUsageStatsResponse]
|
||||||
|
total: int
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class ChatTable:
|
class ChatTable:
|
||||||
def _clean_null_bytes(self, obj):
|
def _clean_null_bytes(self, obj):
|
||||||
"""
|
"""
|
||||||
|
|
@ -675,14 +718,31 @@ class ChatTable:
|
||||||
)
|
)
|
||||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||||
|
|
||||||
def get_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
|
def get_chats_by_user_id(
|
||||||
|
self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None
|
||||||
|
) -> ChatListResponse:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_chats = (
|
query = (
|
||||||
db.query(Chat)
|
db.query(Chat)
|
||||||
.filter_by(user_id=user_id)
|
.filter_by(user_id=user_id)
|
||||||
.order_by(Chat.updated_at.desc())
|
.order_by(Chat.updated_at.desc())
|
||||||
)
|
)
|
||||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip is not None:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
all_chats = query.all()
|
||||||
|
|
||||||
|
return ChatListResponse(
|
||||||
|
**{
|
||||||
|
"items": [ChatModel.model_validate(chat) for chat in all_chats],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
|
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,11 @@ class FileUpdateForm(BaseModel):
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
items: list[FileModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class FilesTable:
|
class FilesTable:
|
||||||
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
|
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
@ -238,6 +243,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.hash = hash
|
file.hash = hash
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
|
|
@ -249,6 +255,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.data = {**(file.data if file.data else {}), **data}
|
file.data = {**(file.data if file.data else {}), **data}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -260,6 +267,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.meta = {**(file.meta if file.meta else {}), **meta}
|
file.meta = {**(file.meta if file.meta else {}), **meta}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,17 @@ from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
from open_webui.models.files import File, FileModel, FileMetadataResponse
|
from open_webui.models.files import (
|
||||||
|
File,
|
||||||
|
FileModel,
|
||||||
|
FileMetadataResponse,
|
||||||
|
FileModelResponse,
|
||||||
|
)
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
@ -21,9 +27,12 @@ from sqlalchemy import (
|
||||||
Text,
|
Text,
|
||||||
JSON,
|
JSON,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
or_,
|
||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
|
from open_webui.utils.db.access_control import has_permission
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||||
|
|
@ -126,7 +135,7 @@ class KnowledgeResponse(KnowledgeModel):
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeUserResponse(KnowledgeUserModel):
|
class KnowledgeUserResponse(KnowledgeUserModel):
|
||||||
files: Optional[list[FileMetadataResponse | dict]] = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeForm(BaseModel):
|
class KnowledgeForm(BaseModel):
|
||||||
|
|
@ -135,6 +144,20 @@ class KnowledgeForm(BaseModel):
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileUserResponse(FileModelResponse):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeListResponse(BaseModel):
|
||||||
|
items: list[KnowledgeUserModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFileListResponse(BaseModel):
|
||||||
|
items: list[FileUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeTable:
|
class KnowledgeTable:
|
||||||
def insert_new_knowledge(
|
def insert_new_knowledge(
|
||||||
self, user_id: str, form_data: KnowledgeForm
|
self, user_id: str, form_data: KnowledgeForm
|
||||||
|
|
@ -162,12 +185,13 @@ class KnowledgeTable:
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
|
def get_knowledge_bases(
|
||||||
|
self, skip: int = 0, limit: int = 30
|
||||||
|
) -> list[KnowledgeUserModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_knowledge = (
|
all_knowledge = (
|
||||||
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
||||||
|
|
||||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
|
@ -186,6 +210,126 @@ class KnowledgeTable:
|
||||||
)
|
)
|
||||||
return knowledge_bases
|
return knowledge_bases
|
||||||
|
|
||||||
|
def search_knowledge_bases(
|
||||||
|
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30
|
||||||
|
) -> KnowledgeListResponse:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Knowledge, User).outerjoin(
|
||||||
|
User, User.id == Knowledge.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Knowledge.name.ilike(f"%{query_key}%"),
|
||||||
|
Knowledge.description.ilike(f"%{query_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Knowledge.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Knowledge.user_id != user_id)
|
||||||
|
|
||||||
|
query = has_permission(db, Knowledge, query, filter)
|
||||||
|
|
||||||
|
query = query.order_by(Knowledge.updated_at.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for knowledge_base, user in items:
|
||||||
|
knowledge_bases.append(
|
||||||
|
KnowledgeUserModel.model_validate(
|
||||||
|
{
|
||||||
|
**KnowledgeModel.model_validate(
|
||||||
|
knowledge_base
|
||||||
|
).model_dump(),
|
||||||
|
"user": (
|
||||||
|
UserModel.model_validate(user).model_dump()
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeListResponse(items=knowledge_bases, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return KnowledgeListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
def search_knowledge_files(
|
||||||
|
self, filter: dict, skip: int = 0, limit: int = 30
|
||||||
|
) -> KnowledgeFileListResponse:
|
||||||
|
"""
|
||||||
|
Scalable version: search files across all knowledge bases the user has
|
||||||
|
READ access to, without loading all KBs or using large IN() lists.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
# Base query: join Knowledge → KnowledgeFile → File
|
||||||
|
query = (
|
||||||
|
db.query(File, User)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
|
||||||
|
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply access-control directly to the joined query
|
||||||
|
# This makes the database handle filtering, even with 10k+ KBs
|
||||||
|
query = has_permission(db, Knowledge, query, filter)
|
||||||
|
|
||||||
|
# Apply filename search
|
||||||
|
if filter:
|
||||||
|
q = filter.get("query")
|
||||||
|
if q:
|
||||||
|
query = query.filter(File.filename.ilike(f"%{q}%"))
|
||||||
|
|
||||||
|
# Order by file changes
|
||||||
|
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)
|
||||||
|
|
||||||
|
rows = query.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for file, user in rows:
|
||||||
|
items.append(
|
||||||
|
FileUserResponse(
|
||||||
|
**FileModel.model_validate(file).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeFileListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("search_knowledge_files error:", e)
|
||||||
|
return KnowledgeFileListResponse(items=[], total=0)
|
||||||
|
|
||||||
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
||||||
knowledge = self.get_knowledge_by_id(id)
|
knowledge = self.get_knowledge_by_id(id)
|
||||||
if not knowledge:
|
if not knowledge:
|
||||||
|
|
@ -217,6 +361,21 @@ class KnowledgeTable:
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_knowledge_by_id_and_user_id(
|
||||||
|
self, id: str, user_id: str
|
||||||
|
) -> Optional[KnowledgeModel]:
|
||||||
|
knowledge = self.get_knowledge_by_id(id)
|
||||||
|
if not knowledge:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if knowledge.user_id == user_id:
|
||||||
|
return knowledge
|
||||||
|
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
|
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
|
||||||
|
return knowledge
|
||||||
|
return None
|
||||||
|
|
||||||
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
|
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
@ -232,6 +391,88 @@ class KnowledgeTable:
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def search_files_by_id(
|
||||||
|
self,
|
||||||
|
knowledge_id: str,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> KnowledgeFileListResponse:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
query = (
|
||||||
|
db.query(File, User)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||||
|
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(KnowledgeFile.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(KnowledgeFile.user_id != user_id)
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.filename.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.filename.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for file, user in items:
|
||||||
|
files.append(
|
||||||
|
FileUserResponse(
|
||||||
|
**FileModel.model_validate(file).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeFileListResponse(items=files, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return KnowledgeFileListResponse(items=[], total=0)
|
||||||
|
|
||||||
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ from functools import lru_cache
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
|
||||||
####################
|
####################
|
||||||
|
|
@ -75,7 +78,138 @@ class NoteUserResponse(NoteModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteItemResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
data: Optional[dict]
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteListResponse(BaseModel):
|
||||||
|
items: list[NoteUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class NoteTable:
|
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
|
||||||
|
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
Note.access_control.is_(None),
|
||||||
|
cast(Note.access_control, String) == "null",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# User-level permission (owner has all permissions)
|
||||||
|
if user_id:
|
||||||
|
conditions.append(Note.user_id == user_id)
|
||||||
|
|
||||||
|
# Group-level permission
|
||||||
|
if group_ids:
|
||||||
|
group_conditions = []
|
||||||
|
for gid in group_ids:
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
group_conditions.append(
|
||||||
|
Note.access_control[permission]["group_ids"].contains([gid])
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_conditions.append(
|
||||||
|
cast(
|
||||||
|
Note.access_control[permission]["group_ids"],
|
||||||
|
JSONB,
|
||||||
|
).contains([gid])
|
||||||
|
)
|
||||||
|
conditions.append(or_(*group_conditions))
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(or_(*conditions))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
def insert_new_note(
|
def insert_new_note(
|
||||||
self,
|
self,
|
||||||
form_data: NoteForm,
|
form_data: NoteForm,
|
||||||
|
|
@ -110,15 +244,107 @@ class NoteTable:
|
||||||
notes = query.all()
|
notes = query.all()
|
||||||
return [NoteModel.model_validate(note) for note in notes]
|
return [NoteModel.model_validate(note) for note in notes]
|
||||||
|
|
||||||
|
def search_notes(
|
||||||
|
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
|
||||||
|
) -> NoteListResponse:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Note.title.ilike(f"%{query_key}%"),
|
||||||
|
cast(Note.data["content"]["md"], Text).ilike(
|
||||||
|
f"%{query_key}%"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Note.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
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=permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.title.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.title.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
for note, user in items:
|
||||||
|
notes.append(
|
||||||
|
NoteUserResponse(
|
||||||
|
**NoteModel.model_validate(note).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NoteListResponse(items=notes, total=total)
|
||||||
|
|
||||||
def get_notes_by_user_id(
|
def get_notes_by_user_id(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
permission: str = "read",
|
||||||
skip: Optional[int] = None,
|
skip: Optional[int] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
) -> list[NoteModel]:
|
) -> list[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
query = db.query(Note).filter(Note.user_id == user_id)
|
user_group_ids = [
|
||||||
query = query.order_by(Note.updated_at.desc())
|
group.id for group in Groups.get_groups_by_member_id(user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||||
|
query = self._has_permission(
|
||||||
|
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
|
||||||
|
)
|
||||||
|
|
||||||
if skip is not None:
|
if skip is not None:
|
||||||
query = query.offset(skip)
|
query = query.offset(skip)
|
||||||
|
|
@ -128,56 +354,6 @@ class NoteTable:
|
||||||
notes = query.all()
|
notes = query.all()
|
||||||
return [NoteModel.model_validate(note) for note in notes]
|
return [NoteModel.model_validate(note) for note in notes]
|
||||||
|
|
||||||
def get_notes_by_permission(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
permission: str = "write",
|
|
||||||
skip: Optional[int] = None,
|
|
||||||
limit: Optional[int] = None,
|
|
||||||
) -> list[NoteModel]:
|
|
||||||
with get_db() as db:
|
|
||||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
|
||||||
user_group_ids = {group.id for group in user_groups}
|
|
||||||
|
|
||||||
# Order newest-first. We stream to keep memory usage low.
|
|
||||||
query = (
|
|
||||||
db.query(Note)
|
|
||||||
.order_by(Note.updated_at.desc())
|
|
||||||
.execution_options(stream_results=True)
|
|
||||||
.yield_per(256)
|
|
||||||
)
|
|
||||||
|
|
||||||
results: list[NoteModel] = []
|
|
||||||
n_skipped = 0
|
|
||||||
|
|
||||||
for note in query:
|
|
||||||
# Fast-pass #1: owner
|
|
||||||
if note.user_id == user_id:
|
|
||||||
permitted = True
|
|
||||||
# Fast-pass #2: public/open
|
|
||||||
elif note.access_control is None:
|
|
||||||
# Technically this should mean public access for both read and write, but we'll only do read for now
|
|
||||||
# We might want to change this behavior later
|
|
||||||
permitted = permission == "read"
|
|
||||||
else:
|
|
||||||
permitted = has_access(
|
|
||||||
user_id, permission, note.access_control, user_group_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
if not permitted:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Apply skip AFTER permission filtering so it counts only accessible notes
|
|
||||||
if skip and n_skipped < skip:
|
|
||||||
n_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
results.append(NoteModel.model_validate(note))
|
|
||||||
if limit is not None and len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
from open_webui.models.groups import Groups, GroupMember
|
from open_webui.models.groups import Groups, GroupMember
|
||||||
from open_webui.models.channels import ChannelMember
|
from open_webui.models.channels import ChannelMember
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.misc import throttle
|
from open_webui.utils.misc import throttle
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.utils.misc import strict_match_mime_type
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.headers import include_user_info_headers
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
from open_webui.config import (
|
from open_webui.config import (
|
||||||
|
|
@ -1155,17 +1156,9 @@ def transcription(
|
||||||
|
|
||||||
stt_supported_content_types = getattr(
|
stt_supported_content_types = getattr(
|
||||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
)
|
) or ["audio/*", "video/webm"]
|
||||||
|
|
||||||
if not any(
|
if not strict_match_mime_type(stt_supported_content_types, file.content_type):
|
||||||
fnmatch(file.content_type, content_type)
|
|
||||||
for content_type in (
|
|
||||||
stt_supported_content_types
|
|
||||||
if stt_supported_content_types
|
|
||||||
and any(t.strip() for t in stt_supported_content_types)
|
|
||||||
else ["audio/*", "video/webm"]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
||||||
|
|
|
||||||
|
|
@ -1093,6 +1093,15 @@ async def post_new_message(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message, channel = await new_message_handler(request, id, form_data, user)
|
message, channel = await new_message_handler(request, id, form_data, user)
|
||||||
|
try:
|
||||||
|
if files := message.data.get("files", []):
|
||||||
|
for file in files:
|
||||||
|
Channels.set_file_message_id_in_channel_by_id(
|
||||||
|
channel.id, file.get("id", ""), message.id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(e)
|
||||||
|
|
||||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||||
|
|
||||||
async def background_handler():
|
async def background_handler():
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.utils.misc import get_message_list
|
||||||
from open_webui.socket.main import get_event_emitter
|
from open_webui.socket.main import get_event_emitter
|
||||||
from open_webui.models.chats import (
|
from open_webui.models.chats import (
|
||||||
ChatForm,
|
ChatForm,
|
||||||
ChatImportForm,
|
ChatImportForm,
|
||||||
|
ChatUsageStatsListResponse,
|
||||||
ChatsImportForm,
|
ChatsImportForm,
|
||||||
ChatResponse,
|
ChatResponse,
|
||||||
Chats,
|
Chats,
|
||||||
|
|
@ -66,6 +68,132 @@ def get_session_user_chat_list(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# GetChatUsageStats
|
||||||
|
# EXPERIMENTAL: may be removed in future releases
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/usage", response_model=ChatUsageStatsListResponse)
|
||||||
|
def get_session_user_chat_usage_stats(
|
||||||
|
items_per_page: Optional[int] = 50,
|
||||||
|
page: Optional[int] = 1,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
limit = items_per_page
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
chats = result.items
|
||||||
|
total = result.total
|
||||||
|
|
||||||
|
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:
|
||||||
|
history_models = {}
|
||||||
|
history_message_count = len(messages_map)
|
||||||
|
history_user_messages = []
|
||||||
|
history_assistant_messages = []
|
||||||
|
|
||||||
|
for message in messages_map.values():
|
||||||
|
if message.get("role", "") == "user":
|
||||||
|
history_user_messages.append(message)
|
||||||
|
elif message.get("role", "") == "assistant":
|
||||||
|
history_assistant_messages.append(message)
|
||||||
|
model = message.get("model", None)
|
||||||
|
if model:
|
||||||
|
if model not in history_models:
|
||||||
|
history_models[model] = 0
|
||||||
|
history_models[model] += 1
|
||||||
|
|
||||||
|
average_user_message_content_length = (
|
||||||
|
sum(
|
||||||
|
len(message.get("content", ""))
|
||||||
|
for message in history_user_messages
|
||||||
|
)
|
||||||
|
/ len(history_user_messages)
|
||||||
|
if len(history_user_messages) > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
average_assistant_message_content_length = (
|
||||||
|
sum(
|
||||||
|
len(message.get("content", ""))
|
||||||
|
for message in history_assistant_messages
|
||||||
|
)
|
||||||
|
/ len(history_assistant_messages)
|
||||||
|
if len(history_assistant_messages) > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
response_times = []
|
||||||
|
for message in history_assistant_messages:
|
||||||
|
user_message_id = message.get("parentId", None)
|
||||||
|
if user_message_id and user_message_id in messages_map:
|
||||||
|
user_message = messages_map[user_message_id]
|
||||||
|
response_time = message.get(
|
||||||
|
"timestamp", 0
|
||||||
|
) - user_message.get("timestamp", 0)
|
||||||
|
|
||||||
|
response_times.append(response_time)
|
||||||
|
|
||||||
|
average_response_time = (
|
||||||
|
sum(response_times) / len(response_times)
|
||||||
|
if len(response_times) > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
message_list = get_message_list(messages_map, message_id)
|
||||||
|
message_count = len(message_list)
|
||||||
|
|
||||||
|
models = {}
|
||||||
|
for message in reversed(message_list):
|
||||||
|
if message.get("role") == "assistant":
|
||||||
|
model = message.get("model", None)
|
||||||
|
if model:
|
||||||
|
if model not in models:
|
||||||
|
models[model] = 0
|
||||||
|
models[model] += 1
|
||||||
|
|
||||||
|
annotation = message.get("annotation", {})
|
||||||
|
|
||||||
|
chat_stats.append(
|
||||||
|
{
|
||||||
|
"id": chat.id,
|
||||||
|
"models": models,
|
||||||
|
"message_count": message_count,
|
||||||
|
"history_models": history_models,
|
||||||
|
"history_message_count": history_message_count,
|
||||||
|
"history_user_message_count": len(history_user_messages),
|
||||||
|
"history_assistant_message_count": len(
|
||||||
|
history_assistant_messages
|
||||||
|
),
|
||||||
|
"average_response_time": average_response_time,
|
||||||
|
"average_user_message_content_length": average_user_message_content_length,
|
||||||
|
"average_assistant_message_content_length": average_assistant_message_content_length,
|
||||||
|
"tags": chat.meta.get("tags", []),
|
||||||
|
"last_message_at": message_list[-1].get("timestamp", None),
|
||||||
|
"updated_at": chat.updated_at,
|
||||||
|
"created_at": chat.created_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ChatUsageStatsListResponse(items=chat_stats, total=total)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# DeleteAllChats
|
# DeleteAllChats
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||||
|
|
||||||
|
from open_webui.models.channels import Channels
|
||||||
from open_webui.models.users import Users
|
from open_webui.models.users import Users
|
||||||
from open_webui.models.files import (
|
from open_webui.models.files import (
|
||||||
FileForm,
|
FileForm,
|
||||||
|
|
@ -38,7 +39,6 @@ from open_webui.models.knowledge import Knowledges
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
|
|
||||||
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
|
|
||||||
from open_webui.routers.retrieval import ProcessFileForm, process_file
|
from open_webui.routers.retrieval import ProcessFileForm, process_file
|
||||||
from open_webui.routers.audio import transcribe
|
from open_webui.routers.audio import transcribe
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ from open_webui.storage.provider import Storage
|
||||||
|
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
|
from open_webui.utils.misc import strict_match_mime_type
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -91,6 +91,10 @@ def has_access_to_file(
|
||||||
if knowledge_base.id == knowledge_base_id:
|
if knowledge_base.id == knowledge_base_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id)
|
||||||
|
if access_type == "read" and channels:
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,17 +108,9 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
|
||||||
if file.content_type:
|
if file.content_type:
|
||||||
stt_supported_content_types = getattr(
|
stt_supported_content_types = getattr(
|
||||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
)
|
) or ["audio/*", "video/webm"]
|
||||||
|
|
||||||
if any(
|
if strict_match_mime_type(stt_supported_content_types, file.content_type):
|
||||||
fnmatch(file.content_type, content_type)
|
|
||||||
for content_type in (
|
|
||||||
stt_supported_content_types
|
|
||||||
if stt_supported_content_types
|
|
||||||
and any(t.strip() for t in stt_supported_content_types)
|
|
||||||
else ["audio/*", "video/webm"]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
file_path = Storage.get_file(file_path)
|
file_path = Storage.get_file(file_path)
|
||||||
result = transcribe(request, file_path, file_metadata, user)
|
result = transcribe(request, file_path, file_metadata, user)
|
||||||
|
|
||||||
|
|
@ -138,6 +134,7 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
|
||||||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||||
)
|
)
|
||||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error processing file: {file_item.id}")
|
log.error(f"Error processing file: {file_item.id}")
|
||||||
Files.update_file_data_by_id(
|
Files.update_file_data_by_id(
|
||||||
|
|
@ -247,6 +244,13 @@ def upload_file_handler(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "channel_id" in file_metadata:
|
||||||
|
channel = Channels.get_channel_by_id_and_user_id(
|
||||||
|
file_metadata["channel_id"], user.id
|
||||||
|
)
|
||||||
|
if channel:
|
||||||
|
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id)
|
||||||
|
|
||||||
if process:
|
if process:
|
||||||
if background_tasks and process_in_background:
|
if background_tasks and process_in_background:
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.knowledge import (
|
from open_webui.models.knowledge import (
|
||||||
|
KnowledgeFileListResponse,
|
||||||
Knowledges,
|
Knowledges,
|
||||||
KnowledgeForm,
|
KnowledgeForm,
|
||||||
KnowledgeResponse,
|
KnowledgeResponse,
|
||||||
|
|
@ -39,41 +41,115 @@ router = APIRouter()
|
||||||
# getKnowledgeBases
|
# getKnowledgeBases
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
PAGE_ITEM_COUNT = 30
|
||||||
|
|
||||||
@router.get("/", response_model=list[KnowledgeUserResponse])
|
|
||||||
async def get_knowledge(user=Depends(get_verified_user)):
|
|
||||||
# Return knowledge bases with read access
|
|
||||||
knowledge_bases = []
|
|
||||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
|
||||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
|
||||||
else:
|
|
||||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
|
|
||||||
|
|
||||||
return [
|
class KnowledgeAccessResponse(KnowledgeUserResponse):
|
||||||
KnowledgeUserResponse(
|
write_access: Optional[bool] = False
|
||||||
**knowledge_base.model_dump(),
|
|
||||||
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
|
|
||||||
|
class KnowledgeAccessListResponse(BaseModel):
|
||||||
|
items: list[KnowledgeAccessResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=KnowledgeAccessListResponse)
|
||||||
|
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)):
|
||||||
|
page = max(page, 1)
|
||||||
|
limit = PAGE_ITEM_COUNT
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||||
|
groups = Groups.get_groups_by_member_id(user.id)
|
||||||
|
if groups:
|
||||||
|
filter["group_ids"] = [group.id for group in groups]
|
||||||
|
|
||||||
|
filter["user_id"] = user.id
|
||||||
|
|
||||||
|
result = Knowledges.search_knowledge_bases(
|
||||||
|
user.id, filter=filter, skip=skip, limit=limit
|
||||||
)
|
)
|
||||||
for knowledge_base in knowledge_bases
|
|
||||||
]
|
|
||||||
|
|
||||||
|
return KnowledgeAccessListResponse(
|
||||||
@router.get("/list", response_model=list[KnowledgeUserResponse])
|
items=[
|
||||||
async def get_knowledge_list(user=Depends(get_verified_user)):
|
KnowledgeAccessResponse(
|
||||||
# Return knowledge bases with write access
|
|
||||||
knowledge_bases = []
|
|
||||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
|
||||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
|
||||||
else:
|
|
||||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
|
|
||||||
|
|
||||||
return [
|
|
||||||
KnowledgeUserResponse(
|
|
||||||
**knowledge_base.model_dump(),
|
**knowledge_base.model_dump(),
|
||||||
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
|
write_access=(
|
||||||
|
user.id == knowledge_base.user_id
|
||||||
|
or has_access(user.id, "write", knowledge_base.access_control)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for knowledge_base in knowledge_bases
|
for knowledge_base in result.items
|
||||||
]
|
],
|
||||||
|
total=result.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=KnowledgeAccessListResponse)
|
||||||
|
async def search_knowledge_bases(
|
||||||
|
query: Optional[str] = None,
|
||||||
|
view_option: Optional[str] = None,
|
||||||
|
page: Optional[int] = 1,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
page = max(page, 1)
|
||||||
|
limit = PAGE_ITEM_COUNT
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if query:
|
||||||
|
filter["query"] = query
|
||||||
|
if view_option:
|
||||||
|
filter["view_option"] = view_option
|
||||||
|
|
||||||
|
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||||
|
groups = Groups.get_groups_by_member_id(user.id)
|
||||||
|
if groups:
|
||||||
|
filter["group_ids"] = [group.id for group in groups]
|
||||||
|
|
||||||
|
filter["user_id"] = user.id
|
||||||
|
|
||||||
|
result = Knowledges.search_knowledge_bases(
|
||||||
|
user.id, filter=filter, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeAccessListResponse(
|
||||||
|
items=[
|
||||||
|
KnowledgeAccessResponse(
|
||||||
|
**knowledge_base.model_dump(),
|
||||||
|
write_access=(
|
||||||
|
user.id == knowledge_base.user_id
|
||||||
|
or has_access(user.id, "write", knowledge_base.access_control)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for knowledge_base in result.items
|
||||||
|
],
|
||||||
|
total=result.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/files", response_model=KnowledgeFileListResponse)
|
||||||
|
async def search_knowledge_files(
|
||||||
|
query: Optional[str] = None,
|
||||||
|
page: Optional[int] = 1,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
page = max(page, 1)
|
||||||
|
limit = PAGE_ITEM_COUNT
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if query:
|
||||||
|
filter["query"] = query
|
||||||
|
|
||||||
|
groups = Groups.get_groups_by_member_id(user.id)
|
||||||
|
if groups:
|
||||||
|
filter["group_ids"] = [group.id for group in groups]
|
||||||
|
|
||||||
|
filter["user_id"] = user.id
|
||||||
|
|
||||||
|
return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|
@ -185,7 +261,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeFilesResponse(KnowledgeResponse):
|
class KnowledgeFilesResponse(KnowledgeResponse):
|
||||||
files: list[FileMetadataResponse]
|
files: Optional[list[FileMetadataResponse]] = None
|
||||||
|
write_access: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
|
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
|
||||||
|
|
@ -201,7 +278,10 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
return KnowledgeFilesResponse(
|
return KnowledgeFilesResponse(
|
||||||
**knowledge.model_dump(),
|
**knowledge.model_dump(),
|
||||||
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
|
write_access=(
|
||||||
|
user.id == knowledge.user_id
|
||||||
|
or has_access(user.id, "write", knowledge.access_control)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -264,6 +344,59 @@ async def update_knowledge_by_id(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# GetKnowledgeFilesById
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
|
||||||
|
async def get_knowledge_files_by_id(
|
||||||
|
id: str,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
view_option: Optional[str] = None,
|
||||||
|
order_by: Optional[str] = None,
|
||||||
|
direction: Optional[str] = None,
|
||||||
|
page: Optional[int] = 1,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
|
||||||
|
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||||
|
if not knowledge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
user.role == "admin"
|
||||||
|
or knowledge.user_id == user.id
|
||||||
|
or has_access(user.id, "read", knowledge.access_control)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = max(page, 1)
|
||||||
|
|
||||||
|
limit = 30
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if query:
|
||||||
|
filter["query"] = query
|
||||||
|
if view_option:
|
||||||
|
filter["view_option"] = view_option
|
||||||
|
if order_by:
|
||||||
|
filter["order_by"] = order_by
|
||||||
|
if direction:
|
||||||
|
filter["direction"] = direction
|
||||||
|
|
||||||
|
return Knowledges.search_files_by_id(
|
||||||
|
id, user.id, filter=filter, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# AddFileToKnowledge
|
# AddFileToKnowledge
|
||||||
############################
|
############################
|
||||||
|
|
@ -309,11 +442,6 @@ def add_file_to_knowledge_by_id(
|
||||||
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
|
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add file to knowledge base
|
|
||||||
Knowledges.add_file_to_knowledge_by_id(
|
|
||||||
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add content to the vector database
|
# Add content to the vector database
|
||||||
try:
|
try:
|
||||||
process_file(
|
process_file(
|
||||||
|
|
@ -321,6 +449,11 @@ def add_file_to_knowledge_by_id(
|
||||||
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
|
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add file to knowledge base
|
||||||
|
Knowledges.add_file_to_knowledge_by_id(
|
||||||
|
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(e)
|
log.debug(e)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,21 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from open_webui.socket.main import sio
|
from open_webui.socket.main import sio
|
||||||
|
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
|
from open_webui.models.notes import (
|
||||||
|
NoteListResponse,
|
||||||
|
Notes,
|
||||||
|
NoteModel,
|
||||||
|
NoteForm,
|
||||||
|
NoteUserResponse,
|
||||||
|
)
|
||||||
|
|
||||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
from open_webui.config import (
|
||||||
|
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||||
|
ENABLE_ADMIN_CHAT_ACCESS,
|
||||||
|
ENABLE_ADMIN_EXPORT,
|
||||||
|
)
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
@ -30,39 +40,17 @@ router = APIRouter()
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[NoteUserResponse])
|
class NoteItemResponse(BaseModel):
|
||||||
async def get_notes(request: Request, user=Depends(get_verified_user)):
|
|
||||||
|
|
||||||
if user.role != "admin" and not has_permission(
|
|
||||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
notes = [
|
|
||||||
NoteUserResponse(
|
|
||||||
**{
|
|
||||||
**note.model_dump(),
|
|
||||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for note in Notes.get_notes_by_permission(user.id, "write")
|
|
||||||
]
|
|
||||||
|
|
||||||
return notes
|
|
||||||
|
|
||||||
|
|
||||||
class NoteTitleIdResponse(BaseModel):
|
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
|
data: Optional[dict]
|
||||||
updated_at: int
|
updated_at: int
|
||||||
created_at: int
|
created_at: int
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
@router.get("/", response_model=list[NoteItemResponse])
|
||||||
async def get_note_list(
|
async def get_notes(
|
||||||
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
|
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
|
|
@ -80,15 +68,64 @@ async def get_note_list(
|
||||||
skip = (page - 1) * limit
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
notes = [
|
notes = [
|
||||||
NoteTitleIdResponse(**note.model_dump())
|
NoteUserResponse(
|
||||||
for note in Notes.get_notes_by_permission(
|
**{
|
||||||
user.id, "write", skip=skip, limit=limit
|
**note.model_dump(),
|
||||||
|
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit)
|
||||||
]
|
]
|
||||||
|
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=NoteListResponse)
|
||||||
|
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,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
if user.role != "admin" and not has_permission(
|
||||||
|
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
limit = None
|
||||||
|
skip = None
|
||||||
|
if page is not None:
|
||||||
|
limit = 60
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if query:
|
||||||
|
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:
|
||||||
|
filter["direction"] = direction
|
||||||
|
|
||||||
|
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||||
|
groups = Groups.get_groups_by_member_id(user.id)
|
||||||
|
if groups:
|
||||||
|
filter["group_ids"] = [group.id for group in groups]
|
||||||
|
|
||||||
|
filter["user_id"] = user.id
|
||||||
|
|
||||||
|
return Notes.search_notes(user.id, filter, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# CreateNewNote
|
# CreateNewNote
|
||||||
############################
|
############################
|
||||||
|
|
@ -98,7 +135,6 @@ async def get_note_list(
|
||||||
async def create_new_note(
|
async def create_new_note(
|
||||||
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
|
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
|
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||||
):
|
):
|
||||||
|
|
@ -122,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)):
|
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||||
|
|
@ -146,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()
|
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)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
130
backend/open_webui/utils/db/access_control.py
Normal file
130
backend/open_webui/utils/db/access_control.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(db, DocumentModel, query, filter: dict, permission: str = "read"):
|
||||||
|
group_ids = filter.get("group_ids", [])
|
||||||
|
user_id = filter.get("user_id")
|
||||||
|
dialect_name = db.bind.dialect.name
|
||||||
|
|
||||||
|
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(
|
||||||
|
DocumentModel.access_control["read"]["group_ids"].contains(
|
||||||
|
[gid]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_read_conditions.append(
|
||||||
|
cast(
|
||||||
|
DocumentModel.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(DocumentModel.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(
|
||||||
|
DocumentModel.access_control["write"]["group_ids"].contains(
|
||||||
|
[gid]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_write_conditions.append(
|
||||||
|
cast(
|
||||||
|
DocumentModel.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(DocumentModel.access_control.isnot(None))
|
||||||
|
write_exclusions.append(cast(DocumentModel.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(
|
||||||
|
[
|
||||||
|
DocumentModel.access_control.is_(None),
|
||||||
|
cast(DocumentModel.access_control, String) == "null",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# User-level permission (owner has all permissions)
|
||||||
|
if user_id:
|
||||||
|
conditions.append(DocumentModel.user_id == user_id)
|
||||||
|
|
||||||
|
# Group-level permission
|
||||||
|
if group_ids:
|
||||||
|
group_conditions = []
|
||||||
|
for gid in group_ids:
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
group_conditions.append(
|
||||||
|
DocumentModel.access_control[permission]["group_ids"].contains(
|
||||||
|
[gid]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_conditions.append(
|
||||||
|
cast(
|
||||||
|
DocumentModel.access_control[permission]["group_ids"],
|
||||||
|
JSONB,
|
||||||
|
).contains([gid])
|
||||||
|
)
|
||||||
|
conditions.append(or_(*group_conditions))
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(or_(*conditions))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
@ -9,6 +9,7 @@ from pathlib import Path
|
||||||
from typing import Callable, Optional, Sequence, Union
|
from typing import Callable, Optional, Sequence, Union
|
||||||
import json
|
import json
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import mimeparse
|
||||||
|
|
||||||
|
|
||||||
import collections.abc
|
import collections.abc
|
||||||
|
|
@ -577,6 +578,37 @@ def throttle(interval: float = 10.0):
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Strictly match the mime type with the supported mime types.
|
||||||
|
|
||||||
|
:param supported: The supported mime types.
|
||||||
|
:param header: The header to match.
|
||||||
|
:return: The matched mime type or None if no match is found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(supported, str):
|
||||||
|
supported = supported.split(",")
|
||||||
|
|
||||||
|
supported = [s for s in supported if s.strip() and "/" in s]
|
||||||
|
|
||||||
|
match = mimeparse.best_match(supported, header)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_, _, match_params = mimeparse.parse_mime_type(match)
|
||||||
|
_, _, header_params = mimeparse.parse_mime_type(header)
|
||||||
|
for k, v in match_params.items():
|
||||||
|
if header_params.get(k) != v:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return match
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Failed to match mime type {header}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_urls(text: str) -> list[str]:
|
def extract_urls(text: str) -> list[str]:
|
||||||
# Regex pattern to match URLs
|
# Regex pattern to match URLs
|
||||||
url_pattern = re.compile(
|
url_pattern = re.compile(
|
||||||
|
|
@ -624,14 +656,17 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
|
||||||
yield line
|
yield line
|
||||||
else:
|
else:
|
||||||
yield b"data: {}"
|
yield b"data: {}"
|
||||||
|
yield b"\n"
|
||||||
else:
|
else:
|
||||||
# Normal mode: check if line exceeds limit
|
# Normal mode: check if line exceeds limit
|
||||||
if len(line) > max_buffer_size:
|
if len(line) > max_buffer_size:
|
||||||
skip_mode = True
|
skip_mode = True
|
||||||
yield b"data: {}"
|
yield b"data: {}"
|
||||||
|
yield b"\n"
|
||||||
log.info(f"Skip mode triggered, line size: {len(line)}")
|
log.info(f"Skip mode triggered, line size: {len(line)}")
|
||||||
else:
|
else:
|
||||||
yield line
|
yield line
|
||||||
|
yield b"\n"
|
||||||
|
|
||||||
# Save the last incomplete fragment
|
# Save the last incomplete fragment
|
||||||
buffer = lines[-1]
|
buffer = lines[-1]
|
||||||
|
|
@ -646,5 +681,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
|
||||||
# Process remaining buffer data
|
# Process remaining buffer data
|
||||||
if buffer and not skip_mode:
|
if buffer and not skip_mode:
|
||||||
yield buffer
|
yield buffer
|
||||||
|
yield b"\n"
|
||||||
|
|
||||||
return yield_safe_stream_chunks()
|
return yield_safe_stream_chunks()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Minimal requirements for backend to run
|
# Minimal requirements for backend to run
|
||||||
# WIP: use this as a reference to build a minimal docker image
|
# WIP: use this as a reference to build a minimal docker image
|
||||||
|
|
||||||
fastapi==0.123.0
|
fastapi==0.124.0
|
||||||
uvicorn[standard]==0.37.0
|
uvicorn[standard]==0.37.0
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
|
@ -16,7 +16,7 @@ PyJWT[crypto]==2.10.1
|
||||||
authlib==1.6.5
|
authlib==1.6.5
|
||||||
|
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
aiohttp==3.12.15
|
aiohttp==3.13.2
|
||||||
async-timeout
|
async-timeout
|
||||||
aiocache
|
aiocache
|
||||||
aiofiles
|
aiofiles
|
||||||
|
|
@ -24,21 +24,21 @@ starlette-compress==1.6.1
|
||||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||||
starsessions[redis]==2.2.1
|
starsessions[redis]==2.2.1
|
||||||
|
|
||||||
sqlalchemy==2.0.38
|
sqlalchemy==2.0.44
|
||||||
alembic==1.17.2
|
alembic==1.17.2
|
||||||
peewee==3.18.3
|
peewee==3.18.3
|
||||||
peewee-migrate==1.14.3
|
peewee-migrate==1.14.3
|
||||||
|
|
||||||
pycrdt==0.12.25
|
pycrdt==0.12.44
|
||||||
redis
|
redis
|
||||||
|
|
||||||
APScheduler==3.10.4
|
APScheduler==3.11.1
|
||||||
RestrictedPython==8.0
|
RestrictedPython==8.1
|
||||||
|
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
asgiref==3.11.0
|
asgiref==3.11.0
|
||||||
|
|
||||||
mcp==1.22.0
|
mcp==1.23.1
|
||||||
openai
|
openai
|
||||||
|
|
||||||
langchain==0.3.27
|
langchain==0.3.27
|
||||||
|
|
@ -46,6 +46,6 @@ langchain-community==0.3.29
|
||||||
fake-useragent==2.2.0
|
fake-useragent==2.2.0
|
||||||
|
|
||||||
chromadb==1.3.5
|
chromadb==1.3.5
|
||||||
black==25.11.0
|
black==25.12.0
|
||||||
pydub
|
pydub
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
fastapi==0.123.0
|
fastapi==0.124.0
|
||||||
uvicorn[standard]==0.37.0
|
uvicorn[standard]==0.37.0
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
|
@ -13,35 +13,36 @@ PyJWT[crypto]==2.10.1
|
||||||
authlib==1.6.5
|
authlib==1.6.5
|
||||||
|
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
aiohttp==3.12.15
|
aiohttp==3.13.2
|
||||||
async-timeout
|
async-timeout
|
||||||
aiocache
|
aiocache
|
||||||
aiofiles
|
aiofiles
|
||||||
starlette-compress==1.6.1
|
starlette-compress==1.6.1
|
||||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||||
starsessions[redis]==2.2.1
|
starsessions[redis]==2.2.1
|
||||||
|
python-mimeparse==2.0.0
|
||||||
|
|
||||||
sqlalchemy==2.0.38
|
sqlalchemy==2.0.44
|
||||||
alembic==1.17.2
|
alembic==1.17.2
|
||||||
peewee==3.18.3
|
peewee==3.18.3
|
||||||
peewee-migrate==1.14.3
|
peewee-migrate==1.14.3
|
||||||
|
|
||||||
pycrdt==0.12.25
|
pycrdt==0.12.44
|
||||||
redis
|
redis
|
||||||
|
|
||||||
APScheduler==3.10.4
|
APScheduler==3.11.1
|
||||||
RestrictedPython==8.0
|
RestrictedPython==8.1
|
||||||
|
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
asgiref==3.11.0
|
asgiref==3.11.0
|
||||||
|
|
||||||
# AI libraries
|
# AI libraries
|
||||||
tiktoken
|
tiktoken
|
||||||
mcp==1.22.0
|
mcp==1.23.3
|
||||||
|
|
||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
google-genai==1.52.0
|
google-genai==1.54.0
|
||||||
google-generativeai==0.8.5
|
google-generativeai==0.8.5
|
||||||
|
|
||||||
langchain==0.3.27
|
langchain==0.3.27
|
||||||
|
|
@ -49,8 +50,8 @@ langchain-community==0.3.29
|
||||||
|
|
||||||
fake-useragent==2.2.0
|
fake-useragent==2.2.0
|
||||||
chromadb==1.3.5
|
chromadb==1.3.5
|
||||||
weaviate-client==4.17.0
|
weaviate-client==4.18.3
|
||||||
opensearch-py==2.8.0
|
opensearch-py==3.1.0
|
||||||
|
|
||||||
transformers==4.57.3
|
transformers==4.57.3
|
||||||
sentence-transformers==5.1.2
|
sentence-transformers==5.1.2
|
||||||
|
|
@ -60,43 +61,43 @@ einops==0.8.1
|
||||||
|
|
||||||
ftfy==6.3.1
|
ftfy==6.3.1
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
pypdf==6.4.0
|
pypdf==6.4.1
|
||||||
fpdf2==2.8.2
|
fpdf2==2.8.5
|
||||||
pymdown-extensions==10.17.2
|
pymdown-extensions==10.18
|
||||||
docx2txt==0.8
|
docx2txt==0.9
|
||||||
python-pptx==1.0.2
|
python-pptx==1.0.2
|
||||||
unstructured==0.18.21
|
unstructured==0.18.21
|
||||||
msoffcrypto-tool==5.4.2
|
msoffcrypto-tool==5.4.2
|
||||||
nltk==3.9.1
|
nltk==3.9.2
|
||||||
Markdown==3.10
|
Markdown==3.10
|
||||||
pypandoc==1.16.2
|
pypandoc==1.16.2
|
||||||
pandas==2.2.3
|
pandas==2.3.3
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
pyxlsb==1.0.10
|
pyxlsb==1.0.10
|
||||||
xlrd==2.0.1
|
xlrd==2.0.2
|
||||||
validators==0.35.0
|
validators==0.35.0
|
||||||
psutil
|
psutil
|
||||||
sentencepiece
|
sentencepiece
|
||||||
soundfile==0.13.1
|
soundfile==0.13.1
|
||||||
|
|
||||||
pillow==11.3.0
|
pillow==12.0.0
|
||||||
opencv-python-headless==4.11.0.86
|
opencv-python-headless==4.12.0.88
|
||||||
rapidocr-onnxruntime==1.4.4
|
rapidocr-onnxruntime==1.4.4
|
||||||
rank-bm25==0.2.2
|
rank-bm25==0.2.2
|
||||||
|
|
||||||
onnxruntime==1.20.1
|
onnxruntime==1.23.2
|
||||||
faster-whisper==1.1.1
|
faster-whisper==1.2.1
|
||||||
|
|
||||||
black==25.11.0
|
black==25.12.0
|
||||||
youtube-transcript-api==1.2.2
|
youtube-transcript-api==1.2.3
|
||||||
pytube==15.0.0
|
pytube==15.0.0
|
||||||
|
|
||||||
pydub
|
pydub
|
||||||
ddgs==9.9.2
|
ddgs==9.9.3
|
||||||
|
|
||||||
azure-ai-documentintelligence==1.0.2
|
azure-ai-documentintelligence==1.0.2
|
||||||
azure-identity==1.25.0
|
azure-identity==1.25.1
|
||||||
azure-storage-blob==12.24.1
|
azure-storage-blob==12.27.1
|
||||||
azure-search-documents==11.6.0
|
azure-search-documents==11.6.0
|
||||||
|
|
||||||
## Google Drive
|
## Google Drive
|
||||||
|
|
@ -105,26 +106,26 @@ google-auth-httplib2
|
||||||
google-auth-oauthlib
|
google-auth-oauthlib
|
||||||
|
|
||||||
googleapis-common-protos==1.72.0
|
googleapis-common-protos==1.72.0
|
||||||
google-cloud-storage==2.19.0
|
google-cloud-storage==3.7.0
|
||||||
|
|
||||||
## Databases
|
## Databases
|
||||||
pymongo
|
pymongo
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.11
|
||||||
pgvector==0.4.1
|
pgvector==0.4.2
|
||||||
|
|
||||||
PyMySQL==1.1.1
|
PyMySQL==1.1.2
|
||||||
boto3==1.41.5
|
boto3==1.42.5
|
||||||
|
|
||||||
pymilvus==2.6.5
|
pymilvus==2.6.5
|
||||||
qdrant-client==1.16.1
|
qdrant-client==1.16.1
|
||||||
playwright==1.56.0 # Caution: version must match docker-compose.playwright.yaml
|
playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||||
elasticsearch==9.1.0
|
elasticsearch==9.2.0
|
||||||
pinecone==6.0.2
|
pinecone==6.0.2
|
||||||
oracledb==3.2.0
|
oracledb==3.4.1
|
||||||
|
|
||||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||||
|
|
||||||
colbert-ai==0.2.21
|
colbert-ai==0.2.22
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
@ -136,17 +137,17 @@ pytest-docker~=3.2.5
|
||||||
ldap3==2.9.1
|
ldap3==2.9.1
|
||||||
|
|
||||||
## Firecrawl
|
## Firecrawl
|
||||||
firecrawl-py==4.10.0
|
firecrawl-py==4.10.4
|
||||||
|
|
||||||
## Trace
|
## Trace
|
||||||
opentelemetry-api==1.38.0
|
opentelemetry-api==1.39.0
|
||||||
opentelemetry-sdk==1.38.0
|
opentelemetry-sdk==1.39.0
|
||||||
opentelemetry-exporter-otlp==1.38.0
|
opentelemetry-exporter-otlp==1.39.0
|
||||||
opentelemetry-instrumentation==0.59b0
|
opentelemetry-instrumentation==0.60b0
|
||||||
opentelemetry-instrumentation-fastapi==0.59b0
|
opentelemetry-instrumentation-fastapi==0.60b0
|
||||||
opentelemetry-instrumentation-sqlalchemy==0.59b0
|
opentelemetry-instrumentation-sqlalchemy==0.60b0
|
||||||
opentelemetry-instrumentation-redis==0.59b0
|
opentelemetry-instrumentation-redis==0.60b0
|
||||||
opentelemetry-instrumentation-requests==0.59b0
|
opentelemetry-instrumentation-requests==0.60b0
|
||||||
opentelemetry-instrumentation-logging==0.59b0
|
opentelemetry-instrumentation-logging==0.60b0
|
||||||
opentelemetry-instrumentation-httpx==0.59b0
|
opentelemetry-instrumentation-httpx==0.60b0
|
||||||
opentelemetry-instrumentation-aiohttp-client==0.59b0
|
opentelemetry-instrumentation-aiohttp-client==0.60b0
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
services:
|
services:
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.56.0-noble # Version must match requirements.txt
|
image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt
|
||||||
container_name: playwright
|
container_name: playwright
|
||||||
command: npx -y playwright@1.56.0 run-server --port 3000 --host 0.0.0.0
|
command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0
|
||||||
|
|
||||||
open-webui:
|
open-webui:
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ authors = [
|
||||||
]
|
]
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi==0.123.0",
|
"fastapi==0.124.0",
|
||||||
"uvicorn[standard]==0.37.0",
|
"uvicorn[standard]==0.37.0",
|
||||||
"pydantic==2.12.5",
|
"pydantic==2.12.5",
|
||||||
"python-multipart==0.0.20",
|
"python-multipart==0.0.20",
|
||||||
|
|
@ -21,34 +21,35 @@ dependencies = [
|
||||||
"authlib==1.6.5",
|
"authlib==1.6.5",
|
||||||
|
|
||||||
"requests==2.32.5",
|
"requests==2.32.5",
|
||||||
"aiohttp==3.12.15",
|
"aiohttp==3.13.2",
|
||||||
"async-timeout",
|
"async-timeout",
|
||||||
"aiocache",
|
"aiocache",
|
||||||
"aiofiles",
|
"aiofiles",
|
||||||
"starlette-compress==1.6.1",
|
"starlette-compress==1.6.1",
|
||||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||||
"starsessions[redis]==2.2.1",
|
"starsessions[redis]==2.2.1",
|
||||||
|
"python-mimeparse==2.0.0",
|
||||||
|
|
||||||
"sqlalchemy==2.0.38",
|
"sqlalchemy==2.0.44",
|
||||||
"alembic==1.17.2",
|
"alembic==1.17.2",
|
||||||
"peewee==3.18.3",
|
"peewee==3.18.3",
|
||||||
"peewee-migrate==1.14.3",
|
"peewee-migrate==1.14.3",
|
||||||
|
|
||||||
"pycrdt==0.12.25",
|
"pycrdt==0.12.44",
|
||||||
"redis",
|
"redis",
|
||||||
|
|
||||||
"APScheduler==3.10.4",
|
"APScheduler==3.11.1",
|
||||||
"RestrictedPython==8.0",
|
"RestrictedPython==8.1",
|
||||||
|
|
||||||
"loguru==0.7.3",
|
"loguru==0.7.3",
|
||||||
"asgiref==3.11.0",
|
"asgiref==3.11.0",
|
||||||
|
|
||||||
"tiktoken",
|
"tiktoken",
|
||||||
"mcp==1.22.0",
|
"mcp==1.23.3",
|
||||||
|
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"google-genai==1.52.0",
|
"google-genai==1.54.0",
|
||||||
"google-generativeai==0.8.5",
|
"google-generativeai==0.8.5",
|
||||||
|
|
||||||
"langchain==0.3.27",
|
"langchain==0.3.27",
|
||||||
|
|
@ -56,62 +57,62 @@ dependencies = [
|
||||||
|
|
||||||
"fake-useragent==2.2.0",
|
"fake-useragent==2.2.0",
|
||||||
"chromadb==1.3.5",
|
"chromadb==1.3.5",
|
||||||
"opensearch-py==2.8.0",
|
"opensearch-py==3.1.0",
|
||||||
"PyMySQL==1.1.1",
|
"PyMySQL==1.1.2",
|
||||||
"boto3==1.41.5",
|
"boto3==1.42.5",
|
||||||
|
|
||||||
"transformers==4.57.3",
|
"transformers==4.57.3",
|
||||||
"sentence-transformers==5.1.2",
|
"sentence-transformers==5.1.2",
|
||||||
"accelerate",
|
"accelerate",
|
||||||
"pyarrow==20.0.0",
|
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||||
"einops==0.8.1",
|
"einops==0.8.1",
|
||||||
|
|
||||||
"ftfy==6.3.1",
|
"ftfy==6.3.1",
|
||||||
"chardet==5.2.0",
|
"chardet==5.2.0",
|
||||||
"pypdf==6.4.0",
|
"pypdf==6.4.1",
|
||||||
"fpdf2==2.8.2",
|
"fpdf2==2.8.5",
|
||||||
"pymdown-extensions==10.17.2",
|
"pymdown-extensions==10.18",
|
||||||
"docx2txt==0.8",
|
"docx2txt==0.9",
|
||||||
"python-pptx==1.0.2",
|
"python-pptx==1.0.2",
|
||||||
"unstructured==0.18.21",
|
"unstructured==0.18.21",
|
||||||
"msoffcrypto-tool==5.4.2",
|
"msoffcrypto-tool==5.4.2",
|
||||||
"nltk==3.9.1",
|
"nltk==3.9.2",
|
||||||
"Markdown==3.10",
|
"Markdown==3.10",
|
||||||
"pypandoc==1.16.2",
|
"pypandoc==1.16.2",
|
||||||
"pandas==2.2.3",
|
"pandas==2.3.3",
|
||||||
"openpyxl==3.1.5",
|
"openpyxl==3.1.5",
|
||||||
"pyxlsb==1.0.10",
|
"pyxlsb==1.0.10",
|
||||||
"xlrd==2.0.1",
|
"xlrd==2.0.2",
|
||||||
"validators==0.35.0",
|
"validators==0.35.0",
|
||||||
"psutil",
|
"psutil",
|
||||||
"sentencepiece",
|
"sentencepiece",
|
||||||
"soundfile==0.13.1",
|
"soundfile==0.13.1",
|
||||||
"azure-ai-documentintelligence==1.0.2",
|
"azure-ai-documentintelligence==1.0.2",
|
||||||
|
|
||||||
"pillow==11.3.0",
|
"pillow==12.0.0",
|
||||||
"opencv-python-headless==4.11.0.86",
|
"opencv-python-headless==4.12.0.88",
|
||||||
"rapidocr-onnxruntime==1.4.4",
|
"rapidocr-onnxruntime==1.4.4",
|
||||||
"rank-bm25==0.2.2",
|
"rank-bm25==0.2.2",
|
||||||
|
|
||||||
"onnxruntime==1.20.1",
|
"onnxruntime==1.23.2",
|
||||||
"faster-whisper==1.1.1",
|
"faster-whisper==1.2.1",
|
||||||
|
|
||||||
"black==25.11.0",
|
"black==25.12.0",
|
||||||
"youtube-transcript-api==1.2.2",
|
"youtube-transcript-api==1.2.3",
|
||||||
"pytube==15.0.0",
|
"pytube==15.0.0",
|
||||||
|
|
||||||
"pydub",
|
"pydub",
|
||||||
"ddgs==9.9.2",
|
"ddgs==9.9.3",
|
||||||
|
|
||||||
"google-api-python-client",
|
"google-api-python-client",
|
||||||
"google-auth-httplib2",
|
"google-auth-httplib2",
|
||||||
"google-auth-oauthlib",
|
"google-auth-oauthlib",
|
||||||
|
|
||||||
"googleapis-common-protos==1.72.0",
|
"googleapis-common-protos==1.72.0",
|
||||||
"google-cloud-storage==2.19.0",
|
"google-cloud-storage==3.7.0",
|
||||||
|
|
||||||
"azure-identity==1.25.0",
|
"azure-identity==1.25.1",
|
||||||
"azure-storage-blob==12.24.1",
|
"azure-storage-blob==12.27.1",
|
||||||
|
|
||||||
"ldap3==2.9.1",
|
"ldap3==2.9.1",
|
||||||
]
|
]
|
||||||
|
|
@ -130,8 +131,8 @@ classifiers = [
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg2-binary==2.9.10",
|
"psycopg2-binary==2.9.11",
|
||||||
"pgvector==0.4.1",
|
"pgvector==0.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
all = [
|
all = [
|
||||||
|
|
@ -143,17 +144,18 @@ all = [
|
||||||
"docker~=7.1.0",
|
"docker~=7.1.0",
|
||||||
"pytest~=8.3.2",
|
"pytest~=8.3.2",
|
||||||
"pytest-docker~=3.2.5",
|
"pytest-docker~=3.2.5",
|
||||||
"playwright==1.56.0",
|
"playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||||
"elasticsearch==9.1.0",
|
"elasticsearch==9.2.0",
|
||||||
|
|
||||||
"qdrant-client==1.16.1",
|
"qdrant-client==1.16.1",
|
||||||
"weaviate-client==4.17.0",
|
"pymilvus==2.6.4",
|
||||||
|
"weaviate-client==4.18.3",
|
||||||
"pymilvus==2.6.5",
|
"pymilvus==2.6.5",
|
||||||
"pinecone==6.0.2",
|
"pinecone==6.0.2",
|
||||||
"oracledb==3.2.0",
|
"oracledb==3.4.1",
|
||||||
"colbert-ai==0.2.21",
|
"colbert-ai==0.2.22",
|
||||||
|
|
||||||
"firecrawl-py==4.10.0",
|
"firecrawl-py==4.10.4",
|
||||||
"azure-search-documents==11.6.0",
|
"azure-search-documents==11.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -803,3 +803,7 @@ body {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#note-content-container .ProseMirror {
|
||||||
|
padding-bottom: 2rem; /* space for the bottom toolbar */
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,13 @@ export const createNewKnowledge = async (
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getKnowledgeBases = async (token: string = '') => {
|
export const getKnowledgeBases = async (token: string = '', page: number | null = null) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, {
|
const searchParams = new URLSearchParams();
|
||||||
|
if (page) searchParams.append('page', page.toString());
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/?${searchParams.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
|
@ -69,10 +72,20 @@ export const getKnowledgeBases = async (token: string = '') => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getKnowledgeBaseList = async (token: string = '') => {
|
export const searchKnowledgeBases = async (
|
||||||
|
token: string = '',
|
||||||
|
query: string | null = null,
|
||||||
|
viewOption: string | null = null,
|
||||||
|
page: number | null = null
|
||||||
|
) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, {
|
const searchParams = new URLSearchParams();
|
||||||
|
if (query) searchParams.append('query', query);
|
||||||
|
if (viewOption) searchParams.append('view_option', viewOption);
|
||||||
|
if (page) searchParams.append('page', page.toString());
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
|
@ -100,6 +113,55 @@ export const getKnowledgeBaseList = async (token: string = '') => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const searchKnowledgeFiles = async (
|
||||||
|
token: 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/search/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;
|
||||||
|
};
|
||||||
|
|
||||||
export const getKnowledgeById = async (token: string, id: string) => {
|
export const getKnowledgeById = async (token: string, id: string) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
@ -132,6 +194,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const searchKnowledgeFilesById = async (
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
query?: string | null = null,
|
||||||
|
viewOption?: string | null = null,
|
||||||
|
orderBy?: string | null = null,
|
||||||
|
direction?: string | null = null,
|
||||||
|
page: number = 1
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (query) searchParams.append('query', query);
|
||||||
|
if (viewOption) searchParams.append('view_option', viewOption);
|
||||||
|
if (orderBy) searchParams.append('order_by', orderBy);
|
||||||
|
if (direction) searchParams.append('direction', direction);
|
||||||
|
searchParams.append('page', page.toString());
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err.detail;
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
type KnowledgeUpdateForm = {
|
type KnowledgeUpdateForm = {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,65 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
|
||||||
return grouped;
|
return grouped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (query !== null) {
|
||||||
|
searchParams.append('query', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewOption !== null) {
|
||||||
|
searchParams.append('view_option', viewOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission !== null) {
|
||||||
|
searchParams.append('permission', permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortKey !== null) {
|
||||||
|
searchParams.append('order_by', sortKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page !== null) {
|
||||||
|
searchParams.append('page', `${page}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${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;
|
||||||
|
};
|
||||||
|
|
||||||
export const getNoteList = async (token: string = '', page: number | null = null) => {
|
export const getNoteList = async (token: string = '', page: number | null = null) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
@ -99,7 +158,7 @@ export const getNoteList = async (token: string = '', page: number | null = null
|
||||||
searchParams.append('page', `${page}`);
|
searchParams.append('page', `${page}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="">
|
<tbody class="">
|
||||||
{#each users as user, userIdx}
|
{#each users as user, userIdx (user.id)}
|
||||||
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
||||||
<td class="px-3 py-1 min-w-[7rem] w-28">
|
<td class="px-3 py-1 min-w-[7rem] w-28">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ''} w-full max-w-full flex flex-col"
|
: ''} w-full max-w-full flex flex-col"
|
||||||
id="channel-container"
|
id="channel-container"
|
||||||
>
|
>
|
||||||
|
|
@ -365,6 +365,7 @@
|
||||||
bind:chatInputElement
|
bind:chatInputElement
|
||||||
bind:replyToMessage
|
bind:replyToMessage
|
||||||
{typingUsers}
|
{typingUsers}
|
||||||
|
{channel}
|
||||||
userSuggestions={true}
|
userSuggestions={true}
|
||||||
channelSuggestions={true}
|
channelSuggestions={true}
|
||||||
disabled={!channel?.write_access}
|
disabled={!channel?.write_access}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
<div class="">
|
<div class="">
|
||||||
<button
|
<button
|
||||||
type="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}
|
on:click={onAdd}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5 " />
|
<Plus className="size-3.5 " />
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,10 @@
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
|
|
||||||
export let placeholder = $i18n.t('Type here...');
|
export let placeholder = $i18n.t('Type here...');
|
||||||
|
export let chatInputElement;
|
||||||
|
|
||||||
export let id = null;
|
export let id = null;
|
||||||
export let chatInputElement;
|
export let channel = null;
|
||||||
|
|
||||||
export let typingUsers = [];
|
export let typingUsers = [];
|
||||||
export let inputLoading = false;
|
export let inputLoading = false;
|
||||||
|
|
@ -459,15 +460,16 @@
|
||||||
try {
|
try {
|
||||||
// During the file upload, file content is automatically extracted.
|
// During the file upload, file content is automatically extracted.
|
||||||
// If the file is an audio file, provide the language for STT.
|
// If the file is an audio file, provide the language for STT.
|
||||||
let metadata = null;
|
let metadata = {
|
||||||
if (
|
channel_id: channel.id,
|
||||||
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
// If the file is an audio file, provide the language for STT.
|
||||||
|
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
||||||
$settings?.audio?.stt?.language
|
$settings?.audio?.stt?.language
|
||||||
) {
|
? {
|
||||||
metadata = {
|
|
||||||
language: $settings?.audio?.stt?.language
|
language: $settings?.audio?.stt?.language
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
const uploadedFile = await uploadFile(localStorage.token, file, metadata, process);
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata, process);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2384,7 +2384,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? ' md:max-w-[calc(100%-260px)]'
|
? ' md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ' '} w-full max-w-full flex flex-col"
|
: ' '} w-full max-w-full flex flex-col"
|
||||||
id="chat-container"
|
id="chat-container"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,37 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { embed, showControls, showEmbeds } from '$lib/stores';
|
import { embed, showControls, showEmbeds } from '$lib/stores';
|
||||||
|
|
||||||
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
|
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
|
||||||
import XMark from '$lib/components/icons/XMark.svelte';
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let overlay = false;
|
export let overlay = false;
|
||||||
|
|
||||||
|
const getSrcUrl = (url: string, chatId?: string, messageId?: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
parsed.searchParams.set('chat_id', chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
parsed.searchParams.set('message_id', messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
// Fallback for relative URLs or invalid input
|
||||||
|
const hasQuery = url.includes('?');
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (chatId) parts.push(`chat_id=${encodeURIComponent(chatId)}`);
|
||||||
|
if (messageId) parts.push(`message_id=${encodeURIComponent(messageId)}`);
|
||||||
|
|
||||||
|
if (parts.length === 0) return url;
|
||||||
|
|
||||||
|
return url + (hasQuery ? '&' : '?') + parts.join('&');
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $embed}
|
{#if $embed}
|
||||||
|
|
@ -40,7 +67,11 @@
|
||||||
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" />
|
<FullHeightIframe
|
||||||
|
src={getSrcUrl($embed?.url ?? '', $embed?.chatId, $embed?.messageId)}
|
||||||
|
payload={$embed?.source ?? null}
|
||||||
|
iframeClassName="w-full h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { marked } from 'marked';
|
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
import { marked } from 'marked';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
|
import dayjs from '$lib/dayjs';
|
||||||
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
|
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 { 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();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -49,6 +57,9 @@
|
||||||
|
|
||||||
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
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 InputMenu from './MessageInput/InputMenu.svelte';
|
||||||
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
|
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
|
||||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||||
|
|
@ -60,11 +71,9 @@
|
||||||
import Image from '../common/Image.svelte';
|
import Image from '../common/Image.svelte';
|
||||||
|
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
import Headphone from '../icons/Headphone.svelte';
|
|
||||||
import GlobeAlt from '../icons/GlobeAlt.svelte';
|
import GlobeAlt from '../icons/GlobeAlt.svelte';
|
||||||
import Photo from '../icons/Photo.svelte';
|
import Photo from '../icons/Photo.svelte';
|
||||||
import Wrench from '../icons/Wrench.svelte';
|
import Wrench from '../icons/Wrench.svelte';
|
||||||
import CommandLine from '../icons/CommandLine.svelte';
|
|
||||||
import Sparkles from '../icons/Sparkles.svelte';
|
import Sparkles from '../icons/Sparkles.svelte';
|
||||||
|
|
||||||
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
||||||
|
|
@ -74,12 +83,13 @@
|
||||||
import Component from '../icons/Component.svelte';
|
import Component from '../icons/Component.svelte';
|
||||||
import PlusAlt from '../icons/PlusAlt.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 CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
|
||||||
import Knobs from '../icons/Knobs.svelte';
|
import Knobs from '../icons/Knobs.svelte';
|
||||||
import ValvesModal from '../workspace/common/ValvesModal.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');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -109,6 +119,8 @@
|
||||||
export let webSearchEnabled = false;
|
export let webSearchEnabled = false;
|
||||||
export let codeInterpreterEnabled = false;
|
export let codeInterpreterEnabled = false;
|
||||||
|
|
||||||
|
let inputContent = null;
|
||||||
|
|
||||||
let showInputVariablesModal = false;
|
let showInputVariablesModal = false;
|
||||||
let inputVariablesModalCallback = (variableValues) => {};
|
let inputVariablesModalCallback = (variableValues) => {};
|
||||||
let inputVariables = {};
|
let inputVariables = {};
|
||||||
|
|
@ -410,6 +422,8 @@
|
||||||
|
|
||||||
let inputFiles;
|
let inputFiles;
|
||||||
|
|
||||||
|
let showInputModal = false;
|
||||||
|
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
let shiftKey = 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) => {
|
const onDragOver = (e) => {
|
||||||
e.preventDefault();
|
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}
|
{#if loaded}
|
||||||
<div class="w-full font-primary">
|
<div class="w-full font-primary">
|
||||||
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
|
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
|
||||||
|
|
@ -1189,14 +1236,33 @@
|
||||||
: ''}"
|
: ''}"
|
||||||
id="chat-input-container"
|
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}
|
{#if suggestions}
|
||||||
{#key $settings?.richTextInput ?? true}
|
{#key $settings?.richTextInput ?? true}
|
||||||
{#key $settings?.showFormattingToolbar ?? false}
|
{#key $settings?.showFormattingToolbar ?? false}
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:this={chatInputElement}
|
bind:this={chatInputElement}
|
||||||
id="chat-input"
|
id="chat-input"
|
||||||
onChange={(e) => {
|
editable={!showInputModal}
|
||||||
prompt = e.md;
|
onChange={(content) => {
|
||||||
|
prompt = content.md;
|
||||||
|
inputContent = content;
|
||||||
command = getCommand();
|
command = getCommand();
|
||||||
}}
|
}}
|
||||||
json={true}
|
json={true}
|
||||||
|
|
@ -1620,13 +1686,54 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="self-end flex space-x-1 mr-1 shrink-0">
|
<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')}>
|
||||||
|
<button
|
||||||
|
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
||||||
|
on:click={() => {
|
||||||
|
stopResponse();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{: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
|
||||||
|
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))}
|
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
||||||
<!-- {$i18n.t('Record voice')} -->
|
<!-- {$i18n.t('Record voice')} -->
|
||||||
<Tooltip content={$i18n.t('Dictate')}>
|
<Tooltip content={$i18n.t('Dictate')}>
|
||||||
<button
|
<button
|
||||||
id="voice-input-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"
|
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"
|
type="button"
|
||||||
on:click={async () => {
|
on:click={async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1660,7 +1767,7 @@
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="w-5 h-5 translate-y-[0.5px]"
|
class="size-5 translate-y-[0.5px]"
|
||||||
>
|
>
|
||||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||||
<path
|
<path
|
||||||
|
|
@ -1671,31 +1778,7 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
||||||
<div class=" flex items-center">
|
|
||||||
<Tooltip content={$i18n.t('Stop')}>
|
|
||||||
<button
|
|
||||||
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
|
||||||
on:click={() => {
|
|
||||||
stopResponse();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
|
||||||
<div class=" flex items-center">
|
<div class=" flex items-center">
|
||||||
<!-- {$i18n.t('Call')} -->
|
<!-- {$i18n.t('Call')} -->
|
||||||
<Tooltip content={$i18n.t('Voice mode')}>
|
<Tooltip content={$i18n.t('Voice mode')}>
|
||||||
|
|
@ -1785,6 +1868,7 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,6 @@
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
prompts.set(await getPrompts(localStorage.token));
|
prompts.set(await getPrompts(localStorage.token));
|
||||||
})(),
|
|
||||||
(async () => {
|
|
||||||
knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
})()
|
})()
|
||||||
]);
|
]);
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
@ -103,7 +100,6 @@
|
||||||
bind:this={suggestionElement}
|
bind:this={suggestionElement}
|
||||||
{query}
|
{query}
|
||||||
bind:filteredItems
|
bind:filteredItems
|
||||||
knowledge={$knowledge ?? []}
|
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
const { type, data } = e;
|
const { type, data } = e;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||||
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
|
|
||||||
|
import { folders } from '$lib/stores';
|
||||||
|
import { getFolders } from '$lib/apis/folders';
|
||||||
|
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
|
||||||
|
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl, decodeString } from '$lib/utils';
|
||||||
|
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||||
import Database from '$lib/components/icons/Database.svelte';
|
import Database from '$lib/components/icons/Database.svelte';
|
||||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||||
import Youtube from '$lib/components/icons/Youtube.svelte';
|
import Youtube from '$lib/components/icons/Youtube.svelte';
|
||||||
import { folders } from '$lib/stores';
|
|
||||||
import Folder from '$lib/components/icons/Folder.svelte';
|
import Folder from '$lib/components/icons/Folder.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -21,22 +23,11 @@
|
||||||
export let query = '';
|
export let query = '';
|
||||||
export let onSelect = (e) => {};
|
export let onSelect = (e) => {};
|
||||||
|
|
||||||
export let knowledge = [];
|
|
||||||
|
|
||||||
let selectedIdx = 0;
|
let selectedIdx = 0;
|
||||||
|
|
||||||
let items = [];
|
let items = [];
|
||||||
let fuse = null;
|
|
||||||
|
|
||||||
export let filteredItems = [];
|
export let filteredItems = [];
|
||||||
$: if (fuse) {
|
$: filteredItems = [
|
||||||
filteredItems = [
|
|
||||||
...(query
|
|
||||||
? fuse.search(query).map((e) => {
|
|
||||||
return e.item;
|
|
||||||
})
|
|
||||||
: items),
|
|
||||||
|
|
||||||
...(query.startsWith('http')
|
...(query.startsWith('http')
|
||||||
? isYoutubeUrl(query)
|
? isYoutubeUrl(query)
|
||||||
? [{ type: 'youtube', name: query, description: query }]
|
? [{ type: 'youtube', name: query, description: query }]
|
||||||
|
|
@ -47,9 +38,9 @@
|
||||||
description: query
|
description: query
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: []),
|
||||||
|
...items
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
$: if (query) {
|
$: if (query) {
|
||||||
selectedIdx = 0;
|
selectedIdx = 0;
|
||||||
|
|
@ -71,106 +62,71 @@
|
||||||
item.click();
|
item.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const decodeString = (str: string) => {
|
|
||||||
try {
|
let folderItems = [];
|
||||||
return decodeURIComponent(str);
|
let knowledgeItems = [];
|
||||||
} catch (e) {
|
let fileItems = [];
|
||||||
return str;
|
|
||||||
|
$: items = [...folderItems, ...knowledgeItems, ...fileItems];
|
||||||
|
|
||||||
|
$: if (query !== null) {
|
||||||
|
getItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getItems = () => {
|
||||||
|
getFolderItems();
|
||||||
|
getKnowledgeItems();
|
||||||
|
getKnowledgeFileItems();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
const getFolderItems = async () => {
|
||||||
let legacy_documents = knowledge
|
folderItems = $folders
|
||||||
.filter((item) => item?.meta?.document)
|
.map((folder) => ({
|
||||||
.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) => ({
|
|
||||||
...item,
|
|
||||||
type: 'collection'
|
|
||||||
}));
|
|
||||||
|
|
||||||
let collection_files =
|
|
||||||
knowledge.length > 0
|
|
||||||
? [
|
|
||||||
...knowledge
|
|
||||||
.reduce((a, item) => {
|
|
||||||
return [
|
|
||||||
...new Set([
|
|
||||||
...a,
|
|
||||||
...(item?.files ?? []).map((file) => ({
|
|
||||||
...file,
|
|
||||||
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
|
|
||||||
}))
|
|
||||||
])
|
|
||||||
];
|
|
||||||
}, [])
|
|
||||||
.map((file) => ({
|
|
||||||
...file,
|
|
||||||
name: file?.meta?.name,
|
|
||||||
description: `${file?.collection?.description}`,
|
|
||||||
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
|
|
||||||
type: 'file'
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
let folder_items = $folders.map((folder) => ({
|
|
||||||
...folder,
|
...folder,
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
description: $i18n.t('Folder'),
|
description: $i18n.t('Folder'),
|
||||||
title: folder.name
|
title: folder.name
|
||||||
}));
|
}))
|
||||||
|
.filter((folder) => folder.name.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
items = [
|
const getKnowledgeItems = async () => {
|
||||||
...folder_items,
|
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
|
||||||
...collections,
|
return null;
|
||||||
...collection_files,
|
});
|
||||||
...legacy_collections,
|
|
||||||
...legacy_documents
|
if (res) {
|
||||||
].map((item) => {
|
knowledgeItems = res.items.map((item) => {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
type: 'collection'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
fuse = new Fuse(items, {
|
const getKnowledgeFileItems = async () => {
|
||||||
keys: ['name', 'description']
|
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
fileItems = res.items.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'file',
|
||||||
|
name: item.filename,
|
||||||
|
description: item.collection ? item.collection.name : ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if ($folders === null) {
|
||||||
|
await folders.set(await getFolders(localStorage.token));
|
||||||
|
}
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -189,12 +145,20 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="px-2 text-xs text-gray-500 py-1">
|
|
||||||
{$i18n.t('Knowledge')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredItems.length > 0 || query.startsWith('http')}
|
{#if filteredItems.length > 0 || query.startsWith('http')}
|
||||||
{#each filteredItems as item, idx}
|
{#each filteredItems as item, idx}
|
||||||
|
{#if idx === 0 || item?.type !== items[idx - 1]?.type}
|
||||||
|
<div class="px-2 text-xs text-gray-500 py-1">
|
||||||
|
{#if item?.type === 'folder'}
|
||||||
|
{$i18n.t('Folders')}
|
||||||
|
{:else if item?.type === 'collection'}
|
||||||
|
{$i18n.t('Collections')}
|
||||||
|
{:else if item?.type === 'file'}
|
||||||
|
{$i18n.t('Files')}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !['youtube', 'web'].includes(item.type)}
|
{#if !['youtube', 'web'].includes(item.type)}
|
||||||
<button
|
<button
|
||||||
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===
|
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<div
|
<div
|
||||||
bind:this={overlayElement}
|
bind:this={overlayElement}
|
||||||
class="fixed {$showSidebar
|
class="fixed {$showSidebar
|
||||||
? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
|
? 'left-0 md:left-[var(--sidebar-width)] md:w-[calc(100%-var(--sidebar-width))]'
|
||||||
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none"
|
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none"
|
||||||
id="dropzone"
|
id="dropzone"
|
||||||
role="region"
|
role="region"
|
||||||
|
|
|
||||||
|
|
@ -73,16 +73,6 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
if ($knowledge === null) {
|
|
||||||
await knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$: if (show) {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSelect = (item) => {
|
const onSelect = (item) => {
|
||||||
if (files.find((f) => f.id === item.id)) {
|
if (files.find((f) => f.id === item.id)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -249,7 +239,6 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ($knowledge ?? []).length > 0}
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={fileUploadCapableModels.length !== selectedModels.length
|
content={fileUploadCapableModels.length !== selectedModels.length
|
||||||
? $i18n.t('Model(s) do not support file upload')
|
? $i18n.t('Model(s) do not support file upload')
|
||||||
|
|
@ -279,7 +268,6 @@
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if ($chats ?? []).length > 0}
|
{#if ($chats ?? []).length > 0}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
|
||||||
|
|
@ -4,120 +4,177 @@
|
||||||
import { decodeString } from '$lib/utils';
|
import { decodeString } from '$lib/utils';
|
||||||
import { knowledge } from '$lib/stores';
|
import { knowledge } from '$lib/stores';
|
||||||
|
|
||||||
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
import { getKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
|
||||||
|
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Database from '$lib/components/icons/Database.svelte';
|
import Database from '$lib/components/icons/Database.svelte';
|
||||||
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import Loader from '$lib/components/common/Loader.svelte';
|
||||||
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let onSelect = (e) => {};
|
export let onSelect = (e) => {};
|
||||||
|
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
let items = [];
|
|
||||||
let selectedIdx = 0;
|
let selectedIdx = 0;
|
||||||
|
|
||||||
onMount(async () => {
|
let selectedItem = null;
|
||||||
if ($knowledge === null) {
|
|
||||||
await knowledge.set(await getKnowledgeBases(localStorage.token));
|
let selectedFileItemsPage = 1;
|
||||||
|
|
||||||
|
let selectedFileItems = null;
|
||||||
|
let selectedFileItemsTotal = null;
|
||||||
|
|
||||||
|
let selectedFileItemsLoading = false;
|
||||||
|
let selectedFileAllItemsLoaded = false;
|
||||||
|
|
||||||
|
$: if (selectedItem) {
|
||||||
|
initSelectedFileItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
let legacy_documents = $knowledge
|
const initSelectedFileItems = async () => {
|
||||||
.filter((item) => item?.meta?.document)
|
selectedFileItemsPage = 1;
|
||||||
.map((item) => ({
|
selectedFileItems = null;
|
||||||
...item,
|
selectedFileItemsTotal = null;
|
||||||
type: 'file'
|
selectedFileAllItemsLoaded = false;
|
||||||
}));
|
selectedFileItemsLoading = false;
|
||||||
|
|
||||||
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) => ({
|
|
||||||
...item,
|
|
||||||
type: 'collection'
|
|
||||||
}));
|
|
||||||
``;
|
|
||||||
let collection_files =
|
|
||||||
$knowledge.length > 0
|
|
||||||
? [
|
|
||||||
...$knowledge
|
|
||||||
.reduce((a, item) => {
|
|
||||||
return [
|
|
||||||
...new Set([
|
|
||||||
...a,
|
|
||||||
...(item?.files ?? []).map((file) => ({
|
|
||||||
...file,
|
|
||||||
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
|
|
||||||
}))
|
|
||||||
])
|
|
||||||
];
|
|
||||||
}, [])
|
|
||||||
.map((file) => ({
|
|
||||||
...file,
|
|
||||||
name: file?.meta?.name,
|
|
||||||
description: `${file?.collection?.name} - ${file?.collection?.description}`,
|
|
||||||
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
|
|
||||||
type: 'file'
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
|
|
||||||
(item) => {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
await getSelectedFileItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreSelectedFileItems = async () => {
|
||||||
|
if (selectedFileAllItemsLoaded) return;
|
||||||
|
selectedFileItemsPage += 1;
|
||||||
|
await getSelectedFileItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedFileItemsPage = async () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
selectedFileItemsLoading = true;
|
||||||
|
|
||||||
|
const res = await searchKnowledgeFilesById(
|
||||||
|
localStorage.token,
|
||||||
|
selectedItem.id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
selectedFileItemsPage
|
||||||
|
).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
selectedFileItemsTotal = res.total;
|
||||||
|
const pageItems = res.items;
|
||||||
|
|
||||||
|
if ((pageItems ?? []).length === 0) {
|
||||||
|
selectedFileAllItemsLoaded = true;
|
||||||
|
} else {
|
||||||
|
selectedFileAllItemsLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFileItems) {
|
||||||
|
selectedFileItems = [...selectedFileItems, ...pageItems];
|
||||||
|
} else {
|
||||||
|
selectedFileItems = pageItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFileItemsLoading = false;
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
let items = null;
|
||||||
|
let total = null;
|
||||||
|
|
||||||
|
let itemsLoading = false;
|
||||||
|
let allItemsLoaded = false;
|
||||||
|
|
||||||
|
$: if (loaded) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
|
await tick();
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
page = 1;
|
||||||
|
items = null;
|
||||||
|
total = null;
|
||||||
|
allItemsLoaded = false;
|
||||||
|
itemsLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreItems = async () => {
|
||||||
|
if (allItemsLoaded) return;
|
||||||
|
page += 1;
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemsPage = async () => {
|
||||||
|
itemsLoading = true;
|
||||||
|
const res = await getKnowledgeBases(localStorage.token, page).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
console.log(res);
|
||||||
|
total = res.total;
|
||||||
|
const pageItems = res.items;
|
||||||
|
|
||||||
|
if ((pageItems ?? []).length === 0) {
|
||||||
|
allItemsLoaded = true;
|
||||||
|
} else {
|
||||||
|
allItemsLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
items = [...items, ...pageItems];
|
||||||
|
} else {
|
||||||
|
items = pageItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsLoading = false;
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await tick();
|
||||||
loaded = true;
|
loaded = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loaded}
|
{#if loaded && items !== null}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{#each items as item, idx}
|
{#if items.length === 0}
|
||||||
<button
|
<div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{$i18n.t('No knowledge bases found.')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each items as item, idx (item.id)}
|
||||||
|
<div
|
||||||
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
|
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
|
||||||
selectedIdx
|
selectedIdx
|
||||||
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
|
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
|
||||||
: ''}"
|
: ''}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex-1"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
console.log(item);
|
onSelect({
|
||||||
onSelect(item);
|
type: 'collection',
|
||||||
|
...item
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
on:mousemove={() => {
|
on:mousemove={() => {
|
||||||
selectedIdx = idx;
|
selectedIdx = idx;
|
||||||
|
|
@ -129,32 +186,114 @@
|
||||||
}}
|
}}
|
||||||
data-selected={idx === selectedIdx}
|
data-selected={idx === selectedIdx}
|
||||||
>
|
>
|
||||||
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
|
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
|
||||||
<Tooltip
|
<Tooltip content={$i18n.t('Collection')} placement="top">
|
||||||
content={item?.legacy
|
|
||||||
? $i18n.t('Legacy')
|
|
||||||
: item?.type === 'file'
|
|
||||||
? $i18n.t('File')
|
|
||||||
: item?.type === 'collection'
|
|
||||||
? $i18n.t('Collection')
|
|
||||||
: ''}
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
{#if item?.type === 'collection'}
|
|
||||||
<Database className="size-4" />
|
<Database className="size-4" />
|
||||||
{:else}
|
|
||||||
<DocumentPage className="size-4" />
|
|
||||||
{/if}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
|
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
|
||||||
<div class="line-clamp-1 flex-1">
|
<div class="line-clamp-1 flex-1 text-sm">
|
||||||
{decodeString(item?.name)}
|
{decodeString(item?.name)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Tooltip content={$i18n.t('Show Files')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=" ml-2 opacity-50 hover:opacity-100 transition"
|
||||||
|
on:click={() => {
|
||||||
|
if (selectedItem && selectedItem.id === item.id) {
|
||||||
|
selectedItem = null;
|
||||||
|
} else {
|
||||||
|
selectedItem = item;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if selectedItem && selectedItem.id === item.id}
|
||||||
|
<ChevronDown className="size-3" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight className="size-3" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedItem && selectedItem.id === item.id}
|
||||||
|
<div class="pl-3 mb-1 flex flex-col gap-0.5">
|
||||||
|
{#if selectedFileItems === null && selectedFileItemsTotal === null}
|
||||||
|
<div class=" py-1 flex justify-center">
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
</div>
|
||||||
|
{:else if selectedFileItemsTotal === 0}
|
||||||
|
<div class=" text-xs text-gray-500 dark:text-gray-400 italic py-0.5 px-2">
|
||||||
|
{$i18n.t('No files in this knowledge base.')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each selectedFileItems as file, fileIdx (file.id)}
|
||||||
|
<button
|
||||||
|
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100"
|
||||||
|
type="button"
|
||||||
|
on:click={() => {
|
||||||
|
console.log(file);
|
||||||
|
onSelect({
|
||||||
|
type: 'file',
|
||||||
|
name: file?.meta?.name,
|
||||||
|
...file
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class=" flex items-center gap-1.5">
|
||||||
|
<Tooltip content={$i18n.t('Collection')} placement="top">
|
||||||
|
<DocumentPage className="size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
|
||||||
|
<div class="line-clamp-1 flex-1 text-sm">
|
||||||
|
{decodeString(file?.meta?.name)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
|
||||||
|
<Loader
|
||||||
|
on:visible={async (e) => {
|
||||||
|
if (!selectedFileItemsLoading) {
|
||||||
|
await loadMoreSelectedFileItems();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
|
||||||
|
>
|
||||||
|
<Spinner className=" size-3" />
|
||||||
|
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</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">
|
||||||
|
<Spinner className=" size-4" />
|
||||||
|
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="py-4.5">
|
<div class="py-4.5">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import CitationModal from './Citations/CitationModal.svelte';
|
|
||||||
import { embed, showControls, showEmbeds } from '$lib/stores';
|
import { embed, showControls, showEmbeds } from '$lib/stores';
|
||||||
|
|
||||||
|
import CitationModal from './Citations/CitationModal.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let id = '';
|
export let id = '';
|
||||||
|
export let chatId = '';
|
||||||
|
|
||||||
export let sources = [];
|
export let sources = [];
|
||||||
export let readOnly = false;
|
export let readOnly = false;
|
||||||
|
|
||||||
|
|
@ -35,8 +38,11 @@
|
||||||
showControls.set(true);
|
showControls.set(true);
|
||||||
showEmbeds.set(true);
|
showEmbeds.set(true);
|
||||||
embed.set({
|
embed.set({
|
||||||
|
url: embedUrl,
|
||||||
title: citations[sourceIdx]?.source?.name || 'Embedded Content',
|
title: citations[sourceIdx]?.source?.name || 'Embedded Content',
|
||||||
url: embedUrl
|
source: citations[sourceIdx],
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{sourceIds}
|
|
||||||
|
|
||||||
{#if sourceIds}
|
{#if sourceIds}
|
||||||
{#if (token?.ids ?? []).length == 1}
|
{#if (token?.ids ?? []).length == 1}
|
||||||
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
|
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
|
||||||
|
|
|
||||||
|
|
@ -824,6 +824,7 @@
|
||||||
<Citations
|
<Citations
|
||||||
bind:this={citationsElement}
|
bind:this={citationsElement}
|
||||||
id={message?.id}
|
id={message?.id}
|
||||||
|
{chatId}
|
||||||
sources={message?.sources ?? message?.citations}
|
sources={message?.sources ?? message?.citations}
|
||||||
{readOnly}
|
{readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
62
src/lib/components/common/DropdownOptions.svelte
Normal file
62
src/lib/components/common/DropdownOptions.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { Select, DropdownMenu } from 'bits-ui';
|
||||||
|
|
||||||
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
export let align = 'center';
|
||||||
|
export let className = '';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let placeholder = 'Select an option';
|
||||||
|
export let items = [
|
||||||
|
{ value: 'new', label: $i18n.t('New') },
|
||||||
|
{ value: 'top', label: $i18n.t('Top') }
|
||||||
|
];
|
||||||
|
|
||||||
|
export let onChange: (value: string) => void = () => {};
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu.Root bind:open>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<div
|
||||||
|
class={className
|
||||||
|
? className
|
||||||
|
: 'flex w-full items-center gap-2 truncate bg-transparent px-0.5 text-sm placeholder-gray-400 outline-hidden focus:outline-hidden'}
|
||||||
|
>
|
||||||
|
{items.find((item) => item.value === value)?.label ?? placeholder}
|
||||||
|
<ChevronDown className=" size-3" strokeWidth="2.5" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Content {align}>
|
||||||
|
<div
|
||||||
|
class="dark:bg-gray-850 z-50 w-full rounded-2xl border border-gray-100 bg-white p-1 shadow-lg dark:border-gray-800 dark:text-white"
|
||||||
|
>
|
||||||
|
{#each items as item}
|
||||||
|
<button
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 {value ===
|
||||||
|
item.value
|
||||||
|
? ' '
|
||||||
|
: ' text-gray-500 dark:text-gray-400'}"
|
||||||
|
type="button"
|
||||||
|
on:click={() => {
|
||||||
|
if (value === item.value) {
|
||||||
|
value = null;
|
||||||
|
} else {
|
||||||
|
value = item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
'strict-origin-when-cross-origin';
|
'strict-origin-when-cross-origin';
|
||||||
export let allowFullscreen = true;
|
export let allowFullscreen = true;
|
||||||
|
|
||||||
|
export let payload = null; // payload to send into the iframe on request
|
||||||
|
|
||||||
let iframe: HTMLIFrameElement | null = null;
|
let iframe: HTMLIFrameElement | null = null;
|
||||||
let iframeSrc: string | null = null;
|
let iframeSrc: string | null = null;
|
||||||
let iframeDoc: string | null = null;
|
let iframeDoc: string | null = null;
|
||||||
|
|
@ -142,13 +144,29 @@ window.Chart = parent.Chart; // Chart previously assigned on parent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle height messages from the iframe (we also verify the sender)
|
|
||||||
function onMessage(e: MessageEvent) {
|
function onMessage(e: MessageEvent) {
|
||||||
if (!iframe || e.source !== iframe.contentWindow) return;
|
if (!iframe || e.source !== iframe.contentWindow) return;
|
||||||
const data = e.data as { type?: string; height?: number };
|
|
||||||
|
const data = e.data || {};
|
||||||
if (data?.type === 'iframe:height' && typeof data.height === 'number') {
|
if (data?.type === 'iframe:height' && typeof data.height === 'number') {
|
||||||
iframe.style.height = Math.max(0, data.height) + 'px';
|
iframe.style.height = Math.max(0, data.height) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pong message for testing connectivity
|
||||||
|
if (data?.type === 'pong') {
|
||||||
|
console.log('Received pong from iframe:', data);
|
||||||
|
|
||||||
|
// Optional: reply back
|
||||||
|
iframe.contentWindow?.postMessage({ type: 'pong:ack' }, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send payload data if requested
|
||||||
|
if (data?.type === 'payload') {
|
||||||
|
iframe.contentWindow?.postMessage(
|
||||||
|
{ type: 'payload', requestId: data?.requestId ?? null, payload: payload },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the iframe loads, try same-origin resize (cross-origin will noop)
|
// When the iframe loads, try same-origin resize (cross-origin will noop)
|
||||||
|
|
|
||||||
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 documentId = '';
|
||||||
|
|
||||||
export let className = 'input-prose';
|
export let className = 'input-prose min-h-fit h-full';
|
||||||
export let placeholder = $i18n.t('Type here...');
|
export let placeholder = $i18n.t('Type here...');
|
||||||
let _placeholder = placeholder;
|
let _placeholder = placeholder;
|
||||||
|
|
||||||
|
|
@ -1156,7 +1156,5 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="relative w-full min-w-full h-full min-h-fit {className} {!editable
|
class="relative w-full min-w-full {className} {!editable ? 'cursor-not-allowed' : ''}"
|
||||||
? '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
|
||||||
|
>
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
let filter = {};
|
let filter = {};
|
||||||
$: filter = {
|
$: filter = {
|
||||||
...(query ? { query } : {}),
|
...(query ? { query: query } : {}),
|
||||||
...(orderBy ? { order_by: orderBy } : {}),
|
...(orderBy ? { order_by: orderBy } : {}),
|
||||||
...(direction ? { direction } : {})
|
...(direction ? { direction } : {})
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder={$i18n.t('Search Chats')}
|
placeholder={$i18n.t('Search Chats')}
|
||||||
|
maxlength="500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if query}
|
{#if query}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
isApp,
|
isApp,
|
||||||
models,
|
models,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
WEBUI_NAME
|
WEBUI_NAME,
|
||||||
|
sidebarWidth
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
|
@ -371,8 +372,55 @@
|
||||||
selectedChatId = null;
|
selectedChatId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_WIDTH = 220;
|
||||||
|
const MAX_WIDTH = 480;
|
||||||
|
|
||||||
|
let isResizing = false;
|
||||||
|
|
||||||
|
let startWidth = 0;
|
||||||
|
let startClientX = 0;
|
||||||
|
|
||||||
|
const resizeStartHandler = (e: MouseEvent) => {
|
||||||
|
if ($mobile) return;
|
||||||
|
isResizing = true;
|
||||||
|
|
||||||
|
startClientX = e.clientX;
|
||||||
|
startWidth = $sidebarWidth ?? 260;
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeEndHandler = () => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
isResizing = false;
|
||||||
|
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
localStorage.setItem('sidebarWidth', String($sidebarWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeSidebarHandler = (endClientX) => {
|
||||||
|
const dx = endClientX - startClientX;
|
||||||
|
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
|
||||||
|
|
||||||
|
sidebarWidth.set(newSidebarWidth);
|
||||||
|
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
|
||||||
|
};
|
||||||
|
|
||||||
let unsubscribers = [];
|
let unsubscribers = [];
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const width = Number(localStorage.getItem('sidebarWidth'));
|
||||||
|
if (!Number.isNaN(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
||||||
|
sidebarWidth.set(width);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--sidebar-width', `${$sidebarWidth}px`);
|
||||||
|
sidebarWidth.subscribe((w) => {
|
||||||
|
document.documentElement.style.setProperty('--sidebar-width', `${w}px`);
|
||||||
|
});
|
||||||
|
|
||||||
await showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
|
await showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
|
||||||
|
|
||||||
unsubscribers = [
|
unsubscribers = [
|
||||||
|
|
@ -570,6 +618,16 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:mousemove={(e) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
resizeSidebarHandler(e.clientX);
|
||||||
|
}}
|
||||||
|
on:mouseup={() => {
|
||||||
|
resizeEndHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if !$mobile && !$showSidebar}
|
{#if !$mobile && !$showSidebar}
|
||||||
<div
|
<div
|
||||||
class=" pt-[7px] pb-2 px-2 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30"
|
class=" pt-[7px] pb-2 px-2 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30"
|
||||||
|
|
@ -775,7 +833,7 @@
|
||||||
data-state={$showSidebar}
|
data-state={$showSidebar}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
|
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[var(--sidebar-width)] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
|
||||||
? ''
|
? ''
|
||||||
: 'invisible'}"
|
: 'invisible'}"
|
||||||
>
|
>
|
||||||
|
|
@ -1321,4 +1379,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !$mobile}
|
||||||
|
<div
|
||||||
|
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||||
|
id="sidebar-resizer"
|
||||||
|
on:mousedown={resizeStartHandler}
|
||||||
|
role="separator"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@
|
||||||
class="w-full rounded-r-xl py-1.5 pl-2.5 text-sm bg-transparent dark:text-gray-300 outline-hidden"
|
class="w-full rounded-r-xl py-1.5 pl-2.5 text-sm bg-transparent dark:text-gray-300 outline-hidden"
|
||||||
placeholder={placeholder ? placeholder : $i18n.t('Search')}
|
placeholder={placeholder ? placeholder : $i18n.t('Search')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
maxlength="500"
|
||||||
bind:value
|
bind:value
|
||||||
on:input={() => {
|
on:input={() => {
|
||||||
dispatch('input');
|
dispatch('input');
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,16 @@
|
||||||
if (res) {
|
if (res) {
|
||||||
note = res;
|
note = res;
|
||||||
files = res.data.files || [];
|
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 {
|
} else {
|
||||||
goto('/');
|
goto('/');
|
||||||
return;
|
return;
|
||||||
|
|
@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await tick();
|
await tick();
|
||||||
$socket?.emit('join-note', {
|
|
||||||
note_id: id,
|
|
||||||
auth: {
|
|
||||||
token: localStorage.token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$socket?.on('note-events', noteEventHandler);
|
|
||||||
|
|
||||||
if ($settings?.models) {
|
if ($settings?.models) {
|
||||||
selectedModelId = $settings?.models[0];
|
selectedModelId = $settings?.models[0];
|
||||||
|
|
@ -956,6 +959,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center gap-0.5 translate-x-1">
|
<div class="flex items-center gap-0.5 translate-x-1">
|
||||||
|
{#if note?.write_access}
|
||||||
{#if editor}
|
{#if editor}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||||
|
|
@ -1019,6 +1023,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
<AdjustmentsHorizontalOutline />
|
<AdjustmentsHorizontalOutline />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<NoteMenu
|
<NoteMenu
|
||||||
onDownload={(type) => {
|
onDownload={(type) => {
|
||||||
|
|
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<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 -->
|
<!-- check for same date, yesterday, last week, and other -->
|
||||||
|
|
||||||
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
||||||
|
|
@ -1099,6 +1102,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if note?.write_access}
|
||||||
<button
|
<button
|
||||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
@ -1106,10 +1110,13 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
}}
|
}}
|
||||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
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>
|
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
{$i18n.t('Read-Only Access')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if editor}
|
{#if editor}
|
||||||
<div class="flex items-center gap-1 px-1 min-w-fit">
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class=" flex-1 w-full h-full overflow-auto px-3.5 pb-20 relative pt-2.5"
|
class=" flex-1 w-full h-full overflow-auto px-3.5 relative"
|
||||||
id="note-content-container"
|
id="note-content-container"
|
||||||
>
|
>
|
||||||
{#if editing}
|
{#if editing}
|
||||||
|
|
@ -1145,7 +1152,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
bind:this={inputElement}
|
bind:this={inputElement}
|
||||||
bind:editor
|
bind:editor
|
||||||
id={`note-${note.id}`}
|
id={`note-${note.id}`}
|
||||||
className="input-prose-sm px-0.5"
|
className="input-prose-sm px-0.5 h-[calc(100%-2rem)]"
|
||||||
json={true}
|
json={true}
|
||||||
bind:value={note.data.content.json}
|
bind:value={note.data.content.json}
|
||||||
html={note.data?.content?.html}
|
html={note.data?.content?.html}
|
||||||
|
|
@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
image={true}
|
image={true}
|
||||||
{files}
|
{files}
|
||||||
placeholder={$i18n.t('Write something...')}
|
placeholder={$i18n.t('Write something...')}
|
||||||
editable={versionIdx === null && !editing}
|
editable={versionIdx === null && !editing && note?.write_access}
|
||||||
onSelectionUpdate={({ editor }) => {
|
onSelectionUpdate={({ editor }) => {
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute z-20 bottom-0 right-0 p-3.5 max-w-full w-full flex">
|
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
|
||||||
<div class="flex gap-1 w-full min-w-full justify-between">
|
<div class="flex flex-col gap-2 justify-end">
|
||||||
{#if recording}
|
{#if recording}
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<VoiceRecording
|
<VoiceRecording
|
||||||
|
|
@ -1269,6 +1276,39 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
|
||||||
|
>
|
||||||
|
<Tooltip content={$i18n.t('AI')} placement="top">
|
||||||
|
{#if editing}
|
||||||
|
<button
|
||||||
|
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
|
||||||
|
on:click={() => {
|
||||||
|
stopResponseHandler();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<AiMenu
|
||||||
|
onEdit={() => {
|
||||||
|
enhanceNoteHandler();
|
||||||
|
}}
|
||||||
|
onChat={() => {
|
||||||
|
showPanel = true;
|
||||||
|
selectedPanel = 'chat';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
|
||||||
|
>
|
||||||
|
<SparklesSolid />
|
||||||
|
</div>
|
||||||
|
</AiMenu>
|
||||||
|
{/if}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<RecordMenu
|
<RecordMenu
|
||||||
onRecord={async () => {
|
onRecord={async () => {
|
||||||
displayMediaRecord = false;
|
displayMediaRecord = false;
|
||||||
|
|
@ -1324,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</RecordMenu>
|
</RecordMenu>
|
||||||
|
|
||||||
<div
|
|
||||||
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
|
|
||||||
>
|
|
||||||
<Tooltip content={$i18n.t('AI')} placement="top">
|
|
||||||
{#if editing}
|
|
||||||
<button
|
|
||||||
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
|
|
||||||
on:click={() => {
|
|
||||||
stopResponseHandler();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<AiMenu
|
|
||||||
onEdit={() => {
|
|
||||||
enhanceNoteHandler();
|
|
||||||
}}
|
|
||||||
onChat={() => {
|
|
||||||
showPanel = true;
|
|
||||||
selectedPanel = 'chat';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
|
|
||||||
>
|
|
||||||
<SparklesSolid />
|
|
||||||
</div>
|
|
||||||
</AiMenu>
|
|
||||||
{/if}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import fileSaver from 'file-saver';
|
import fileSaver from 'file-saver';
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
|
|
||||||
const { saveAs } = fileSaver;
|
const { saveAs } = fileSaver;
|
||||||
|
|
||||||
|
|
@ -25,17 +23,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { onMount, getContext, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
// Assuming $i18n.languages is an array of language codes
|
// Assuming $i18n.languages is an array of language codes
|
||||||
$: loadLocale($i18n.languages);
|
$: loadLocale($i18n.languages);
|
||||||
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, getContext, onDestroy } from 'svelte';
|
|
||||||
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
|
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
|
||||||
|
import { createNewNote, deleteNoteById, getNoteList, searchNotes } from '$lib/apis/notes';
|
||||||
import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes';
|
|
||||||
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
|
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
|
||||||
|
|
||||||
import { downloadPdf, createNoteHandler } from './utils';
|
import { downloadPdf, createNoteHandler } from './utils';
|
||||||
|
|
||||||
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
||||||
|
|
@ -48,58 +45,31 @@
|
||||||
import NoteMenu from './Notes/NoteMenu.svelte';
|
import NoteMenu from './Notes/NoteMenu.svelte';
|
||||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
|
import DropdownOptions from '../common/DropdownOptions.svelte';
|
||||||
|
import Loader from '../common/Loader.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
||||||
let importFiles = '';
|
let importFiles = '';
|
||||||
let query = '';
|
|
||||||
|
|
||||||
let noteItems = [];
|
|
||||||
let fuse = null;
|
|
||||||
|
|
||||||
let selectedNote = null;
|
let selectedNote = null;
|
||||||
let notes = {};
|
|
||||||
$: if (fuse) {
|
|
||||||
notes = groupNotes(
|
|
||||||
query
|
|
||||||
? fuse.search(query).map((e) => {
|
|
||||||
return e.item;
|
|
||||||
})
|
|
||||||
: noteItems
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let showDeleteConfirm = false;
|
let showDeleteConfirm = false;
|
||||||
|
|
||||||
const groupNotes = (res) => {
|
let notes = {};
|
||||||
console.log(res);
|
|
||||||
if (!Array.isArray(res)) {
|
|
||||||
return {}; // or throw new Error("Notes response is not an array")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the grouped object
|
let items = null;
|
||||||
const grouped: Record<string, any[]> = {};
|
let total = null;
|
||||||
for (const note of res) {
|
|
||||||
const timeRange = getTimeRange(note.updated_at / 1000000000);
|
|
||||||
if (!grouped[timeRange]) {
|
|
||||||
grouped[timeRange] = [];
|
|
||||||
}
|
|
||||||
grouped[timeRange].push({
|
|
||||||
...note,
|
|
||||||
timeRange
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
|
|
||||||
const init = async () => {
|
let query = '';
|
||||||
noteItems = await getNotes(localStorage.token, true);
|
|
||||||
|
|
||||||
fuse = new Fuse(noteItems, {
|
let sortKey = null;
|
||||||
keys: ['title']
|
let displayOption = null;
|
||||||
});
|
let viewOption = null;
|
||||||
};
|
let permission = null;
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
let itemsLoading = false;
|
||||||
|
let allItemsLoaded = false;
|
||||||
|
|
||||||
const downloadHandler = async (type) => {
|
const downloadHandler = async (type) => {
|
||||||
if (type === 'txt') {
|
if (type === 'txt') {
|
||||||
|
|
@ -173,6 +143,96 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
page = 1;
|
||||||
|
items = null;
|
||||||
|
total = null;
|
||||||
|
allItemsLoaded = false;
|
||||||
|
itemsLoading = false;
|
||||||
|
notes = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreItems = async () => {
|
||||||
|
if (allItemsLoaded) return;
|
||||||
|
page += 1;
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
loaded &&
|
||||||
|
query !== undefined &&
|
||||||
|
sortKey !== undefined &&
|
||||||
|
permission !== undefined &&
|
||||||
|
viewOption !== undefined
|
||||||
|
) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemsPage = async () => {
|
||||||
|
itemsLoading = true;
|
||||||
|
|
||||||
|
if (viewOption === 'created') {
|
||||||
|
permission = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await searchNotes(
|
||||||
|
localStorage.token,
|
||||||
|
query,
|
||||||
|
viewOption,
|
||||||
|
permission,
|
||||||
|
sortKey,
|
||||||
|
page
|
||||||
|
).catch(() => {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
console.log(res);
|
||||||
|
total = res.total;
|
||||||
|
const pageItems = res.items;
|
||||||
|
|
||||||
|
if ((pageItems ?? []).length === 0) {
|
||||||
|
allItemsLoaded = true;
|
||||||
|
} else {
|
||||||
|
allItemsLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
items = [...items, ...pageItems];
|
||||||
|
} else {
|
||||||
|
items = pageItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsLoading = false;
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupNotes = (res) => {
|
||||||
|
if (!Array.isArray(res)) {
|
||||||
|
return {}; // or throw new Error("Notes response is not an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the grouped object
|
||||||
|
const grouped: Record<string, any[]> = {};
|
||||||
|
for (const note of res) {
|
||||||
|
const timeRange = getTimeRange(note.updated_at / 1000000000);
|
||||||
|
if (!grouped[timeRange]) {
|
||||||
|
grouped[timeRange] = [];
|
||||||
|
}
|
||||||
|
grouped[timeRange].push({
|
||||||
|
...note,
|
||||||
|
timeRange
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
|
|
||||||
const onDragOver = (e) => {
|
const onDragOver = (e) => {
|
||||||
|
|
@ -205,6 +265,18 @@
|
||||||
dragged = false;
|
dragged = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
viewOption = localStorage?.noteViewOption ?? null;
|
||||||
|
displayOption = localStorage?.noteDisplayOption ?? null;
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
|
||||||
|
const dropzoneElement = document.getElementById('notes-container');
|
||||||
|
dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||||
|
dropzoneElement?.addEventListener('drop', onDrop);
|
||||||
|
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
console.log('destroy');
|
console.log('destroy');
|
||||||
const dropzoneElement = document.getElementById('notes-container');
|
const dropzoneElement = document.getElementById('notes-container');
|
||||||
|
|
@ -215,17 +287,6 @@
|
||||||
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await init();
|
|
||||||
loaded = true;
|
|
||||||
|
|
||||||
const dropzoneElement = document.getElementById('notes-container');
|
|
||||||
|
|
||||||
dropzoneElement?.addEventListener('dragover', onDragOver);
|
|
||||||
dropzoneElement?.addEventListener('drop', onDrop);
|
|
||||||
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -236,7 +297,7 @@
|
||||||
|
|
||||||
<FilesOverlay show={dragged} />
|
<FilesOverlay show={dragged} />
|
||||||
|
|
||||||
<div id="notes-container" class="w-full min-h-full h-full">
|
<div id="notes-container" class="w-full min-h-full h-full px-3 md:px-[18px]">
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
bind:show={showDeleteConfirm}
|
bind:show={showDeleteConfirm}
|
||||||
|
|
@ -251,8 +312,41 @@
|
||||||
</div>
|
</div>
|
||||||
</DeleteConfirmDialog>
|
</DeleteConfirmDialog>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 px-3.5">
|
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
|
||||||
<div class=" flex flex-1 items-center w-full space-x-2">
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||||
|
<div>
|
||||||
|
{$i18n.t('Notes')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||||
|
{total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
|
||||||
|
on:click={async () => {
|
||||||
|
const res = await createNoteHandler(dayjs().format('YYYY-MM-DD'));
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
goto(`/notes/${res.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" strokeWidth="2.5" />
|
||||||
|
|
||||||
|
<div class=" ml-1 text-xs">{$i18n.t('New Note')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
|
||||||
|
>
|
||||||
|
<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="flex flex-1 items-center">
|
||||||
<div class=" self-center ml-1 mr-3">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<Search className="size-3.5" />
|
<Search className="size-3.5" />
|
||||||
|
|
@ -277,18 +371,174 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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.noteViewOption = value;
|
||||||
|
} else {
|
||||||
|
delete localStorage.noteViewOption;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
<div class="px-4.5 @container h-full pt-2">
|
<div>
|
||||||
{#if Object.keys(notes).length > 0}
|
<DropdownOptions
|
||||||
<div class="pb-10">
|
align="start"
|
||||||
{#each Object.keys(notes) as timeRange}
|
bind:value={displayOption}
|
||||||
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5">
|
items={[
|
||||||
|
{ value: null, label: $i18n.t('List') },
|
||||||
|
{ value: 'grid', label: $i18n.t('Grid') }
|
||||||
|
]}
|
||||||
|
onChange={() => {
|
||||||
|
if (displayOption) {
|
||||||
|
localStorage.noteDisplayOption = displayOption;
|
||||||
|
} else {
|
||||||
|
delete localStorage.noteDisplayOption;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if items !== null && total !== null}
|
||||||
|
{#if (items ?? []).length > 0}
|
||||||
|
{@const notes = groupNotes(items)}
|
||||||
|
|
||||||
|
<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)}
|
{$i18n.t(timeRange)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if displayOption === null}
|
||||||
<div
|
<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"
|
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)}
|
{#each notes[timeRange] as note, idx (note.id)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -300,8 +550,12 @@
|
||||||
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class=" flex items-center gap-2 self-center mb-1 justify-between">
|
<div
|
||||||
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</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>
|
<div>
|
||||||
<NoteMenu
|
<NoteMenu
|
||||||
|
|
@ -312,7 +566,9 @@
|
||||||
}}
|
}}
|
||||||
onCopyLink={async () => {
|
onCopyLink={async () => {
|
||||||
const baseUrl = window.location.origin;
|
const baseUrl = window.location.origin;
|
||||||
const res = await copyToClipboard(`${baseUrl}/notes/${note.id}`);
|
const res = await copyToClipboard(
|
||||||
|
`${baseUrl}/notes/${note.id}`
|
||||||
|
);
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Copied link to clipboard'));
|
toast.success($i18n.t('Copied link to clipboard'));
|
||||||
|
|
@ -358,7 +614,9 @@
|
||||||
<div class="shrink-0 text-gray-500">
|
<div class="shrink-0 text-gray-500">
|
||||||
{$i18n.t('By {{name}}', {
|
{$i18n.t('By {{name}}', {
|
||||||
name: capitalizeFirstLetter(
|
name: capitalizeFirstLetter(
|
||||||
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
|
note?.user?.name ??
|
||||||
|
note?.user?.email ??
|
||||||
|
$i18n.t('Deleted User')
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,102 +627,49 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/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"
|
||||||
|
>
|
||||||
|
<Spinner className=" size-4" />
|
||||||
|
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full flex flex-col items-center justify-center">
|
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||||
<div class="pb-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
|
<div class=" text-sm text-gray-400 dark:text-gray-600">
|
||||||
{$i18n.t('No Notes')}
|
{$i18n.t('No Notes')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
|
<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.')}
|
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
|
|
||||||
<div class="flex gap-0.5 justify-end w-full">
|
|
||||||
<Tooltip content={$i18n.t('Create Note')}>
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
type="button"
|
|
||||||
on:click={async () => {
|
|
||||||
const res = await createNoteHandler(dayjs().format('YYYY-MM-DD'));
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
goto(`/notes/${res.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-4.5" strokeWidth="2.5" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<!-- <button
|
|
||||||
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
|
|
||||||
>
|
|
||||||
<SparklesSolid className="size-4" />
|
|
||||||
</button> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- {#if $user?.role === 'admin'}
|
|
||||||
<div class=" flex justify-end w-full mb-3">
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<input
|
|
||||||
id="notes-import-input"
|
|
||||||
bind:files={importFiles}
|
|
||||||
type="file"
|
|
||||||
accept=".md"
|
|
||||||
hidden
|
|
||||||
on:change={() => {
|
|
||||||
console.log(importFiles);
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
console.log(event.target.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsText(importFiles[0]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
|
||||||
on:click={() => {
|
|
||||||
const notesImportInputElement = document.getElementById('notes-import-input');
|
|
||||||
if (notesImportInputElement) {
|
|
||||||
notesImportInputElement.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
|
|
||||||
|
|
||||||
<div class=" self-center">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if} -->
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full flex justify-center items-center">
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center">
|
||||||
|
<Spinner className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ export const downloadPdf = async (note) => {
|
||||||
pdf.save(`${note.title}.pdf`);
|
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'),
|
// $i18n.t('New Note'),
|
||||||
const res = await createNewNote(localStorage.token, {
|
const res = await createNewNote(localStorage.token, {
|
||||||
// YYYY-MM-DD
|
// YYYY-MM-DD
|
||||||
|
|
@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => {
|
||||||
data: {
|
data: {
|
||||||
content: {
|
content: {
|
||||||
json: null,
|
json: null,
|
||||||
html: content ?? '',
|
html: html || md || '',
|
||||||
md: content ?? ''
|
md: md || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
meta: null,
|
meta: null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
@ -10,11 +8,7 @@
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
|
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
|
||||||
import {
|
import { deleteKnowledgeById, searchKnowledgeBases } from '$lib/apis/knowledge';
|
||||||
getKnowledgeBases,
|
|
||||||
deleteKnowledgeById,
|
|
||||||
getKnowledgeBaseList
|
|
||||||
} from '$lib/apis/knowledge';
|
|
||||||
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { capitalizeFirstLetter } from '$lib/utils';
|
import { capitalizeFirstLetter } from '$lib/utils';
|
||||||
|
|
@ -28,75 +22,90 @@
|
||||||
import Tooltip from '../common/Tooltip.svelte';
|
import Tooltip from '../common/Tooltip.svelte';
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
import ViewSelector from './common/ViewSelector.svelte';
|
import ViewSelector from './common/ViewSelector.svelte';
|
||||||
|
import Loader from '../common/Loader.svelte';
|
||||||
|
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
||||||
let query = '';
|
|
||||||
let selectedItem = null;
|
|
||||||
let showDeleteConfirm = false;
|
let showDeleteConfirm = false;
|
||||||
|
|
||||||
let tagsContainerElement: HTMLDivElement;
|
let tagsContainerElement: HTMLDivElement;
|
||||||
|
|
||||||
|
let selectedItem = null;
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
let query = '';
|
||||||
let viewOption = '';
|
let viewOption = '';
|
||||||
|
|
||||||
let fuse = null;
|
let items = null;
|
||||||
|
let total = null;
|
||||||
|
|
||||||
let knowledgeBases = [];
|
let allItemsLoaded = false;
|
||||||
|
let itemsLoading = false;
|
||||||
|
|
||||||
let items = [];
|
$: if (loaded && query !== undefined && viewOption !== undefined) {
|
||||||
let filteredItems = [];
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
const setFuse = async () => {
|
const reset = () => {
|
||||||
items = knowledgeBases.filter(
|
page = 1;
|
||||||
(item) =>
|
items = null;
|
||||||
viewOption === '' ||
|
total = null;
|
||||||
(viewOption === 'created' && item.user_id === $user?.id) ||
|
allItemsLoaded = false;
|
||||||
(viewOption === 'shared' && item.user_id !== $user?.id)
|
itemsLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreItems = async () => {
|
||||||
|
if (allItemsLoaded) return;
|
||||||
|
page += 1;
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemsPage = async () => {
|
||||||
|
itemsLoading = true;
|
||||||
|
const res = await searchKnowledgeBases(localStorage.token, query, viewOption, page).catch(
|
||||||
|
() => {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
fuse = new Fuse(items, {
|
if (res) {
|
||||||
keys: [
|
console.log(res);
|
||||||
'name',
|
total = res.total;
|
||||||
'description',
|
const pageItems = res.items;
|
||||||
'user.name', // Ensures Fuse looks into item.user.name
|
|
||||||
'user.email' // Ensures Fuse looks into item.user.email
|
|
||||||
],
|
|
||||||
threshold: 0.3
|
|
||||||
});
|
|
||||||
|
|
||||||
await tick();
|
if ((pageItems ?? []).length === 0) {
|
||||||
setFilteredItems();
|
allItemsLoaded = true;
|
||||||
};
|
|
||||||
|
|
||||||
$: if (knowledgeBases.length > 0 && viewOption !== undefined) {
|
|
||||||
// Added a check for non-empty array, good practice
|
|
||||||
setFuse();
|
|
||||||
} else {
|
} else {
|
||||||
fuse = null; // Reset fuse if knowledgeBases is empty
|
allItemsLoaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setFilteredItems = () => {
|
if (items) {
|
||||||
filteredItems = query ? fuse.search(query).map((result) => result.item) : items;
|
items = [...items, ...pageItems];
|
||||||
|
} else {
|
||||||
|
items = pageItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsLoading = false;
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (query !== undefined && fuse) {
|
|
||||||
setFilteredItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteHandler = async (item) => {
|
const deleteHandler = async (item) => {
|
||||||
const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => {
|
const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
|
|
||||||
knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
toast.success($i18n.t('Knowledge deleted successfully.'));
|
toast.success($i18n.t('Knowledge deleted successfully.'));
|
||||||
|
init();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
viewOption = localStorage?.workspaceViewOption || '';
|
viewOption = localStorage?.workspaceViewOption || '';
|
||||||
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -123,7 +132,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||||
{filteredItems.length}
|
{total}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -192,11 +201,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if (filteredItems ?? []).length !== 0}
|
{#if items !== null && total !== null}
|
||||||
|
{#if (items ?? []).length !== 0}
|
||||||
<!-- The Aleph dreams itself into being, and the void learns its own name -->
|
<!-- The Aleph dreams itself into being, and the void learns its own name -->
|
||||||
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
|
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||||
{#each filteredItems as item}
|
{#each items as item}
|
||||||
<Tooltip content={item?.description ?? item.name}>
|
|
||||||
<button
|
<button
|
||||||
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
|
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
@ -212,23 +221,20 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class=" w-full">
|
<div class=" w-full">
|
||||||
<div class=" self-center flex-1">
|
<div class=" self-center flex-1 justify-between">
|
||||||
<div class="flex items-center justify-between -my-1">
|
<div class="flex items-center justify-between -my-1 h-8">
|
||||||
<div class=" flex gap-2 items-center">
|
<div class=" flex gap-2 items-center justify-between w-full">
|
||||||
<div>
|
<div>
|
||||||
{#if item?.meta?.document}
|
|
||||||
<Badge type="muted" content={$i18n.t('Document')} />
|
|
||||||
{:else}
|
|
||||||
<Badge type="success" content={$i18n.t('Collection')} />
|
<Badge type="success" content={$i18n.t('Collection')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !item?.write_access}
|
||||||
|
<div>
|
||||||
|
<Badge type="muted" content={$i18n.t('Read Only')} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if item?.write_access}
|
||||||
<div class=" text-xs text-gray-500 line-clamp-1">
|
|
||||||
{$i18n.t('Updated')}
|
|
||||||
{dayjs(item.updated_at * 1000).fromNow()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class=" flex self-center">
|
<div class=" flex self-center">
|
||||||
<ItemMenu
|
<ItemMenu
|
||||||
|
|
@ -239,15 +245,25 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" flex items-center gap-1 justify-between px-1.5">
|
<div class=" flex items-center gap-1 justify-between px-1.5">
|
||||||
|
<Tooltip content={item?.description ?? item.name}>
|
||||||
<div class=" flex items-center gap-2">
|
<div class=" flex items-center gap-2">
|
||||||
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</div>
|
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<div>
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<div class="text-xs text-gray-500">
|
<Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
|
||||||
|
<div class=" text-xs text-gray-500 line-clamp-1 hidden sm:block">
|
||||||
|
{$i18n.t('Updated')}
|
||||||
|
{dayjs(item.updated_at * 1000).fromNow()}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 shrink-0">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={item?.user?.email ?? $i18n.t('Deleted User')}
|
content={item?.user?.email ?? $i18n.t('Deleted User')}
|
||||||
className="flex shrink-0"
|
className="flex shrink-0"
|
||||||
|
|
@ -265,9 +281,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#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">
|
||||||
|
<Spinner className=" size-4" />
|
||||||
|
<div class=" ">{$i18n.t('Loading...')}</div>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||||
<div class="max-w-md text-center">
|
<div class="max-w-md text-center">
|
||||||
|
|
@ -279,6 +309,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" text-gray-500 text-xs m-2">
|
<div class=" text-gray-500 text-xs m-2">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import { createNewKnowledge, getKnowledgeBases } from '$lib/apis/knowledge';
|
import { user } from '$lib/stores';
|
||||||
import { toast } from 'svelte-sonner';
|
import { createNewKnowledge } from '$lib/apis/knowledge';
|
||||||
import { knowledge, user } from '$lib/stores';
|
|
||||||
import AccessControl from '../common/AccessControl.svelte';
|
import AccessControl from '../common/AccessControl.svelte';
|
||||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
|
||||||
|
|
@ -37,7 +39,6 @@
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Knowledge created successfully.'));
|
toast.success($i18n.t('Knowledge created successfully.'));
|
||||||
knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
goto(`/workspace/knowledge/${res.id}`);
|
goto(`/workspace/knowledge/${res.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@
|
||||||
import {
|
import {
|
||||||
addFileToKnowledgeById,
|
addFileToKnowledgeById,
|
||||||
getKnowledgeById,
|
getKnowledgeById,
|
||||||
getKnowledgeBases,
|
|
||||||
removeFileFromKnowledgeById,
|
removeFileFromKnowledgeById,
|
||||||
resetKnowledgeById,
|
resetKnowledgeById,
|
||||||
updateFileFromKnowledgeById,
|
updateFileFromKnowledgeById,
|
||||||
updateKnowledgeById
|
updateKnowledgeById,
|
||||||
|
searchKnowledgeFilesById
|
||||||
} from '$lib/apis/knowledge';
|
} from '$lib/apis/knowledge';
|
||||||
import { blobToFile } from '$lib/utils';
|
import { blobToFile } from '$lib/utils';
|
||||||
|
|
||||||
|
|
@ -43,22 +43,25 @@
|
||||||
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
||||||
|
|
||||||
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
||||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
|
||||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
|
||||||
import Drawer from '$lib/components/common/Drawer.svelte';
|
import Drawer from '$lib/components/common/Drawer.svelte';
|
||||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||||
import Search from '$lib/components/icons/Search.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
|
||||||
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
||||||
|
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
|
||||||
|
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||||
|
|
||||||
let largeScreen = true;
|
let largeScreen = true;
|
||||||
|
|
||||||
let pane;
|
let pane;
|
||||||
let showSidepanel = true;
|
let showSidepanel = true;
|
||||||
let minSize = 0;
|
|
||||||
|
|
||||||
|
let showAddTextContentModal = false;
|
||||||
|
let showSyncConfirmModal = false;
|
||||||
|
let showAccessControlModal = false;
|
||||||
|
|
||||||
|
let minSize = 0;
|
||||||
type Knowledge = {
|
type Knowledge = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -71,52 +74,89 @@
|
||||||
|
|
||||||
let id = null;
|
let id = null;
|
||||||
let knowledge: Knowledge | null = null;
|
let knowledge: Knowledge | null = null;
|
||||||
let query = '';
|
let knowledgeId = null;
|
||||||
|
|
||||||
let showAddTextContentModal = false;
|
let selectedFileId = null;
|
||||||
let showSyncConfirmModal = false;
|
let selectedFile = null;
|
||||||
let showAccessControlModal = false;
|
let selectedFileContent = '';
|
||||||
|
|
||||||
let inputFiles = null;
|
let inputFiles = null;
|
||||||
|
|
||||||
let filteredItems = [];
|
let query = '';
|
||||||
$: if (knowledge && knowledge.files) {
|
let viewOption = null;
|
||||||
fuse = new Fuse(knowledge.files, {
|
let sortKey = null;
|
||||||
keys: ['meta.name', 'meta.description']
|
let direction = null;
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
let fileItems = null;
|
||||||
|
let fileItemsTotal = null;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
currentPage = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
knowledgeId !== null &&
|
||||||
|
query !== undefined &&
|
||||||
|
viewOption !== undefined &&
|
||||||
|
sortKey !== undefined &&
|
||||||
|
direction !== undefined &&
|
||||||
|
currentPage !== undefined
|
||||||
|
) {
|
||||||
|
getItemsPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
query !== undefined &&
|
||||||
|
viewOption !== undefined &&
|
||||||
|
sortKey !== undefined &&
|
||||||
|
direction !== undefined
|
||||||
|
) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemsPage = async () => {
|
||||||
|
if (knowledgeId === null) return;
|
||||||
|
|
||||||
|
fileItems = null;
|
||||||
|
fileItemsTotal = null;
|
||||||
|
|
||||||
|
if (sortKey === null) {
|
||||||
|
direction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
$: if (fuse) {
|
const fileSelectHandler = async (file) => {
|
||||||
filteredItems = query
|
try {
|
||||||
? fuse.search(query).map((e) => {
|
selectedFile = file;
|
||||||
return e.item;
|
selectedFileContent = selectedFile?.data?.content || '';
|
||||||
})
|
} catch (e) {
|
||||||
: (knowledge?.files ?? []);
|
toast.error($i18n.t('Failed to load file content.'));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
let selectedFile = null;
|
|
||||||
let selectedFileId = null;
|
|
||||||
let selectedFileContent = '';
|
|
||||||
|
|
||||||
// Add cache object
|
|
||||||
let fileContentCache = new Map();
|
|
||||||
|
|
||||||
$: if (selectedFileId) {
|
|
||||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
|
||||||
if (file) {
|
|
||||||
fileSelectHandler(file);
|
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fuse = null;
|
|
||||||
let debounceTimeout = null;
|
|
||||||
let mediaQuery;
|
|
||||||
let dragged = false;
|
|
||||||
let isSaving = false;
|
|
||||||
|
|
||||||
const createFileFromText = (name, content) => {
|
const createFileFromText = (name, content) => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
|
@ -163,19 +203,18 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
knowledge.files = [...(knowledge.files ?? []), fileItem];
|
fileItems = [...(fileItems ?? []), fileItem];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let metadata = {
|
||||||
|
knowledge_id: knowledge.id,
|
||||||
// If the file is an audio file, provide the language for STT.
|
// If the file is an audio file, provide the language for STT.
|
||||||
let metadata = null;
|
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
||||||
if (
|
|
||||||
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
|
||||||
$settings?.audio?.stt?.language
|
$settings?.audio?.stt?.language
|
||||||
) {
|
? {
|
||||||
metadata = {
|
|
||||||
language: $settings?.audio?.stt?.language
|
language: $settings?.audio?.stt?.language
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => {
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
|
|
@ -184,7 +223,7 @@
|
||||||
|
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
console.log(uploadedFile);
|
console.log(uploadedFile);
|
||||||
knowledge.files = knowledge.files.map((item) => {
|
fileItems = fileItems.map((item) => {
|
||||||
if (item.itemId === tempItemId) {
|
if (item.itemId === tempItemId) {
|
||||||
item.id = uploadedFile.id;
|
item.id = uploadedFile.id;
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +236,7 @@
|
||||||
if (uploadedFile.error) {
|
if (uploadedFile.error) {
|
||||||
console.warn('File upload warning:', uploadedFile.error);
|
console.warn('File upload warning:', uploadedFile.error);
|
||||||
toast.warning(uploadedFile.error);
|
toast.warning(uploadedFile.error);
|
||||||
knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id);
|
fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
|
||||||
} else {
|
} else {
|
||||||
await addFileHandler(uploadedFile.id);
|
await addFileHandler(uploadedFile.id);
|
||||||
}
|
}
|
||||||
|
|
@ -383,13 +422,13 @@
|
||||||
|
|
||||||
// Helper function to maintain file paths within zip
|
// Helper function to maintain file paths within zip
|
||||||
const syncDirectoryHandler = async () => {
|
const syncDirectoryHandler = async () => {
|
||||||
if ((knowledge?.files ?? []).length > 0) {
|
if (fileItems.length > 0) {
|
||||||
const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
|
const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
knowledge = res;
|
fileItems = [];
|
||||||
toast.success($i18n.t('Knowledge reset successfully.'));
|
toast.success($i18n.t('Knowledge reset successfully.'));
|
||||||
|
|
||||||
// Upload directory
|
// Upload directory
|
||||||
|
|
@ -401,19 +440,17 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const addFileHandler = async (fileId) => {
|
const addFileHandler = async (fileId) => {
|
||||||
const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch(
|
const res = await addFileToKnowledgeById(localStorage.token, id, fileId).catch((e) => {
|
||||||
(e) => {
|
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedKnowledge) {
|
if (res) {
|
||||||
knowledge = updatedKnowledge;
|
|
||||||
toast.success($i18n.t('File added successfully.'));
|
toast.success($i18n.t('File added successfully.'));
|
||||||
|
init();
|
||||||
} else {
|
} else {
|
||||||
toast.error($i18n.t('Failed to add file.'));
|
toast.error($i18n.t('Failed to add file.'));
|
||||||
knowledge.files = knowledge.files.filter((file) => file.id !== fileId);
|
fileItems = fileItems.filter((file) => file.id !== fileId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -422,13 +459,12 @@
|
||||||
console.log('Starting file deletion process for:', fileId);
|
console.log('Starting file deletion process for:', fileId);
|
||||||
|
|
||||||
// Remove from knowledge base only
|
// Remove from knowledge base only
|
||||||
const updatedKnowledge = await removeFileFromKnowledgeById(localStorage.token, id, fileId);
|
const res = await removeFileFromKnowledgeById(localStorage.token, id, fileId);
|
||||||
|
console.log('Knowledge base updated:', res);
|
||||||
|
|
||||||
console.log('Knowledge base updated:', updatedKnowledge);
|
if (res) {
|
||||||
|
|
||||||
if (updatedKnowledge) {
|
|
||||||
knowledge = updatedKnowledge;
|
|
||||||
toast.success($i18n.t('File removed successfully.'));
|
toast.success($i18n.t('File removed successfully.'));
|
||||||
|
await init();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error in deleteFileHandler:', e);
|
console.error('Error in deleteFileHandler:', e);
|
||||||
|
|
@ -436,32 +472,38 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let debounceTimeout = null;
|
||||||
|
let mediaQuery;
|
||||||
|
|
||||||
|
let dragged = false;
|
||||||
|
let isSaving = false;
|
||||||
|
|
||||||
const updateFileContentHandler = async () => {
|
const updateFileContentHandler = async () => {
|
||||||
if (isSaving) {
|
if (isSaving) {
|
||||||
console.log('Save operation already in progress, skipping...');
|
console.log('Save operation already in progress, skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileId = selectedFile.id;
|
const res = await updateFileDataContentById(
|
||||||
const content = selectedFileContent;
|
|
||||||
// Clear the cache for this file since we're updating it
|
|
||||||
fileContentCache.delete(fileId);
|
|
||||||
const res = await updateFileDataContentById(localStorage.token, fileId, content).catch(
|
|
||||||
(e) => {
|
|
||||||
toast.error(`${e}`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const updatedKnowledge = await updateFileFromKnowledgeById(
|
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
id,
|
selectedFile.id,
|
||||||
fileId
|
selectedFileContent
|
||||||
).catch((e) => {
|
).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
if (res && updatedKnowledge) {
|
|
||||||
knowledge = updatedKnowledge;
|
if (res) {
|
||||||
toast.success($i18n.t('File content updated successfully.'));
|
toast.success($i18n.t('File content updated successfully.'));
|
||||||
|
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
selectedFileContent = '';
|
||||||
|
|
||||||
|
await init();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
|
|
@ -491,7 +533,6 @@
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Knowledge updated successfully'));
|
toast.success($i18n.t('Knowledge updated successfully'));
|
||||||
_knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
@ -504,29 +545,6 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileSelectHandler = async (file) => {
|
|
||||||
try {
|
|
||||||
selectedFile = file;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if (fileContentCache.has(file.id)) {
|
|
||||||
selectedFileContent = fileContentCache.get(file.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getFileById(localStorage.token, file.id);
|
|
||||||
if (response) {
|
|
||||||
selectedFileContent = response.data.content;
|
|
||||||
// Cache the content
|
|
||||||
fileContentCache.set(file.id, response.data.content);
|
|
||||||
} else {
|
|
||||||
toast.error($i18n.t('No content found in file.'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error($i18n.t('Failed to load file content.'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOver = (e) => {
|
const onDragOver = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -546,6 +564,11 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragged = false;
|
dragged = false;
|
||||||
|
|
||||||
|
if (!knowledge?.write_access) {
|
||||||
|
toast.error($i18n.t('You do not have permission to upload files to this knowledge base.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleUploadingFileFolder = (items) => {
|
const handleUploadingFileFolder = (items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.isFile) {
|
if (item.isFile) {
|
||||||
|
|
@ -627,7 +650,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
id = $page.params.id;
|
id = $page.params.id;
|
||||||
|
|
||||||
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
|
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -635,6 +657,7 @@
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
knowledge = res;
|
knowledge = res;
|
||||||
|
knowledgeId = knowledge?.id;
|
||||||
} else {
|
} else {
|
||||||
goto('/workspace/knowledge');
|
goto('/workspace/knowledge');
|
||||||
}
|
}
|
||||||
|
|
@ -705,34 +728,46 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col w-full h-full translate-y-1" id="collection-container">
|
<div class="flex flex-col w-full h-full min-h-full" id="collection-container">
|
||||||
{#if id && knowledge}
|
{#if id && knowledge}
|
||||||
<AccessControlModal
|
<AccessControlModal
|
||||||
bind:show={showAccessControlModal}
|
bind:show={showAccessControlModal}
|
||||||
bind:accessControl={knowledge.access_control}
|
bind:accessControl={knowledge.access_control}
|
||||||
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
||||||
sharePu={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
changeDebounceHandler();
|
changeDebounceHandler();
|
||||||
}}
|
}}
|
||||||
accessRoles={['read', 'write']}
|
accessRoles={['read', 'write']}
|
||||||
/>
|
/>
|
||||||
<div class="w-full mb-2.5">
|
<div class="w-full px-2">
|
||||||
<div class=" flex w-full">
|
<div class=" flex w-full">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center justify-between w-full px-0.5 mb-1">
|
<div class="flex items-center justify-between w-full">
|
||||||
<div class="w-full">
|
<div class="w-full flex justify-between items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="text-left w-full font-medium text-2xl font-primary bg-transparent outline-hidden"
|
class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1"
|
||||||
bind:value={knowledge.name}
|
bind:value={knowledge.name}
|
||||||
placeholder={$i18n.t('Knowledge Name')}
|
placeholder={$i18n.t('Knowledge Name')}
|
||||||
|
disabled={!knowledge?.write_access}
|
||||||
on:input={() => {
|
on:input={() => {
|
||||||
changeDebounceHandler();
|
changeDebounceHandler();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="shrink-0 mr-2.5">
|
||||||
|
{#if fileItemsTotal}
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{$i18n.t('{{count}} files', {
|
||||||
|
count: fileItemsTotal
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if knowledge?.write_access}
|
||||||
<div class="self-center shrink-0">
|
<div class="self-center shrink-0">
|
||||||
<button
|
<button
|
||||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
|
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
|
||||||
|
|
@ -748,14 +783,20 @@
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-xs shrink-0 text-gray-500">
|
||||||
|
{$i18n.t('Read Only')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full px-1">
|
<div class="flex w-full">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
||||||
bind:value={knowledge.description}
|
bind:value={knowledge.description}
|
||||||
placeholder={$i18n.t('Knowledge Description')}
|
placeholder={$i18n.t('Knowledge Description')}
|
||||||
|
disabled={!knowledge?.write_access}
|
||||||
on:input={() => {
|
on:input={() => {
|
||||||
changeDebounceHandler();
|
changeDebounceHandler();
|
||||||
}}
|
}}
|
||||||
|
|
@ -765,158 +806,24 @@
|
||||||
</div>
|
</div>
|
||||||
</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=" 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
|
<div
|
||||||
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden"
|
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"
|
||||||
>
|
>
|
||||||
{#key selectedFile.id}
|
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||||
<textarea
|
<div class="flex flex-1 items-center">
|
||||||
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>
|
|
||||||
{:else if !largeScreen && selectedFileId !== null}
|
|
||||||
<Drawer
|
|
||||||
className="h-full"
|
|
||||||
show={selectedFileId !== null}
|
|
||||||
onClose={() => {
|
|
||||||
selectedFileId = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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 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">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<Search />
|
<Search className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
|
placeholder={`${$i18n.t('Search Collection')}`}
|
||||||
on:focus={() => {
|
on:focus={() => {
|
||||||
selectedFileId = null;
|
selectedFileId = null;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if knowledge?.write_access}
|
||||||
<div>
|
<div>
|
||||||
<AddContentMenu
|
<AddContentMenu
|
||||||
on:upload={(e) => {
|
on:upload={(e) => {
|
||||||
|
|
@ -933,26 +840,101 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filteredItems.length > 0}
|
<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 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;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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') }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#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">
|
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
||||||
<Files
|
<Files
|
||||||
small
|
files={fileItems}
|
||||||
files={filteredItems}
|
{knowledge}
|
||||||
{selectedFileId}
|
{selectedFileId}
|
||||||
on:click={(e) => {
|
onClick={(fileId) => {
|
||||||
selectedFileId = selectedFileId === e.detail ? null : e.detail;
|
selectedFileId = fileId;
|
||||||
}}
|
|
||||||
on:delete={(e) => {
|
|
||||||
console.log(e.detail);
|
|
||||||
|
|
||||||
|
if (fileItems) {
|
||||||
|
const file = fileItems.find((file) => file.id === selectedFileId);
|
||||||
|
if (file) {
|
||||||
|
fileSelectHandler(file);
|
||||||
|
} else {
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={(fileId) => {
|
||||||
selectedFileId = null;
|
selectedFileId = null;
|
||||||
deleteFileHandler(e.detail);
|
selectedFile = null;
|
||||||
|
|
||||||
|
deleteFileHandler(fileId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if fileItemsTotal > 30}
|
||||||
|
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -963,6 +945,72 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{#if knowledge?.write_access}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key selectedFile.id}
|
||||||
|
<textarea
|
||||||
|
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
|
||||||
|
bind:value={selectedFileContent}
|
||||||
|
disabled={!knowledge?.write_access}
|
||||||
|
placeholder={$i18n.t('Add content here')}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="my-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,14 @@
|
||||||
|
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
class="w-full max-w-44 rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
transition={flyAndScale}
|
transition={flyAndScale}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'files' });
|
dispatch('upload', { type: 'files' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'directory' });
|
dispatch('upload', { type: 'directory' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('sync', { type: 'directory' });
|
dispatch('sync', { type: 'directory' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'text' });
|
dispatch('upload', { type: 'text' });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,100 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import dayjs from '$lib/dayjs';
|
||||||
const dispatch = createEventDispatcher();
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
import FileItem from '$lib/components/common/FileItem.svelte';
|
dayjs.extend(duration);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import { capitalizeFirstLetter, formatFileSize } from '$lib/utils';
|
||||||
|
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
|
||||||
|
export let knowledge = null;
|
||||||
export let selectedFileId = null;
|
export let selectedFileId = null;
|
||||||
export let files = [];
|
export let files = [];
|
||||||
|
|
||||||
export let small = false;
|
export let onClick = (fileId) => {};
|
||||||
|
export let onDelete = (fileId) => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" max-h-full flex flex-col w-full">
|
<div class=" max-h-full flex flex-col w-full gap-[0.5px]">
|
||||||
{#each files as file}
|
{#each files as file (file?.id ?? file?.tempId)}
|
||||||
<div class="mt-1 px-2">
|
<div
|
||||||
<FileItem
|
class=" flex cursor-pointer w-full px-1.5 py-0.5 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
|
||||||
className="w-full"
|
? ''
|
||||||
colorClassName="{selectedFileId === file.id
|
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
|
||||||
? ' bg-gray-50 dark:bg-gray-850'
|
>
|
||||||
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
<button
|
||||||
{small}
|
class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
|
||||||
item={file}
|
type="button"
|
||||||
name={file?.name ?? file?.meta?.name}
|
on:click={async () => {
|
||||||
type="file"
|
console.log(file);
|
||||||
size={file?.size ?? file?.meta?.size ?? ''}
|
onClick(file?.id ?? file?.tempId);
|
||||||
loading={file.status === 'uploading'}
|
}}
|
||||||
dismissible
|
>
|
||||||
|
<div class="">
|
||||||
|
<div class="flex gap-2 items-center line-clamp-1">
|
||||||
|
<div class="shrink-0">
|
||||||
|
{#if file?.status !== 'uploading'}
|
||||||
|
<DocumentPage className="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Spinner className="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line-clamp-1 text-sm">
|
||||||
|
{file?.name ?? file?.meta?.name}
|
||||||
|
{#if file?.meta?.size}
|
||||||
|
<span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>
|
||||||
|
{/if}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{#if knowledge?.write_access}
|
||||||
|
<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={() => {
|
on:click={() => {
|
||||||
if (file.status === 'uploading') {
|
onDelete(file?.id ?? file?.tempId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch('click', file.id);
|
|
||||||
}}
|
}}
|
||||||
on:dismiss={() => {
|
>
|
||||||
if (file.status === 'uploading') {
|
<XMark />
|
||||||
return;
|
</button>
|
||||||
}
|
</Tooltip>
|
||||||
|
</div>
|
||||||
dispatch('delete', file.id);
|
{/if}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,18 @@
|
||||||
let models = null;
|
let models = null;
|
||||||
let total = null;
|
let total = null;
|
||||||
|
|
||||||
|
let searchDebounceTimer;
|
||||||
|
|
||||||
$: if (
|
$: if (
|
||||||
page !== undefined &&
|
page !== undefined &&
|
||||||
query !== undefined &&
|
query !== undefined &&
|
||||||
selectedTag !== undefined &&
|
selectedTag !== undefined &&
|
||||||
viewOption !== undefined
|
viewOption !== undefined
|
||||||
) {
|
) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
getModelList();
|
getModelList();
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getModelList = async () => {
|
const getModelList = async () => {
|
||||||
|
|
@ -381,6 +386,7 @@
|
||||||
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder={$i18n.t('Search Models')}
|
placeholder={$i18n.t('Search Models')}
|
||||||
|
maxlength="500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if query}
|
{#if query}
|
||||||
|
|
@ -430,6 +436,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if models !== null}
|
||||||
{#if (models ?? []).length !== 0}
|
{#if (models ?? []).length !== 0}
|
||||||
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
|
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
|
||||||
{#each models as model (model.id)}
|
{#each models as model (model.id)}
|
||||||
|
|
@ -487,7 +494,9 @@
|
||||||
<div class="flex flex-row gap-0.5 items-center">
|
<div class="flex flex-row gap-0.5 items-center">
|
||||||
{#if shiftKey}
|
{#if shiftKey}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}
|
content={model?.meta?.hidden
|
||||||
|
? $i18n.t('Show')
|
||||||
|
: $i18n.t('Hide')}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
|
|
@ -639,6 +648,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $config?.features.enable_community_sharing}
|
{#if $config?.features.enable_community_sharing}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
import { config, knowledge, settings, user } from '$lib/stores';
|
import { config, knowledge, settings, user } from '$lib/stores';
|
||||||
|
|
||||||
import Selector from './Knowledge/Selector.svelte';
|
import KnowledgeSelector from './Knowledge/KnowledgeSelector.svelte';
|
||||||
import FileItem from '$lib/components/common/FileItem.svelte';
|
import FileItem from '$lib/components/common/FileItem.svelte';
|
||||||
|
|
||||||
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
||||||
|
|
@ -128,9 +128,6 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!$knowledge) {
|
|
||||||
knowledge.set(await getKnowledgeBases(localStorage.token));
|
|
||||||
}
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -190,8 +187,7 @@
|
||||||
|
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div class="flex flex-wrap flex-row text-sm gap-1">
|
<div class="flex flex-wrap flex-row text-sm gap-1">
|
||||||
<Selector
|
<KnowledgeSelector
|
||||||
knowledgeItems={$knowledge || []}
|
|
||||||
on:select={(e) => {
|
on:select={(e) => {
|
||||||
const item = e.detail;
|
const item = e.detail;
|
||||||
|
|
||||||
|
|
@ -210,7 +206,7 @@
|
||||||
>
|
>
|
||||||
{$i18n.t('Select Knowledge')}
|
{$i18n.t('Select Knowledge')}
|
||||||
</div>
|
</div>
|
||||||
</Selector>
|
</KnowledgeSelector>
|
||||||
|
|
||||||
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
|
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { DropdownMenu } from 'bits-ui';
|
||||||
|
import { onMount, getContext, createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import { searchNotes } from '$lib/apis/notes';
|
||||||
|
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
|
||||||
|
|
||||||
|
import { flyAndScale } from '$lib/utils/transitions';
|
||||||
|
import { decodeString } from '$lib/utils';
|
||||||
|
|
||||||
|
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import Database from '$lib/components/icons/Database.svelte';
|
||||||
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||||
|
import PageEdit from '$lib/components/icons/PageEdit.svelte';
|
||||||
|
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let onClose: Function = () => {};
|
||||||
|
|
||||||
|
let show = false;
|
||||||
|
|
||||||
|
let query = '';
|
||||||
|
|
||||||
|
let noteItems = [];
|
||||||
|
let knowledgeItems = [];
|
||||||
|
let fileItems = [];
|
||||||
|
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
|
||||||
|
|
||||||
|
$: if (query !== null) {
|
||||||
|
getItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItems = () => {
|
||||||
|
getNoteItems();
|
||||||
|
getKnowledgeItems();
|
||||||
|
getKnowledgeFileItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNoteItems = async () => {
|
||||||
|
const res = await searchNotes(localStorage.token, query).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
noteItems = res.items.map((note) => {
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
type: 'note',
|
||||||
|
name: note.title,
|
||||||
|
description: dayjs(note.updated_at / 1000000).fromNow()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKnowledgeItems = async () => {
|
||||||
|
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
knowledgeItems = res.items.map((note) => {
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
type: 'collection'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKnowledgeFileItems = async () => {
|
||||||
|
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
fileItems = res.items.map((file) => {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
type: 'file',
|
||||||
|
name: file.meta?.name || file.filename,
|
||||||
|
description: file.description || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
getItems();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
bind:show
|
||||||
|
on:change={(e) => {
|
||||||
|
if (e.detail === false) {
|
||||||
|
onClose();
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<div slot="content">
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class=" text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-70 p-1.5"
|
||||||
|
sideOffset={8}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
transition={flyAndScale}
|
||||||
|
>
|
||||||
|
<div class=" flex w-full space-x-2 px-2 pb-0.5">
|
||||||
|
<div class="flex flex-1">
|
||||||
|
<div class=" self-center mr-2">
|
||||||
|
<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')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-56 overflow-y-scroll gap-0.5 flex flex-col">
|
||||||
|
{#if items.length === 0}
|
||||||
|
<div class="text-center text-xs text-gray-500 dark:text-gray-400 pt-4 pb-6">
|
||||||
|
{$i18n.t('No knowledge found')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each items as item, i}
|
||||||
|
{#if i === 0 || item?.type !== items[i - 1]?.type}
|
||||||
|
<div class="px-2 text-xs text-gray-500 py-1">
|
||||||
|
{#if item?.type === 'note'}
|
||||||
|
{$i18n.t('Notes')}
|
||||||
|
{:else if item?.type === 'collection'}
|
||||||
|
{$i18n.t('Collections')}
|
||||||
|
{:else if item?.type === 'file'}
|
||||||
|
{$i18n.t('Files')}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100 selected-command-option-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex-1"
|
||||||
|
type="button"
|
||||||
|
on:click={() => {
|
||||||
|
dispatch('select', item);
|
||||||
|
show = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
|
||||||
|
{#if item.type === 'note'}
|
||||||
|
<Tooltip content={$i18n.t('Note')} placement="top">
|
||||||
|
<PageEdit className="size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
{:else if item.type === 'collection'}
|
||||||
|
<Tooltip content={$i18n.t('Collection')} placement="top">
|
||||||
|
<Database className="size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
{:else if item.type === 'file'}
|
||||||
|
<Tooltip content={$i18n.t('File')} placement="top">
|
||||||
|
<DocumentPage className="size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={item.description || decodeString(item?.name)}
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="line-clamp-1 flex-1 text-sm text-left">
|
||||||
|
{decodeString(item?.name)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
|
|
||||||
import { DropdownMenu } from 'bits-ui';
|
|
||||||
import { onMount, getContext, createEventDispatcher } from 'svelte';
|
|
||||||
import { flyAndScale } from '$lib/utils/transitions';
|
|
||||||
import { knowledge } from '$lib/stores';
|
|
||||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
|
||||||
import Search from '$lib/components/icons/Search.svelte';
|
|
||||||
import { getNoteList } from '$lib/apis/notes';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let onClose: Function = () => {};
|
|
||||||
|
|
||||||
export let knowledgeItems = [];
|
|
||||||
|
|
||||||
let query = '';
|
|
||||||
|
|
||||||
let items = [];
|
|
||||||
let filteredItems = [];
|
|
||||||
|
|
||||||
let fuse = null;
|
|
||||||
$: if (fuse) {
|
|
||||||
filteredItems = query
|
|
||||||
? fuse.search(query).map((e) => {
|
|
||||||
return e.item;
|
|
||||||
})
|
|
||||||
: items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodeString = (str: string) => {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(str);
|
|
||||||
} catch (e) {
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
let notes = await getNoteList(localStorage.token).catch(() => {
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
notes = notes.map((note) => {
|
|
||||||
return {
|
|
||||||
...note,
|
|
||||||
type: 'note',
|
|
||||||
name: note.title,
|
|
||||||
description: dayjs(note.updated_at / 1000000).fromNow()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => ({
|
|
||||||
...item,
|
|
||||||
type: 'collection'
|
|
||||||
}));
|
|
||||||
let collection_files =
|
|
||||||
knowledgeItems.length > 0
|
|
||||||
? [
|
|
||||||
...knowledgeItems
|
|
||||||
.reduce((a, item) => {
|
|
||||||
return [
|
|
||||||
...new Set([
|
|
||||||
...a,
|
|
||||||
...(item?.files ?? []).map((file) => ({
|
|
||||||
...file,
|
|
||||||
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
|
|
||||||
}))
|
|
||||||
])
|
|
||||||
];
|
|
||||||
}, [])
|
|
||||||
.map((file) => ({
|
|
||||||
...file,
|
|
||||||
name: file?.meta?.name,
|
|
||||||
description: `${file?.collection?.name} - ${file?.collection?.description}`,
|
|
||||||
type: 'file'
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
items = [...notes, ...collections, ...legacy_collections].map((item) => {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
fuse = new Fuse(items, {
|
|
||||||
keys: ['name', 'description']
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
on:change={(e) => {
|
|
||||||
if (e.detail === false) {
|
|
||||||
onClose();
|
|
||||||
query = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<div slot="content">
|
|
||||||
<DropdownMenu.Content
|
|
||||||
class="w-full max-w-96 rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-[99999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
|
||||||
sideOffset={8}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
transition={flyAndScale}
|
|
||||||
>
|
|
||||||
<div class=" flex w-full space-x-2 py-0.5 px-2 pb-2">
|
|
||||||
<div class="flex flex-1">
|
|
||||||
<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 Knowledge')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-56 overflow-y-scroll">
|
|
||||||
{#if filteredItems.length === 0}
|
|
||||||
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
|
|
||||||
{$i18n.t('No knowledge found')}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each filteredItems as item}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="flex gap-2.5 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
|
||||||
on:click={() => {
|
|
||||||
dispatch('select', item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
|
|
||||||
{#if item.legacy}
|
|
||||||
<div
|
|
||||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
|
|
||||||
>
|
|
||||||
Legacy
|
|
||||||
</div>
|
|
||||||
{:else if item?.meta?.document}
|
|
||||||
<div
|
|
||||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
|
|
||||||
>
|
|
||||||
Document
|
|
||||||
</div>
|
|
||||||
{:else if item?.type === 'file'}
|
|
||||||
<div
|
|
||||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
|
|
||||||
>
|
|
||||||
File
|
|
||||||
</div>
|
|
||||||
{:else if item?.type === 'note'}
|
|
||||||
<div
|
|
||||||
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
|
|
||||||
>
|
|
||||||
Note
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
|
|
||||||
>
|
|
||||||
Collection
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="line-clamp-1">
|
|
||||||
{decodeString(item?.name)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
|
|
||||||
{item?.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import { onMount, getContext, tick } from 'svelte';
|
import { onMount, getContext, tick } from 'svelte';
|
||||||
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores';
|
import { models, tools, functions, user } from '$lib/stores';
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
import { getTools } from '$lib/apis/tools';
|
import { getTools } from '$lib/apis/tools';
|
||||||
import { getFunctions } from '$lib/apis/functions';
|
import { getFunctions } from '$lib/apis/functions';
|
||||||
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
|
||||||
|
|
||||||
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
||||||
import Tags from '$lib/components/common/Tags.svelte';
|
import Tags from '$lib/components/common/Tags.svelte';
|
||||||
|
|
@ -223,7 +222,6 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await tools.set(await getTools(localStorage.token));
|
await tools.set(await getTools(localStorage.token));
|
||||||
await functions.set(await getFunctions(localStorage.token));
|
await functions.set(await getFunctions(localStorage.token));
|
||||||
await knowledgeCollections.set([...(await getKnowledgeBases(localStorage.token))]);
|
|
||||||
|
|
||||||
// Scroll to top 'workspace-container' element
|
// Scroll to top 'workspace-container' element
|
||||||
const workspaceContainer = document.getElementById('workspace-container');
|
const workspaceContainer = document.getElementById('workspace-container');
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@
|
||||||
"{{COUNT}} words": "{{COUNT}} ord",
|
"{{COUNT}} words": "{{COUNT}} ord",
|
||||||
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} klokken {{LOCALIZED_TIME}}",
|
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} klokken {{LOCALIZED_TIME}}",
|
||||||
"{{model}} download has been canceled": "Download af {{model}} er blevet annulleret",
|
"{{model}} download has been canceled": "Download af {{model}} er blevet annulleret",
|
||||||
"{{NAMES}} reacted with {{REACTION}}": "",
|
"{{NAMES}} reacted with {{REACTION}}": "{{NAMES}} reagerede med {{REACTION}}",
|
||||||
"{{user}}'s Chats": "{{user}}s chats",
|
"{{user}}'s Chats": "{{user}}s chats",
|
||||||
"{{webUIName}} Backend Required": "{{webUIName}} Backend kræves",
|
"{{webUIName}} Backend Required": "{{webUIName}} Backend kræves",
|
||||||
"*Prompt node ID(s) are required for image generation": "*Prompt node ID(s) er påkrævet for at kunne generere billeder",
|
"*Prompt node ID(s) are required for image generation": "*Prompt node ID(s) er påkrævet for at kunne generere billeder",
|
||||||
"1 Source": "1 kilde",
|
"1 Source": "1 kilde",
|
||||||
"A collaboration channel where people join as members": "",
|
"A collaboration channel where people join as members": "En samarbejdskanal hvor folk tilmelder sig som medlemmer",
|
||||||
"A discussion channel where access is controlled by groups and permissions": "",
|
"A discussion channel where access is controlled by groups and permissions": "En diskussionskanal hvor adgang styres af grupper og tilladelser",
|
||||||
"A new version (v{{LATEST_VERSION}}) is now available.": "En ny version (v{{LATEST_VERSION}}) er nu tilgængelig.",
|
"A new version (v{{LATEST_VERSION}}) is now available.": "En ny version (v{{LATEST_VERSION}}) er nu tilgængelig.",
|
||||||
"A private conversation between you and selected users": "",
|
"A private conversation between you and selected users": "En privat samtale mellem dig og udvalgte brugere",
|
||||||
"A task model is used when performing tasks such as generating titles for chats and web search queries": "En 'task model' bliver brugt til at opgaver såsom at generere overskrifter til chats eller internetsøgninger",
|
"A task model is used when performing tasks such as generating titles for chats and web search queries": "En 'task model' bliver brugt til at opgaver såsom at generere overskrifter til chats eller internetsøgninger",
|
||||||
"a user": "en bruger",
|
"a user": "en bruger",
|
||||||
"About": "Information",
|
"About": "Information",
|
||||||
|
|
@ -57,8 +57,8 @@
|
||||||
"Add Custom Prompt": "Tilføj brugerdefineret prompt",
|
"Add Custom Prompt": "Tilføj brugerdefineret prompt",
|
||||||
"Add Details": "Tilføj detaljer",
|
"Add Details": "Tilføj detaljer",
|
||||||
"Add Files": "Tilføj filer",
|
"Add Files": "Tilføj filer",
|
||||||
"Add Member": "",
|
"Add Member": "Tilføj medlem",
|
||||||
"Add Members": "",
|
"Add Members": "Tilføj medlemmer",
|
||||||
"Add Memory": "Tilføj hukommelse",
|
"Add Memory": "Tilføj hukommelse",
|
||||||
"Add Model": "Tilføj model",
|
"Add Model": "Tilføj model",
|
||||||
"Add Reaction": "Tilføj reaktion",
|
"Add Reaction": "Tilføj reaktion",
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"Additional Config": "Yderligere konfiguration",
|
"Additional Config": "Yderligere konfiguration",
|
||||||
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "Yderligere konfigurationsmuligheder for Marker. Dette skal være en JSON streng med key-value pairs. For eksempel, '{\"key\": \"value\"}'. Tilladte keys omfatter: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level",
|
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "Yderligere konfigurationsmuligheder for Marker. Dette skal være en JSON streng med key-value pairs. For eksempel, '{\"key\": \"value\"}'. Tilladte keys omfatter: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level",
|
||||||
"Additional Parameters": "Yderligere parametre",
|
"Additional Parameters": "Yderligere parametre",
|
||||||
"Adds filenames, titles, sections, and snippets into the BM25 text to improve lexical recall.": "",
|
"Adds filenames, titles, sections, and snippets into the BM25 text to improve lexical recall.": "Tilføjer filnavne, titler, afsnit og uddrag til BM25-teksten for at forbedre leksikalsk genkaldelse.",
|
||||||
"Adjusting these settings will apply changes universally to all users.": "Ændringer af disse indstillinger har konsekvenser for alle brugere.",
|
"Adjusting these settings will apply changes universally to all users.": "Ændringer af disse indstillinger har konsekvenser for alle brugere.",
|
||||||
"admin": "administrator",
|
"admin": "administrator",
|
||||||
"Admin": "Administrator",
|
"Admin": "Administrator",
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
"Allow Continue Response": "Tillad fortsættelse af svar",
|
"Allow Continue Response": "Tillad fortsættelse af svar",
|
||||||
"Allow Delete Messages": "Tillad sletning af beskeder",
|
"Allow Delete Messages": "Tillad sletning af beskeder",
|
||||||
"Allow File Upload": "Tillad upload af fil",
|
"Allow File Upload": "Tillad upload af fil",
|
||||||
"Allow Group Sharing": "",
|
"Allow Group Sharing": "Tillad gruppedeling",
|
||||||
"Allow Multiple Models in Chat": "Tillad flere modeller i chats",
|
"Allow Multiple Models in Chat": "Tillad flere modeller i chats",
|
||||||
"Allow non-local voices": "Tillad ikke-lokale stemmer",
|
"Allow non-local voices": "Tillad ikke-lokale stemmer",
|
||||||
"Allow Rate Response": "Tillad vurdering af svar",
|
"Allow Rate Response": "Tillad vurdering af svar",
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
"API Key created.": "API nøgle lavet",
|
"API Key created.": "API nøgle lavet",
|
||||||
"API Key Endpoint Restrictions": "API nøgler endpoint forbehold",
|
"API Key Endpoint Restrictions": "API nøgler endpoint forbehold",
|
||||||
"API keys": "API nøgler",
|
"API keys": "API nøgler",
|
||||||
"API Keys": "",
|
"API Keys": "API nøgler",
|
||||||
"API Mode": "API tilstand",
|
"API Mode": "API tilstand",
|
||||||
"API Version": "API Version",
|
"API Version": "API Version",
|
||||||
"API Version is required": "API version er påkrævet",
|
"API Version is required": "API version er påkrævet",
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
"Archived Chats": "Arkiverede chats",
|
"Archived Chats": "Arkiverede chats",
|
||||||
"archived-chat-export": "arkiveret-chat-eksport",
|
"archived-chat-export": "arkiveret-chat-eksport",
|
||||||
"Are you sure you want to clear all memories? This action cannot be undone.": "Er du sikker på du vil rydde hele hukommelsen? Dette kan ikke gøres om.",
|
"Are you sure you want to clear all memories? This action cannot be undone.": "Er du sikker på du vil rydde hele hukommelsen? Dette kan ikke gøres om.",
|
||||||
"Are you sure you want to delete \"{{NAME}}\"?": "",
|
"Are you sure you want to delete \"{{NAME}}\"?": "Er du sikker på at du vil slette \"{{NAME}}\"?",
|
||||||
"Are you sure you want to delete this channel?": "Er du sikker på du vil slette denne kanal?",
|
"Are you sure you want to delete this channel?": "Er du sikker på du vil slette denne kanal?",
|
||||||
"Are you sure you want to delete this message?": "Er du sikker på du vil slette denne besked?",
|
"Are you sure you want to delete this message?": "Er du sikker på du vil slette denne besked?",
|
||||||
"Are you sure you want to unarchive all archived chats?": "Er du sikker på du vil fjerne alle arkiverede chats?",
|
"Are you sure you want to unarchive all archived chats?": "Er du sikker på du vil fjerne alle arkiverede chats?",
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
"Ask": "Spørg",
|
"Ask": "Spørg",
|
||||||
"Ask a question": "Stil et spørgsmål",
|
"Ask a question": "Stil et spørgsmål",
|
||||||
"Assistant": "Assistent",
|
"Assistant": "Assistent",
|
||||||
"Async Embedding Processing": "",
|
"Async Embedding Processing": "Asynkron embedding processering",
|
||||||
"Attach File From Knowledge": "Vedhæft fil fra viden",
|
"Attach File From Knowledge": "Vedhæft fil fra viden",
|
||||||
"Attach Knowledge": "Vedhæft viden",
|
"Attach Knowledge": "Vedhæft viden",
|
||||||
"Attach Notes": "Vedhæft noter",
|
"Attach Notes": "Vedhæft noter",
|
||||||
|
|
@ -228,7 +228,7 @@
|
||||||
"Channel deleted successfully": "Kanal slettet",
|
"Channel deleted successfully": "Kanal slettet",
|
||||||
"Channel Name": "Kanalnavn",
|
"Channel Name": "Kanalnavn",
|
||||||
"Channel name cannot be empty.": "Kanalnavn må ikke være tom.",
|
"Channel name cannot be empty.": "Kanalnavn må ikke være tom.",
|
||||||
"Channel Type": "",
|
"Channel Type": "Kanaltype",
|
||||||
"Channel updated successfully": "Kanal redigeret",
|
"Channel updated successfully": "Kanal redigeret",
|
||||||
"Channels": "Kanaler",
|
"Channels": "Kanaler",
|
||||||
"Character": "Karakterer",
|
"Character": "Karakterer",
|
||||||
|
|
@ -257,7 +257,7 @@
|
||||||
"Citations": "Citater",
|
"Citations": "Citater",
|
||||||
"Clear memory": "Slet hukommelse",
|
"Clear memory": "Slet hukommelse",
|
||||||
"Clear Memory": "Slet hukommelse",
|
"Clear Memory": "Slet hukommelse",
|
||||||
"Clear status": "",
|
"Clear status": "Slet status",
|
||||||
"click here": "klik her",
|
"click here": "klik her",
|
||||||
"Click here for filter guides.": "Klik her for filter guider",
|
"Click here for filter guides.": "Klik her for filter guider",
|
||||||
"Click here for help.": "Klik her for hjælp",
|
"Click here for help.": "Klik her for hjælp",
|
||||||
|
|
@ -294,7 +294,7 @@
|
||||||
"Code Interpreter": "Kode interpreter",
|
"Code Interpreter": "Kode interpreter",
|
||||||
"Code Interpreter Engine": "Kode interpreter engine",
|
"Code Interpreter Engine": "Kode interpreter engine",
|
||||||
"Code Interpreter Prompt Template": "Kode interpreter prompt template",
|
"Code Interpreter Prompt Template": "Kode interpreter prompt template",
|
||||||
"Collaboration channel where people join as members": "",
|
"Collaboration channel where people join as members": "Samarbejdskanal hvor folk tilmelder sig som medlemmer",
|
||||||
"Collapse": "Kollapse",
|
"Collapse": "Kollapse",
|
||||||
"Collection": "Samling",
|
"Collection": "Samling",
|
||||||
"Color": "Farve",
|
"Color": "Farve",
|
||||||
|
|
@ -386,7 +386,7 @@
|
||||||
"Datalab Marker API": "Datalab Marker API",
|
"Datalab Marker API": "Datalab Marker API",
|
||||||
"DD/MM/YYYY": "DD/MM/ÅÅÅÅ",
|
"DD/MM/YYYY": "DD/MM/ÅÅÅÅ",
|
||||||
"December": "december",
|
"December": "december",
|
||||||
"Decrease UI Scale": "",
|
"Decrease UI Scale": "Nedjuster UI-skalering",
|
||||||
"Deepgram": "Deepgram",
|
"Deepgram": "Deepgram",
|
||||||
"Default": "Standard",
|
"Default": "Standard",
|
||||||
"Default (Open AI)": "Standard (Open AI)",
|
"Default (Open AI)": "Standard (Open AI)",
|
||||||
|
|
@ -395,14 +395,14 @@
|
||||||
"Default description enabled": "Standardbeskrivelse aktiveret",
|
"Default description enabled": "Standardbeskrivelse aktiveret",
|
||||||
"Default Features": "Standardfunktioner",
|
"Default Features": "Standardfunktioner",
|
||||||
"Default Filters": "Standardfiltre",
|
"Default Filters": "Standardfiltre",
|
||||||
"Default Group": "",
|
"Default Group": "Standardgruppe",
|
||||||
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "Standardtilstand fungerer med et bredere udvalg af modeller ved at kalde værktøjer én gang før udførelse. Native tilstand udnytter modellens indbyggede værktøjskald-funktioner, men kræver at modellen i sagens natur understøtter denne funktion.",
|
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "Standardtilstand fungerer med et bredere udvalg af modeller ved at kalde værktøjer én gang før udførelse. Native tilstand udnytter modellens indbyggede værktøjskald-funktioner, men kræver at modellen i sagens natur understøtter denne funktion.",
|
||||||
"Default Model": "Standard model",
|
"Default Model": "Standard model",
|
||||||
"Default model updated": "Standard model opdateret",
|
"Default model updated": "Standard model opdateret",
|
||||||
"Default Models": "Standard modeller",
|
"Default Models": "Standard modeller",
|
||||||
"Default permissions": "Standard tilladelser",
|
"Default permissions": "Standard tilladelser",
|
||||||
"Default permissions updated successfully": "Standard tilladelser opdateret",
|
"Default permissions updated successfully": "Standard tilladelser opdateret",
|
||||||
"Default Pinned Models": "",
|
"Default Pinned Models": "Standard fastgjorte modeller",
|
||||||
"Default Prompt Suggestions": "Standardforslag til prompt",
|
"Default Prompt Suggestions": "Standardforslag til prompt",
|
||||||
"Default to 389 or 636 if TLS is enabled": "Standard til 389 eller 636 hvis TLS er aktiveret",
|
"Default to 389 or 636 if TLS is enabled": "Standard til 389 eller 636 hvis TLS er aktiveret",
|
||||||
"Default to ALL": "Standard til ALLE",
|
"Default to ALL": "Standard til ALLE",
|
||||||
|
|
@ -411,7 +411,7 @@
|
||||||
"Delete": "Slet",
|
"Delete": "Slet",
|
||||||
"Delete a model": "Slet en model",
|
"Delete a model": "Slet en model",
|
||||||
"Delete All Chats": "Slet alle chats",
|
"Delete All Chats": "Slet alle chats",
|
||||||
"Delete all contents inside this folder": "",
|
"Delete all contents inside this folder": "Slet alt indhold i denne mappe",
|
||||||
"Delete All Models": "Slet alle modeller",
|
"Delete All Models": "Slet alle modeller",
|
||||||
"Delete Chat": "Slet chat",
|
"Delete Chat": "Slet chat",
|
||||||
"Delete chat?": "Slet chat?",
|
"Delete chat?": "Slet chat?",
|
||||||
|
|
@ -437,7 +437,7 @@
|
||||||
"Direct": "Direkte",
|
"Direct": "Direkte",
|
||||||
"Direct Connections": "Direkte forbindelser",
|
"Direct Connections": "Direkte forbindelser",
|
||||||
"Direct Connections allow users to connect to their own OpenAI compatible API endpoints.": "Direkte forbindelser tillader brugere at oprette forbindelse til deres egen OpenAI kompatible API endpoints.",
|
"Direct Connections allow users to connect to their own OpenAI compatible API endpoints.": "Direkte forbindelser tillader brugere at oprette forbindelse til deres egen OpenAI kompatible API endpoints.",
|
||||||
"Direct Message": "",
|
"Direct Message": "Direkte besked",
|
||||||
"Direct Tool Servers": "Direkte værktøjsservere",
|
"Direct Tool Servers": "Direkte værktøjsservere",
|
||||||
"Directory selection was cancelled": "Valg af mappe annulleret",
|
"Directory selection was cancelled": "Valg af mappe annulleret",
|
||||||
"Disable Code Interpreter": "Deaktiver kode interpreter",
|
"Disable Code Interpreter": "Deaktiver kode interpreter",
|
||||||
|
|
@ -454,7 +454,7 @@
|
||||||
"Discover, download, and explore custom prompts": "Find, download og udforsk unikke prompts",
|
"Discover, download, and explore custom prompts": "Find, download og udforsk unikke prompts",
|
||||||
"Discover, download, and explore custom tools": "Find, download og udforsk unikke værktøjer",
|
"Discover, download, and explore custom tools": "Find, download og udforsk unikke værktøjer",
|
||||||
"Discover, download, and explore model presets": "Find, download og udforsk modelindstillinger",
|
"Discover, download, and explore model presets": "Find, download og udforsk modelindstillinger",
|
||||||
"Discussion channel where access is based on groups and permissions": "",
|
"Discussion channel where access is based on groups and permissions": "Diskussionskanal hvor adgang styres af grupper og tilladelser",
|
||||||
"Display": "Vis",
|
"Display": "Vis",
|
||||||
"Display chat title in tab": "Vis chattitel i fane",
|
"Display chat title in tab": "Vis chattitel i fane",
|
||||||
"Display Emoji in Call": "Vis emoji i chat",
|
"Display Emoji in Call": "Vis emoji i chat",
|
||||||
|
|
@ -466,12 +466,12 @@
|
||||||
"Do not install functions from sources you do not fully trust.": "Lad være med at installere funktioner fra kilder, som du ikke stoler på.",
|
"Do not install functions from sources you do not fully trust.": "Lad være med at installere funktioner fra kilder, som du ikke stoler på.",
|
||||||
"Do not install tools from sources you do not fully trust.": "Lad være med at installere værktøjer fra kilder, som du ikke stoler på.",
|
"Do not install tools from sources you do not fully trust.": "Lad være med at installere værktøjer fra kilder, som du ikke stoler på.",
|
||||||
"Docling": "Docling",
|
"Docling": "Docling",
|
||||||
"Docling Parameters": "",
|
"Docling Parameters": "Docling parametre",
|
||||||
"Docling Server URL required.": "Docling Server URL påkrævet.",
|
"Docling Server URL required.": "Docling Server URL påkrævet.",
|
||||||
"Document": "Dokument",
|
"Document": "Dokument",
|
||||||
"Document Intelligence": "Document Intelligence",
|
"Document Intelligence": "Document Intelligence",
|
||||||
"Document Intelligence endpoint required.": "Document Intelligence endpoint påkrævet",
|
"Document Intelligence endpoint required.": "Document Intelligence endpoint påkrævet",
|
||||||
"Document Intelligence Model": "",
|
"Document Intelligence Model": "Document Intelligence model",
|
||||||
"Documentation": "Dokumentation",
|
"Documentation": "Dokumentation",
|
||||||
"Documents": "Dokumenter",
|
"Documents": "Dokumenter",
|
||||||
"does not make any external connections, and your data stays securely on your locally hosted server.": "laver ikke eksterne kald, og din data bliver sikkert på din egen lokalt hostede server.",
|
"does not make any external connections, and your data stays securely on your locally hosted server.": "laver ikke eksterne kald, og din data bliver sikkert på din egen lokalt hostede server.",
|
||||||
|
|
@ -494,15 +494,15 @@
|
||||||
"e.g. \"json\" or a JSON schema": "f.eks. \"json\" eller en JSON schema",
|
"e.g. \"json\" or a JSON schema": "f.eks. \"json\" eller en JSON schema",
|
||||||
"e.g. 60": "f.eks. 60",
|
"e.g. 60": "f.eks. 60",
|
||||||
"e.g. A filter to remove profanity from text": "f.eks. Et filter til at fjerne upassende ord fra tekst",
|
"e.g. A filter to remove profanity from text": "f.eks. Et filter til at fjerne upassende ord fra tekst",
|
||||||
"e.g. about the Roman Empire": "",
|
"e.g. about the Roman Empire": "f.eks. om Romerriget",
|
||||||
"e.g. en": "f.eks. en",
|
"e.g. en": "f.eks. en",
|
||||||
"e.g. My Filter": "f.eks. Mit Filter",
|
"e.g. My Filter": "f.eks. Mit Filter",
|
||||||
"e.g. My Tools": "f.eks. Mine Værktøjer",
|
"e.g. My Tools": "f.eks. Mine Værktøjer",
|
||||||
"e.g. my_filter": "f.eks. mit_filter",
|
"e.g. my_filter": "f.eks. mit_filter",
|
||||||
"e.g. my_tools": "f.eks. mine_værktøjer",
|
"e.g. my_tools": "f.eks. mine_værktøjer",
|
||||||
"e.g. pdf, docx, txt": "f.eks. pdf, docx, txt",
|
"e.g. pdf, docx, txt": "f.eks. pdf, docx, txt",
|
||||||
"e.g. Tell me a fun fact": "",
|
"e.g. Tell me a fun fact": "f.eks. fortæl mig en fun fact",
|
||||||
"e.g. Tell me a fun fact about the Roman Empire": "",
|
"e.g. Tell me a fun fact about the Roman Empire": "f.eks. fortæl mig en fun fact om Romerriget",
|
||||||
"e.g. Tools for performing various operations": "f.eks. Værktøjer til at udføre forskellige operationer",
|
"e.g. Tools for performing various operations": "f.eks. Værktøjer til at udføre forskellige operationer",
|
||||||
"e.g., 3, 4, 5 (leave blank for default)": "f.eks. 3, 4, 5 (lad være tom for standard)",
|
"e.g., 3, 4, 5 (leave blank for default)": "f.eks. 3, 4, 5 (lad være tom for standard)",
|
||||||
"e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)": "f.eks. audio/wav,audio/mpeg,video/* (lad være tom for standarder)",
|
"e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)": "f.eks. audio/wav,audio/mpeg,video/* (lad være tom for standarder)",
|
||||||
|
|
@ -531,7 +531,7 @@
|
||||||
"Embedding Batch Size": "Embedding Batch størrelse",
|
"Embedding Batch Size": "Embedding Batch størrelse",
|
||||||
"Embedding Model": "Embedding Model",
|
"Embedding Model": "Embedding Model",
|
||||||
"Embedding Model Engine": "Embedding Model engine",
|
"Embedding Model Engine": "Embedding Model engine",
|
||||||
"Enable API Keys": "",
|
"Enable API Keys": "Aktiver API nøgler",
|
||||||
"Enable autocomplete generation for chat messages": "Aktiver autofuldførsel for chatbeskeder",
|
"Enable autocomplete generation for chat messages": "Aktiver autofuldførsel for chatbeskeder",
|
||||||
"Enable Code Execution": "Aktiver kodekørsel",
|
"Enable Code Execution": "Aktiver kodekørsel",
|
||||||
"Enable Code Interpreter": "Aktiver kode interpreter",
|
"Enable Code Interpreter": "Aktiver kode interpreter",
|
||||||
|
|
@ -547,14 +547,14 @@
|
||||||
"Endpoint URL": "Endpoint URL",
|
"Endpoint URL": "Endpoint URL",
|
||||||
"Enforce Temporary Chat": "Gennemtving midlertidig chat",
|
"Enforce Temporary Chat": "Gennemtving midlertidig chat",
|
||||||
"Enhance": "Forbedre",
|
"Enhance": "Forbedre",
|
||||||
"Enrich Hybrid Search Text": "",
|
"Enrich Hybrid Search Text": "Berig hybrid søgetekst",
|
||||||
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "Sørg for at din CSV-fil indeholder 4 kolonner i denne rækkefølge: Name, Email, Password, Role.",
|
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "Sørg for at din CSV-fil indeholder 4 kolonner i denne rækkefølge: Name, Email, Password, Role.",
|
||||||
"Enter {{role}} message here": "Indtast {{role}} besked her",
|
"Enter {{role}} message here": "Indtast {{role}} besked her",
|
||||||
"Enter a detail about yourself for your LLMs to recall": "Indtast en detalje om dig selv, som dine LLMs kan huske",
|
"Enter a detail about yourself for your LLMs to recall": "Indtast en detalje om dig selv, som dine LLMs kan huske",
|
||||||
"Enter a title for the pending user info overlay. Leave empty for default.": "Indtast en titel til afventende bruger informations overlay. Lad være tom for standard.",
|
"Enter a title for the pending user info overlay. Leave empty for default.": "Indtast en titel til afventende bruger informations overlay. Lad være tom for standard.",
|
||||||
"Enter a watermark for the response. Leave empty for none.": "Indtast et vandmærke til svaret. Lad være tom for ingen.",
|
"Enter a watermark for the response. Leave empty for none.": "Indtast et vandmærke til svaret. Lad være tom for ingen.",
|
||||||
"Enter additional headers in JSON format": "Indtast yderligere headers i JSON format",
|
"Enter additional headers in JSON format": "Indtast yderligere headers i JSON format",
|
||||||
"Enter additional headers in JSON format (e.g. {\"X-Custom-Header\": \"value\"}": "",
|
"Enter additional headers in JSON format (e.g. {\"X-Custom-Header\": \"value\"}": "Indtast yderligere headers i JSON format (f.eks. {\"X-Custom-Header\": \"value\"})",
|
||||||
"Enter additional parameters in JSON format": "Indtast yderligere parametre i JSON format",
|
"Enter additional parameters in JSON format": "Indtast yderligere parametre i JSON format",
|
||||||
"Enter api auth string (e.g. username:password)": "Indtast api-godkendelsesstreng (f.eks. brugernavn:adgangskode)",
|
"Enter api auth string (e.g. username:password)": "Indtast api-godkendelsesstreng (f.eks. brugernavn:adgangskode)",
|
||||||
"Enter Application DN": "Indtast Application DN",
|
"Enter Application DN": "Indtast Application DN",
|
||||||
|
|
@ -572,12 +572,12 @@
|
||||||
"Enter Datalab Marker API Base URL": "Indtast Datalab Marker API base URL",
|
"Enter Datalab Marker API Base URL": "Indtast Datalab Marker API base URL",
|
||||||
"Enter Datalab Marker API Key": "Indtast Datalab Marker API nøgle",
|
"Enter Datalab Marker API Key": "Indtast Datalab Marker API nøgle",
|
||||||
"Enter description": "Indtast beskrivelse",
|
"Enter description": "Indtast beskrivelse",
|
||||||
"Enter Docling API Key": "",
|
"Enter Docling API Key": "Indtast Docling API nøgler",
|
||||||
"Enter Docling Server URL": "Indtast Docling Server URL",
|
"Enter Docling Server URL": "Indtast Docling Server URL",
|
||||||
"Enter Document Intelligence Endpoint": "Indtast Dokument Intelligence Endpoint",
|
"Enter Document Intelligence Endpoint": "Indtast Dokument Intelligence Endpoint",
|
||||||
"Enter Document Intelligence Key": "Indtast Dokument Intelligence nøgle",
|
"Enter Document Intelligence Key": "Indtast Dokument Intelligence nøgle",
|
||||||
"Enter Document Intelligence Model": "",
|
"Enter Document Intelligence Model": "Indtast Dokument Intelligence model",
|
||||||
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "",
|
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "Indtast domæner separeret af kommaer (f.eks. example.com,site.org,!excludedsite.com)",
|
||||||
"Enter Exa API Key": "Indtast Exa API nøgle",
|
"Enter Exa API Key": "Indtast Exa API nøgle",
|
||||||
"Enter External Document Loader API Key": "Indtast External Dokument Loader API nøgle",
|
"Enter External Document Loader API Key": "Indtast External Dokument Loader API nøgle",
|
||||||
"Enter External Document Loader URL": "Indtast External Dokument Loader URL",
|
"Enter External Document Loader URL": "Indtast External Dokument Loader URL",
|
||||||
|
|
@ -588,7 +588,7 @@
|
||||||
"Enter Firecrawl API Base URL": "Indtast Firecrawl API base URL",
|
"Enter Firecrawl API Base URL": "Indtast Firecrawl API base URL",
|
||||||
"Enter Firecrawl API Key": "Indtast Firecrawl API nøgle",
|
"Enter Firecrawl API Key": "Indtast Firecrawl API nøgle",
|
||||||
"Enter folder name": "Indtast mappenavn",
|
"Enter folder name": "Indtast mappenavn",
|
||||||
"Enter function name filter list (e.g. func1, !func2)": "",
|
"Enter function name filter list (e.g. func1, !func2)": "Indtast funktionsnavn filterliste (f.eks. func1, !func2)",
|
||||||
"Enter Github Raw URL": "Indtast Github Raw URL",
|
"Enter Github Raw URL": "Indtast Github Raw URL",
|
||||||
"Enter Google PSE API Key": "Indtast Google PSE API-nøgle",
|
"Enter Google PSE API Key": "Indtast Google PSE API-nøgle",
|
||||||
"Enter Google PSE Engine Id": "Indtast Google PSE Engine ID",
|
"Enter Google PSE Engine Id": "Indtast Google PSE Engine ID",
|
||||||
|
|
@ -614,7 +614,7 @@
|
||||||
"Enter Number of Steps (e.g. 50)": "Indtast antal trin (f.eks. 50)",
|
"Enter Number of Steps (e.g. 50)": "Indtast antal trin (f.eks. 50)",
|
||||||
"Enter Ollama Cloud API Key": "Indtast Ollama Cloud API nøgle",
|
"Enter Ollama Cloud API Key": "Indtast Ollama Cloud API nøgle",
|
||||||
"Enter Perplexity API Key": "Indtast Perplexity API nøgle",
|
"Enter Perplexity API Key": "Indtast Perplexity API nøgle",
|
||||||
"Enter Perplexity Search API URL": "",
|
"Enter Perplexity Search API URL": "Indtast Perplexity Search API URL",
|
||||||
"Enter Playwright Timeout": "Indtast Playwright timeout",
|
"Enter Playwright Timeout": "Indtast Playwright timeout",
|
||||||
"Enter Playwright WebSocket URL": "Indtast Playwright WebSocket URL",
|
"Enter Playwright WebSocket URL": "Indtast Playwright WebSocket URL",
|
||||||
"Enter proxy URL (e.g. https://user:password@host:port)": "Indtast proxy URL (f.eks. https://bruger:adgangskode@host:port)",
|
"Enter proxy URL (e.g. https://user:password@host:port)": "Indtast proxy URL (f.eks. https://bruger:adgangskode@host:port)",
|
||||||
|
|
@ -700,11 +700,11 @@
|
||||||
"Export chat (.json)": "Eksportér chat (.json)",
|
"Export chat (.json)": "Eksportér chat (.json)",
|
||||||
"Export Chats": "Eksportér chats",
|
"Export Chats": "Eksportér chats",
|
||||||
"Export Config to JSON File": "Eksportér konfiguration til JSON-fil",
|
"Export Config to JSON File": "Eksportér konfiguration til JSON-fil",
|
||||||
"Export Models": "",
|
"Export Models": "Eksportér modeller",
|
||||||
"Export Presets": "Eksportér indstillinger",
|
"Export Presets": "Eksportér indstillinger",
|
||||||
"Export Prompts": "",
|
"Export Prompts": "Eksportér prompter",
|
||||||
"Export to CSV": "Eksportér til CSV",
|
"Export to CSV": "Eksportér til CSV",
|
||||||
"Export Tools": "",
|
"Export Tools": "Eksportér værktøjer",
|
||||||
"Export Users": "Eksportér brugere",
|
"Export Users": "Eksportér brugere",
|
||||||
"External": "Ekstern",
|
"External": "Ekstern",
|
||||||
"External Document Loader URL required.": "External Dokument Loader URL påkrævet.",
|
"External Document Loader URL required.": "External Dokument Loader URL påkrævet.",
|
||||||
|
|
@ -716,8 +716,8 @@
|
||||||
"External Web Search URL": "Ekstern Web Search URL",
|
"External Web Search URL": "Ekstern Web Search URL",
|
||||||
"Fade Effect for Streaming Text": "Fade-effekt for streaming tekst",
|
"Fade Effect for Streaming Text": "Fade-effekt for streaming tekst",
|
||||||
"Failed to add file.": "Kunne ikke tilføje fil.",
|
"Failed to add file.": "Kunne ikke tilføje fil.",
|
||||||
"Failed to add members": "",
|
"Failed to add members": "Kunne ikke tilføje medlemmer",
|
||||||
"Failed to clear status": "",
|
"Failed to clear status": "Kunne ikke fjerne status",
|
||||||
"Failed to connect to {{URL}} OpenAPI tool server": "Kunne ikke forbinde til {{URL}} OpenAPI tool server",
|
"Failed to connect to {{URL}} OpenAPI tool server": "Kunne ikke forbinde til {{URL}} OpenAPI tool server",
|
||||||
"Failed to copy link": "Kunne ikke kopiere link",
|
"Failed to copy link": "Kunne ikke kopiere link",
|
||||||
"Failed to create API Key.": "Kunne ikke oprette API-nøgle.",
|
"Failed to create API Key.": "Kunne ikke oprette API-nøgle.",
|
||||||
|
|
@ -731,19 +731,19 @@
|
||||||
"Failed to load file content.": "Kunne ikke indlæse filindhold.",
|
"Failed to load file content.": "Kunne ikke indlæse filindhold.",
|
||||||
"Failed to move chat": "Kunne ikke flytte chat",
|
"Failed to move chat": "Kunne ikke flytte chat",
|
||||||
"Failed to read clipboard contents": "Kunne ikke læse indholdet af udklipsholderen",
|
"Failed to read clipboard contents": "Kunne ikke læse indholdet af udklipsholderen",
|
||||||
"Failed to remove member": "",
|
"Failed to remove member": "Kunne ikke fjerne medlem",
|
||||||
"Failed to render diagram": "Kunne ikke rendere diagram",
|
"Failed to render diagram": "Kunne ikke rendere diagram",
|
||||||
"Failed to render visualization": "Kunne ikke rendere visualisering",
|
"Failed to render visualization": "Kunne ikke rendere visualisering",
|
||||||
"Failed to save connections": "Kunne ikke gemme forbindelser",
|
"Failed to save connections": "Kunne ikke gemme forbindelser",
|
||||||
"Failed to save conversation": "Kunne ikke gemme samtalen",
|
"Failed to save conversation": "Kunne ikke gemme samtalen",
|
||||||
"Failed to save models configuration": "Kunne ikke gemme modeller konfiguration",
|
"Failed to save models configuration": "Kunne ikke gemme modeller konfiguration",
|
||||||
"Failed to update settings": "Kunne ikke opdatere indstillinger",
|
"Failed to update settings": "Kunne ikke opdatere indstillinger",
|
||||||
"Failed to update status": "",
|
"Failed to update status": "Kunne ikke opdatere status",
|
||||||
"Failed to upload file.": "Kunne ikke uploade fil.",
|
"Failed to upload file.": "Kunne ikke uploade fil.",
|
||||||
"Features": "Features",
|
"Features": "Features",
|
||||||
"Features Permissions": "Features tilladelser",
|
"Features Permissions": "Features tilladelser",
|
||||||
"February": "Februar",
|
"February": "Februar",
|
||||||
"Feedback deleted successfully": "",
|
"Feedback deleted successfully": "Feedback slettet",
|
||||||
"Feedback Details": "Feedback detaljer",
|
"Feedback Details": "Feedback detaljer",
|
||||||
"Feedback History": "Feedback historik",
|
"Feedback History": "Feedback historik",
|
||||||
"Feedbacks": "Feedback",
|
"Feedbacks": "Feedback",
|
||||||
|
|
@ -758,7 +758,7 @@
|
||||||
"File size should not exceed {{maxSize}} MB.": "Filstørrelsen må ikke overstige {{maxSize}} MB.",
|
"File size should not exceed {{maxSize}} MB.": "Filstørrelsen må ikke overstige {{maxSize}} MB.",
|
||||||
"File Upload": "Fil upload",
|
"File Upload": "Fil upload",
|
||||||
"File uploaded successfully": "Fil uploadet.",
|
"File uploaded successfully": "Fil uploadet.",
|
||||||
"File uploaded!": "",
|
"File uploaded!": "Fil uploadet!",
|
||||||
"Files": "Filer",
|
"Files": "Filer",
|
||||||
"Filter": "Filter",
|
"Filter": "Filter",
|
||||||
"Filter is now globally disabled": "Filter er nu globalt deaktiveret",
|
"Filter is now globally disabled": "Filter er nu globalt deaktiveret",
|
||||||
|
|
@ -803,7 +803,7 @@
|
||||||
"Function is now globally disabled": "Funktionen er nu globalt deaktiveret",
|
"Function is now globally disabled": "Funktionen er nu globalt deaktiveret",
|
||||||
"Function is now globally enabled": "Funktionen er nu globalt aktiveret",
|
"Function is now globally enabled": "Funktionen er nu globalt aktiveret",
|
||||||
"Function Name": "Funktionsnavn",
|
"Function Name": "Funktionsnavn",
|
||||||
"Function Name Filter List": "",
|
"Function Name Filter List": "Funktionsnavn filterliste",
|
||||||
"Function updated successfully": "Funktion opdateret.",
|
"Function updated successfully": "Funktion opdateret.",
|
||||||
"Functions": "Funktioner",
|
"Functions": "Funktioner",
|
||||||
"Functions allow arbitrary code execution.": "Funktioner tillader kørsel af vilkårlig kode.",
|
"Functions allow arbitrary code execution.": "Funktioner tillader kørsel af vilkårlig kode.",
|
||||||
|
|
@ -832,13 +832,13 @@
|
||||||
"Google PSE Engine Id": "Google PSE Engine-ID",
|
"Google PSE Engine Id": "Google PSE Engine-ID",
|
||||||
"Gravatar": "Gravatar",
|
"Gravatar": "Gravatar",
|
||||||
"Group": "Gruppe",
|
"Group": "Gruppe",
|
||||||
"Group Channel": "",
|
"Group Channel": "Gruppekanal",
|
||||||
"Group created successfully": "Gruppe oprettet.",
|
"Group created successfully": "Gruppe oprettet.",
|
||||||
"Group deleted successfully": "Gruppe slettet.",
|
"Group deleted successfully": "Gruppe slettet.",
|
||||||
"Group Description": "Gruppe beskrivelse",
|
"Group Description": "Gruppe beskrivelse",
|
||||||
"Group Name": "Gruppenavn",
|
"Group Name": "Gruppenavn",
|
||||||
"Group updated successfully": "Gruppe opdateret.",
|
"Group updated successfully": "Gruppe opdateret.",
|
||||||
"groups": "",
|
"groups": "grupper",
|
||||||
"Groups": "Grupper",
|
"Groups": "Grupper",
|
||||||
"H1": "H1",
|
"H1": "H1",
|
||||||
"H2": "H2",
|
"H2": "H2",
|
||||||
|
|
@ -875,7 +875,7 @@
|
||||||
"Image Compression": "Billedkomprimering",
|
"Image Compression": "Billedkomprimering",
|
||||||
"Image Compression Height": "Billedkomprimering højde",
|
"Image Compression Height": "Billedkomprimering højde",
|
||||||
"Image Compression Width": "Billedkomprimering bredde",
|
"Image Compression Width": "Billedkomprimering bredde",
|
||||||
"Image Edit": "",
|
"Image Edit": "Billederedigering",
|
||||||
"Image Edit Engine": "Billederedigeringsmotor",
|
"Image Edit Engine": "Billederedigeringsmotor",
|
||||||
"Image Generation": "Billedgenerering",
|
"Image Generation": "Billedgenerering",
|
||||||
"Image Generation Engine": "Billedgenereringsmotor",
|
"Image Generation Engine": "Billedgenereringsmotor",
|
||||||
|
|
@ -890,18 +890,18 @@
|
||||||
"Import Chats": "Importer chats",
|
"Import Chats": "Importer chats",
|
||||||
"Import Config from JSON File": "Importer konfiguration fra JSON-fil",
|
"Import Config from JSON File": "Importer konfiguration fra JSON-fil",
|
||||||
"Import From Link": "Importer fra et link",
|
"Import From Link": "Importer fra et link",
|
||||||
"Import Models": "",
|
"Import Models": "Importer modeller",
|
||||||
"Import Notes": "Importer noter",
|
"Import Notes": "Importer noter",
|
||||||
"Import Presets": "Importer Presets",
|
"Import Presets": "Importer Presets",
|
||||||
"Import Prompts": "",
|
"Import Prompts": "Importer prompter",
|
||||||
"Import successful": "Importeret",
|
"Import successful": "Importeret",
|
||||||
"Import Tools": "",
|
"Import Tools": "Importer værktøjer",
|
||||||
"Important Update": "Vigtig opdatering",
|
"Important Update": "Vigtig opdatering",
|
||||||
"Include": "Inkluder",
|
"Include": "Inkluder",
|
||||||
"Include `--api-auth` flag when running stable-diffusion-webui": "Inkluder `--api-auth` flag, når du kører stable-diffusion-webui",
|
"Include `--api-auth` flag when running stable-diffusion-webui": "Inkluder `--api-auth` flag, når du kører stable-diffusion-webui",
|
||||||
"Include `--api` flag when running stable-diffusion-webui": "Inkluder `--api` flag, når du kører stable-diffusion-webui",
|
"Include `--api` flag when running stable-diffusion-webui": "Inkluder `--api` flag, når du kører stable-diffusion-webui",
|
||||||
"Includes SharePoint": "Inkluderer SharePoint",
|
"Includes SharePoint": "Inkluderer SharePoint",
|
||||||
"Increase UI Scale": "",
|
"Increase UI Scale": "Forøg UI-skalering",
|
||||||
"Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.": "Påvirker hvor hurtigt algoritmen reagerer på feedback fra den genererede tekst. En lavere indlæringshastighed vil resultere i langsommere justeringer, mens en højere indlæringshastighed vil gøre algoritmen mere responsiv.",
|
"Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.": "Påvirker hvor hurtigt algoritmen reagerer på feedback fra den genererede tekst. En lavere indlæringshastighed vil resultere i langsommere justeringer, mens en højere indlæringshastighed vil gøre algoritmen mere responsiv.",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
"Initials": "Initialer",
|
"Initials": "Initialer",
|
||||||
|
|
@ -924,7 +924,7 @@
|
||||||
"Invalid JSON format for ComfyUI Edit Workflow.": "Ugyldigt JSON format for ComfyUI Edit Workflow.",
|
"Invalid JSON format for ComfyUI Edit Workflow.": "Ugyldigt JSON format for ComfyUI Edit Workflow.",
|
||||||
"Invalid JSON format for ComfyUI Workflow.": "Ugyldigt JSON format for ComfyUI Workflow.",
|
"Invalid JSON format for ComfyUI Workflow.": "Ugyldigt JSON format for ComfyUI Workflow.",
|
||||||
"Invalid JSON format for Parameters": "Ugyldigt JSON format for parametre",
|
"Invalid JSON format for Parameters": "Ugyldigt JSON format for parametre",
|
||||||
"Invalid JSON format in {{NAME}}": "",
|
"Invalid JSON format in {{NAME}}": "Ugyldigt JSON format i {{NAME}}",
|
||||||
"Invalid JSON format in Additional Config": "Ugyldigt JSON format for yderligere konfiguration",
|
"Invalid JSON format in Additional Config": "Ugyldigt JSON format for yderligere konfiguration",
|
||||||
"Invalid JSON format in MinerU Parameters": "Ugyldigt JSON format for MinerU parametre",
|
"Invalid JSON format in MinerU Parameters": "Ugyldigt JSON format for MinerU parametre",
|
||||||
"Invalid Tag": "Ugyldigt tag",
|
"Invalid Tag": "Ugyldigt tag",
|
||||||
|
|
@ -958,7 +958,7 @@
|
||||||
"Knowledge Name": "Vidensnavn",
|
"Knowledge Name": "Vidensnavn",
|
||||||
"Knowledge Public Sharing": "Viden offentlig deling",
|
"Knowledge Public Sharing": "Viden offentlig deling",
|
||||||
"Knowledge reset successfully.": "Viden nulstillet.",
|
"Knowledge reset successfully.": "Viden nulstillet.",
|
||||||
"Knowledge Sharing": "",
|
"Knowledge Sharing": "Vidensdeling",
|
||||||
"Knowledge updated successfully": "Viden opdateret.",
|
"Knowledge updated successfully": "Viden opdateret.",
|
||||||
"Kokoro.js (Browser)": "Kokoro.js (Browser)",
|
"Kokoro.js (Browser)": "Kokoro.js (Browser)",
|
||||||
"Kokoro.js Dtype": "Kokoro.js Dtype",
|
"Kokoro.js Dtype": "Kokoro.js Dtype",
|
||||||
|
|
@ -1024,13 +1024,13 @@
|
||||||
"Max Upload Size": "Maks. uploadstørrelse",
|
"Max Upload Size": "Maks. uploadstørrelse",
|
||||||
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Højst 3 modeller kan downloades samtidigt. Prøv igen senere.",
|
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Højst 3 modeller kan downloades samtidigt. Prøv igen senere.",
|
||||||
"May": "Maj",
|
"May": "Maj",
|
||||||
"MBR": "",
|
"MBR": "MBR",
|
||||||
"MCP": "MCP",
|
"MCP": "MCP",
|
||||||
"MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.": "MCP understøttelse er eksperimentel og dens specifikationer ændres ofte hvilket kan medføre inkompatibilitet. OpenAI specifikationsunderstøttelse er vedligeholdt af Open WebUI-teamet hvilket gør det til den mest pålidelige mulighed for understøttelse.",
|
"MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.": "MCP understøttelse er eksperimentel og dens specifikationer ændres ofte hvilket kan medføre inkompatibilitet. OpenAI specifikationsunderstøttelse er vedligeholdt af Open WebUI-teamet hvilket gør det til den mest pålidelige mulighed for understøttelse.",
|
||||||
"Medium": "Medium",
|
"Medium": "Medium",
|
||||||
"Member removed successfully": "",
|
"Member removed successfully": "Medlem fjernet",
|
||||||
"Members": "",
|
"Members": "Medlemmer",
|
||||||
"Members added successfully": "",
|
"Members added successfully": "Medlemmer tilføjet",
|
||||||
"Memories accessible by LLMs will be shown here.": "Minder, der er tilgængelige for LLM'er, vises her.",
|
"Memories accessible by LLMs will be shown here.": "Minder, der er tilgængelige for LLM'er, vises her.",
|
||||||
"Memory": "Hukommelse",
|
"Memory": "Hukommelse",
|
||||||
"Memory added successfully": "Hukommelse tilføjet.",
|
"Memory added successfully": "Hukommelse tilføjet.",
|
||||||
|
|
@ -1084,7 +1084,7 @@
|
||||||
"Models configuration saved successfully": "Modeller konfiguration gemt",
|
"Models configuration saved successfully": "Modeller konfiguration gemt",
|
||||||
"Models imported successfully": "Modeller importeret",
|
"Models imported successfully": "Modeller importeret",
|
||||||
"Models Public Sharing": "Modeller offentlig deling",
|
"Models Public Sharing": "Modeller offentlig deling",
|
||||||
"Models Sharing": "",
|
"Models Sharing": "Modeldeling",
|
||||||
"Mojeek Search API Key": "Mojeek Search API nøgle",
|
"Mojeek Search API Key": "Mojeek Search API nøgle",
|
||||||
"More": "Mere",
|
"More": "Mere",
|
||||||
"More Concise": "Mere kortfattet",
|
"More Concise": "Mere kortfattet",
|
||||||
|
|
@ -1130,7 +1130,7 @@
|
||||||
"No models selected": "Ingen modeller valgt",
|
"No models selected": "Ingen modeller valgt",
|
||||||
"No Notes": "Ingen noter",
|
"No Notes": "Ingen noter",
|
||||||
"No notes found": "Ingen noter fundet",
|
"No notes found": "Ingen noter fundet",
|
||||||
"No pinned messages": "",
|
"No pinned messages": "Ingen fastgjorte beskeder",
|
||||||
"No prompts found": "Ingen prompts fundet",
|
"No prompts found": "Ingen prompts fundet",
|
||||||
"No results": "Ingen resultater fundet",
|
"No results": "Ingen resultater fundet",
|
||||||
"No results found": "Ingen resultater fundet",
|
"No results found": "Ingen resultater fundet",
|
||||||
|
|
@ -1152,7 +1152,7 @@
|
||||||
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Bemærk: Hvis du angiver en minimumscore, returnerer søgningen kun dokumenter med en score, der er større end eller lig med minimumscoren.",
|
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Bemærk: Hvis du angiver en minimumscore, returnerer søgningen kun dokumenter med en score, der er større end eller lig med minimumscoren.",
|
||||||
"Notes": "Noter",
|
"Notes": "Noter",
|
||||||
"Notes Public Sharing": "Noter offentlig deling",
|
"Notes Public Sharing": "Noter offentlig deling",
|
||||||
"Notes Sharing": "",
|
"Notes Sharing": "Notedeling",
|
||||||
"Notification Sound": "Notifikationslyd",
|
"Notification Sound": "Notifikationslyd",
|
||||||
"Notification Webhook": "Notifikations webhook",
|
"Notification Webhook": "Notifikations webhook",
|
||||||
"Notifications": "Notifikationer",
|
"Notifications": "Notifikationer",
|
||||||
|
|
@ -1178,7 +1178,7 @@
|
||||||
"Only alphanumeric characters and hyphens are allowed in the command string.": "Kun alfanumeriske tegn og bindestreger er tilladt i kommandostrengen.",
|
"Only alphanumeric characters and hyphens are allowed in the command string.": "Kun alfanumeriske tegn og bindestreger er tilladt i kommandostrengen.",
|
||||||
"Only can be triggered when the chat input is in focus.": "Kan kun udløses når chat-input er fokuseret.",
|
"Only can be triggered when the chat input is in focus.": "Kan kun udløses når chat-input er fokuseret.",
|
||||||
"Only collections can be edited, create a new knowledge base to edit/add documents.": "Kun samlinger kan redigeres, opret en ny vidensbase for at redigere/tilføje dokumenter.",
|
"Only collections can be edited, create a new knowledge base to edit/add documents.": "Kun samlinger kan redigeres, opret en ny vidensbase for at redigere/tilføje dokumenter.",
|
||||||
"Only invited users can access": "",
|
"Only invited users can access": "Kun inviterede brugere kan få adgang",
|
||||||
"Only markdown files are allowed": "Kun markdown-filer er tilladt",
|
"Only markdown files are allowed": "Kun markdown-filer er tilladt",
|
||||||
"Only select users and groups with permission can access": "Kun valgte brugere og grupper med tilladelse kan tilgå",
|
"Only select users and groups with permission can access": "Kun valgte brugere og grupper med tilladelse kan tilgå",
|
||||||
"Oops! Looks like the URL is invalid. Please double-check and try again.": "Ups! URL'en ser ud til at være ugyldig. Tjek den igen, og prøv igen.",
|
"Oops! Looks like the URL is invalid. Please double-check and try again.": "Ups! URL'en ser ud til at være ugyldig. Tjek den igen, og prøv igen.",
|
||||||
|
|
@ -1236,12 +1236,12 @@
|
||||||
"Permissions": "Tilladelser",
|
"Permissions": "Tilladelser",
|
||||||
"Perplexity API Key": "Perplexity API nøgle",
|
"Perplexity API Key": "Perplexity API nøgle",
|
||||||
"Perplexity Model": "Perplexity model",
|
"Perplexity Model": "Perplexity model",
|
||||||
"Perplexity Search API URL": "",
|
"Perplexity Search API URL": "Perplexity Search API URL",
|
||||||
"Perplexity Search Context Usage": "Perplexity søgekontekst brug",
|
"Perplexity Search Context Usage": "Perplexity søgekontekst brug",
|
||||||
"Personalization": "Personalisering",
|
"Personalization": "Personalisering",
|
||||||
"Pin": "Fastgør",
|
"Pin": "Fastgør",
|
||||||
"Pinned": "Fastgjort",
|
"Pinned": "Fastgjort",
|
||||||
"Pinned Messages": "",
|
"Pinned Messages": "Fastgjorte beskeder",
|
||||||
"Pioneer insights": "Banebrydende indsigter",
|
"Pioneer insights": "Banebrydende indsigter",
|
||||||
"Pipe": "Pipe",
|
"Pipe": "Pipe",
|
||||||
"Pipeline deleted successfully": "Pipeline slettet.",
|
"Pipeline deleted successfully": "Pipeline slettet.",
|
||||||
|
|
@ -1271,7 +1271,7 @@
|
||||||
"Please select a model.": "Vælg en model.",
|
"Please select a model.": "Vælg en model.",
|
||||||
"Please select a reason": "Vælg en årsag",
|
"Please select a reason": "Vælg en årsag",
|
||||||
"Please select a valid JSON file": "Vælg en valid JSON-fil",
|
"Please select a valid JSON file": "Vælg en valid JSON-fil",
|
||||||
"Please select at least one user for Direct Message channel.": "",
|
"Please select at least one user for Direct Message channel.": "Vælg mindst én bruger til direkte besked-kanal.",
|
||||||
"Please wait until all files are uploaded.": "Vent venligst indtil alle filerne er uploadet.",
|
"Please wait until all files are uploaded.": "Vent venligst indtil alle filerne er uploadet.",
|
||||||
"Port": "Port",
|
"Port": "Port",
|
||||||
"Positive attitude": "Positiv holdning",
|
"Positive attitude": "Positiv holdning",
|
||||||
|
|
@ -1284,7 +1284,7 @@
|
||||||
"Previous 7 days": "Seneste 7 dage",
|
"Previous 7 days": "Seneste 7 dage",
|
||||||
"Previous message": "Forrige besked",
|
"Previous message": "Forrige besked",
|
||||||
"Private": "Privat",
|
"Private": "Privat",
|
||||||
"Private conversation between selected users": "",
|
"Private conversation between selected users": "Privat samtale mellem valgte brugere",
|
||||||
"Profile": "Profil",
|
"Profile": "Profil",
|
||||||
"Prompt": "Prompt",
|
"Prompt": "Prompt",
|
||||||
"Prompt Autocompletion": "Prompt autofuldførelse",
|
"Prompt Autocompletion": "Prompt autofuldførelse",
|
||||||
|
|
@ -1294,7 +1294,7 @@
|
||||||
"Prompts": "Prompts",
|
"Prompts": "Prompts",
|
||||||
"Prompts Access": "Prompts adgang",
|
"Prompts Access": "Prompts adgang",
|
||||||
"Prompts Public Sharing": "Prompts offentlig deling",
|
"Prompts Public Sharing": "Prompts offentlig deling",
|
||||||
"Prompts Sharing": "",
|
"Prompts Sharing": "Promptdeling",
|
||||||
"Provider Type": "Udbyder type",
|
"Provider Type": "Udbyder type",
|
||||||
"Public": "Offentlig",
|
"Public": "Offentlig",
|
||||||
"Pull \"{{searchValue}}\" from Ollama.com": "Hent \"{{searchValue}}\" fra Ollama.com",
|
"Pull \"{{searchValue}}\" from Ollama.com": "Hent \"{{searchValue}}\" fra Ollama.com",
|
||||||
|
|
@ -1365,8 +1365,8 @@
|
||||||
"Retrieval": "Hentning",
|
"Retrieval": "Hentning",
|
||||||
"Retrieval Query Generation": "Hentnings forespørgsel generering",
|
"Retrieval Query Generation": "Hentnings forespørgsel generering",
|
||||||
"Retrieved {{count}} sources": "Fandt en kildehenvisning",
|
"Retrieved {{count}} sources": "Fandt en kildehenvisning",
|
||||||
"Retrieved {{count}} sources_one": "Fandt {{count}} sources_one",
|
"Retrieved {{count}} sources_one": "Fandt {{count}} kildehenvisning",
|
||||||
"Retrieved {{count}} sources_other": "Fandt {{count}} sources_other",
|
"Retrieved {{count}} sources_other": "Fandt {{count}} kildehenvisninger",
|
||||||
"Retrieved 1 source": "Fandt en kildehenvisning",
|
"Retrieved 1 source": "Fandt en kildehenvisning",
|
||||||
"Rich Text Input for Chat": "Rich text input til chat",
|
"Rich Text Input for Chat": "Rich text input til chat",
|
||||||
"RK": "RK",
|
"RK": "RK",
|
||||||
|
|
@ -1377,7 +1377,7 @@
|
||||||
"Run": "Kør",
|
"Run": "Kør",
|
||||||
"Running": "Kører",
|
"Running": "Kører",
|
||||||
"Running...": "Kører...",
|
"Running...": "Kører...",
|
||||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Kører embedding-opgaver sideløbende for at fremskynde behandlingen. Slå fra hvis hastighedsbegrænsninger bliver et problem.",
|
||||||
"Save": "Gem",
|
"Save": "Gem",
|
||||||
"Save & Create": "Gem og opret",
|
"Save & Create": "Gem og opret",
|
||||||
"Save & Update": "Gem og opdater",
|
"Save & Update": "Gem og opdater",
|
||||||
|
|
@ -1472,14 +1472,14 @@
|
||||||
"Set the number of worker threads used for computation. This option controls how many threads are used to process incoming requests concurrently. Increasing this value can improve performance under high concurrency workloads but may also consume more CPU resources.": "Indstil antallet af arbejdstråde brugt til beregning. Denne mulighed styrer, hvor mange tråde der bruges til at behandle indkommende forespørgsler samtidigt. At øge denne værdi kan forbedre ydeevnen under høj samtidighedsbelastning, men kan også forbruge flere CPU-ressourcer.",
|
"Set the number of worker threads used for computation. This option controls how many threads are used to process incoming requests concurrently. Increasing this value can improve performance under high concurrency workloads but may also consume more CPU resources.": "Indstil antallet af arbejdstråde brugt til beregning. Denne mulighed styrer, hvor mange tråde der bruges til at behandle indkommende forespørgsler samtidigt. At øge denne værdi kan forbedre ydeevnen under høj samtidighedsbelastning, men kan også forbruge flere CPU-ressourcer.",
|
||||||
"Set Voice": "Indstil stemme",
|
"Set Voice": "Indstil stemme",
|
||||||
"Set whisper model": "Indstil whisper model",
|
"Set whisper model": "Indstil whisper model",
|
||||||
"Set your status": "",
|
"Set your status": "Indstil din status",
|
||||||
"Sets a flat bias against tokens that have appeared at least once. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Indstiller en flad bias mod tokens, der er forekommet mindst én gang. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
|
"Sets a flat bias against tokens that have appeared at least once. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Indstiller en flad bias mod tokens, der er forekommet mindst én gang. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
|
||||||
"Sets a scaling bias against tokens to penalize repetitions, based on how many times they have appeared. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Indstiller en skalerende bias mod tokens for at straffe gentagelser, baseret på hvor mange gange de er forekommet. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
|
"Sets a scaling bias against tokens to penalize repetitions, based on how many times they have appeared. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Indstiller en skalerende bias mod tokens for at straffe gentagelser, baseret på hvor mange gange de er forekommet. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
|
||||||
"Sets how far back for the model to look back to prevent repetition.": "Indstiller hvor langt tilbage modellen skal se for at forhindre gentagelse.",
|
"Sets how far back for the model to look back to prevent repetition.": "Indstiller hvor langt tilbage modellen skal se for at forhindre gentagelse.",
|
||||||
"Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "Indstiller det tilfældige tal seed til brug for generering. At indstille dette til et specifikt tal vil få modellen til at generere den samme tekst for den samme prompt.",
|
"Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "Indstiller det tilfældige tal seed til brug for generering. At indstille dette til et specifikt tal vil få modellen til at generere den samme tekst for den samme prompt.",
|
||||||
"Sets the size of the context window used to generate the next token.": "Indstiller størrelsen af kontekstvinduet brugt til at generere det næste token.",
|
"Sets the size of the context window used to generate the next token.": "Indstiller størrelsen af kontekstvinduet brugt til at generere det næste token.",
|
||||||
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Indstiller stop-sekvenserne til brug. Når dette mønster stødes på, vil LLM'en stoppe med at generere tekst og returnere. Flere stop-mønstre kan indstilles ved at specificere flere separate stop-parametre i en modelfil.",
|
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Indstiller stop-sekvenserne til brug. Når dette mønster stødes på, vil LLM'en stoppe med at generere tekst og returnere. Flere stop-mønstre kan indstilles ved at specificere flere separate stop-parametre i en modelfil.",
|
||||||
"Setting": "",
|
"Setting": "Indstilling",
|
||||||
"Settings": "Indstillinger",
|
"Settings": "Indstillinger",
|
||||||
"Settings saved successfully!": "Indstillinger gemt!",
|
"Settings saved successfully!": "Indstillinger gemt!",
|
||||||
"Share": "Del",
|
"Share": "Del",
|
||||||
|
|
@ -1525,9 +1525,9 @@
|
||||||
"Start a new conversation": "Start en ny samtale",
|
"Start a new conversation": "Start en ny samtale",
|
||||||
"Start of the channel": "Kanalens start",
|
"Start of the channel": "Kanalens start",
|
||||||
"Start Tag": "Start tag",
|
"Start Tag": "Start tag",
|
||||||
"Status": "",
|
"Status": "Status",
|
||||||
"Status cleared successfully": "",
|
"Status cleared successfully": "Status slettet",
|
||||||
"Status updated successfully": "",
|
"Status updated successfully": "Status opdateret",
|
||||||
"Status Updates": "Statusopdateringer",
|
"Status Updates": "Statusopdateringer",
|
||||||
"STDOUT/STDERR": "STDOUT/STDERR",
|
"STDOUT/STDERR": "STDOUT/STDERR",
|
||||||
"Steps": "Trin",
|
"Steps": "Trin",
|
||||||
|
|
@ -1543,7 +1543,7 @@
|
||||||
"STT Model": "STT-model",
|
"STT Model": "STT-model",
|
||||||
"STT Settings": "STT-indstillinger",
|
"STT Settings": "STT-indstillinger",
|
||||||
"Stylized PDF Export": "Stiliseret PDF eksport",
|
"Stylized PDF Export": "Stiliseret PDF eksport",
|
||||||
"Subtitle": "",
|
"Subtitle": "Undertekst",
|
||||||
"Success": "Succes",
|
"Success": "Succes",
|
||||||
"Successfully imported {{userCount}} users.": "Importerede {{userCount}} brugere.",
|
"Successfully imported {{userCount}} users.": "Importerede {{userCount}} brugere.",
|
||||||
"Successfully updated.": "Opdateret.",
|
"Successfully updated.": "Opdateret.",
|
||||||
|
|
@ -1660,7 +1660,7 @@
|
||||||
"Tools Function Calling Prompt": "Værktøjs Funktionkaldprompt",
|
"Tools Function Calling Prompt": "Værktøjs Funktionkaldprompt",
|
||||||
"Tools have a function calling system that allows arbitrary code execution.": "Værktøjer har et funktionkaldssystem, der tillader vilkårlig kodeudførelse.",
|
"Tools have a function calling system that allows arbitrary code execution.": "Værktøjer har et funktionkaldssystem, der tillader vilkårlig kodeudførelse.",
|
||||||
"Tools Public Sharing": "Værktøjer Offentlig Deling",
|
"Tools Public Sharing": "Værktøjer Offentlig Deling",
|
||||||
"Tools Sharing": "",
|
"Tools Sharing": "Værktøjsdeling",
|
||||||
"Top K": "Top K",
|
"Top K": "Top K",
|
||||||
"Top K Reranker": "Top K omarrangering",
|
"Top K Reranker": "Top K omarrangering",
|
||||||
"Transformers": "Transformers",
|
"Transformers": "Transformers",
|
||||||
|
|
@ -1676,7 +1676,7 @@
|
||||||
"Type Hugging Face Resolve (Download) URL": "Indtast Hugging Face Resolve (Download) URL",
|
"Type Hugging Face Resolve (Download) URL": "Indtast Hugging Face Resolve (Download) URL",
|
||||||
"Uh-oh! There was an issue with the response.": "Uh-oh! Der var et problem det det svar.",
|
"Uh-oh! There was an issue with the response.": "Uh-oh! Der var et problem det det svar.",
|
||||||
"UI": "UI",
|
"UI": "UI",
|
||||||
"UI Scale": "",
|
"UI Scale": "UI-skalering",
|
||||||
"Unarchive All": "Udpak alle arkiver",
|
"Unarchive All": "Udpak alle arkiver",
|
||||||
"Unarchive All Archived Chats": "Udpak alle arkiverede chats",
|
"Unarchive All Archived Chats": "Udpak alle arkiverede chats",
|
||||||
"Unarchive Chat": "Fjern chat fra arkiv",
|
"Unarchive Chat": "Fjern chat fra arkiv",
|
||||||
|
|
@ -1694,7 +1694,7 @@
|
||||||
"Update and Copy Link": "Opdater og kopier link",
|
"Update and Copy Link": "Opdater og kopier link",
|
||||||
"Update for the latest features and improvements.": "Opdater for at få de nyeste funktioner og forbedringer.",
|
"Update for the latest features and improvements.": "Opdater for at få de nyeste funktioner og forbedringer.",
|
||||||
"Update password": "Opdater adgangskode",
|
"Update password": "Opdater adgangskode",
|
||||||
"Update your status": "",
|
"Update your status": "Opdater din status",
|
||||||
"Updated": "Opdateret",
|
"Updated": "Opdateret",
|
||||||
"Updated at": "Opdateret kl.",
|
"Updated at": "Opdateret kl.",
|
||||||
"Updated At": "Opdateret Klokken.",
|
"Updated At": "Opdateret Klokken.",
|
||||||
|
|
@ -1709,7 +1709,7 @@
|
||||||
"Upload Pipeline": "Upload pipeline",
|
"Upload Pipeline": "Upload pipeline",
|
||||||
"Upload Progress": "Uploadfremdrift",
|
"Upload Progress": "Uploadfremdrift",
|
||||||
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Uploadfremdrift: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
|
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Uploadfremdrift: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
|
||||||
"Uploading file...": "",
|
"Uploading file...": "Uploader fil...",
|
||||||
"URL": "URL",
|
"URL": "URL",
|
||||||
"URL is required": "URL er påkrævet",
|
"URL is required": "URL er påkrævet",
|
||||||
"URL Mode": "URL-tilstand",
|
"URL Mode": "URL-tilstand",
|
||||||
|
|
@ -1728,7 +1728,7 @@
|
||||||
"User menu": "Brugermenu",
|
"User menu": "Brugermenu",
|
||||||
"User Webhooks": "Bruger Webhooks",
|
"User Webhooks": "Bruger Webhooks",
|
||||||
"Username": "Brugernavn",
|
"Username": "Brugernavn",
|
||||||
"users": "",
|
"users": "brugere",
|
||||||
"Users": "Brugere",
|
"Users": "Brugere",
|
||||||
"Uses DefaultAzureCredential to authenticate": "Bruger DefaultAzureCredential til at autentificere",
|
"Uses DefaultAzureCredential to authenticate": "Bruger DefaultAzureCredential til at autentificere",
|
||||||
"Uses OAuth 2.1 Dynamic Client Registration": "Bruger OAuth 2.1 Dynamic Client Registration",
|
"Uses OAuth 2.1 Dynamic Client Registration": "Bruger OAuth 2.1 Dynamic Client Registration",
|
||||||
|
|
@ -1748,13 +1748,13 @@
|
||||||
"View Replies": "Vis svar",
|
"View Replies": "Vis svar",
|
||||||
"View Result from **{{NAME}}**": "Vis resultat fra **{{NAME}}**",
|
"View Result from **{{NAME}}**": "Vis resultat fra **{{NAME}}**",
|
||||||
"Visibility": "Synlighed",
|
"Visibility": "Synlighed",
|
||||||
"Visible to all users": "",
|
"Visible to all users": "Synlig for alle brugere",
|
||||||
"Vision": "Vision",
|
"Vision": "Vision",
|
||||||
"Voice": "Stemme",
|
"Voice": "Stemme",
|
||||||
"Voice Input": "Stemme Input",
|
"Voice Input": "Stemme Input",
|
||||||
"Voice mode": "Stemmetilstand",
|
"Voice mode": "Stemmetilstand",
|
||||||
"Voice Mode Custom Prompt": "",
|
"Voice Mode Custom Prompt": "Brugerdefineret prompt til stemmetilstand",
|
||||||
"Voice Mode Prompt": "",
|
"Voice Mode Prompt": "Prompt til stemmetilstand",
|
||||||
"Warning": "Advarsel",
|
"Warning": "Advarsel",
|
||||||
"Warning:": "Advarsel:",
|
"Warning:": "Advarsel:",
|
||||||
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Advarsel: Hvis du aktiverer dette, vil brugerne kunne uploade vilkårlig kode på serveren.",
|
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Advarsel: Hvis du aktiverer dette, vil brugerne kunne uploade vilkårlig kode på serveren.",
|
||||||
|
|
@ -1776,7 +1776,7 @@
|
||||||
"What are you trying to achieve?": "Hvad prøver du at opnå?",
|
"What are you trying to achieve?": "Hvad prøver du at opnå?",
|
||||||
"What are you working on?": "Hvad arbejder du på?",
|
"What are you working on?": "Hvad arbejder du på?",
|
||||||
"What's New in": "Nyheder i",
|
"What's New in": "Nyheder i",
|
||||||
"What's on your mind?": "",
|
"What's on your mind?": "Hvad har du på hjertet?",
|
||||||
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Når aktiveret, vil modellen reagere på hver chatbesked i realtid og generere et svar, så snart brugeren sender en besked. Denne tilstand er nyttig til live chat-applikationer, men kan påvirke ydeevnen på langsommere hardware.",
|
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Når aktiveret, vil modellen reagere på hver chatbesked i realtid og generere et svar, så snart brugeren sender en besked. Denne tilstand er nyttig til live chat-applikationer, men kan påvirke ydeevnen på langsommere hardware.",
|
||||||
"wherever you are": "hvad end du er",
|
"wherever you are": "hvad end du er",
|
||||||
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Om outputtet skal pagineres. Hver side vil være adskilt af en vandret streg og sidetal. Standard er False.",
|
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Om outputtet skal pagineres. Hver side vil være adskilt af en vandret streg og sidetal. Standard er False.",
|
||||||
|
|
|
||||||
|
|
@ -1624,6 +1624,7 @@
|
||||||
"Tika": "Tika",
|
"Tika": "Tika",
|
||||||
"Tika Server URL required.": "请输入 Tika 服务器接口地址",
|
"Tika Server URL required.": "请输入 Tika 服务器接口地址",
|
||||||
"Tiktoken": "Tiktoken",
|
"Tiktoken": "Tiktoken",
|
||||||
|
"Timeout": "超时时间",
|
||||||
"Title": "标题",
|
"Title": "标题",
|
||||||
"Title Auto-Generation": "自动生成标题",
|
"Title Auto-Generation": "自动生成标题",
|
||||||
"Title cannot be an empty string.": "标题不能为空",
|
"Title cannot be an empty string.": "标题不能为空",
|
||||||
|
|
|
||||||
|
|
@ -1624,6 +1624,7 @@
|
||||||
"Tika": "Tika",
|
"Tika": "Tika",
|
||||||
"Tika Server URL required.": "需要提供 Tika 伺服器 URL。",
|
"Tika Server URL required.": "需要提供 Tika 伺服器 URL。",
|
||||||
"Tiktoken": "Tiktoken",
|
"Tiktoken": "Tiktoken",
|
||||||
|
"Timeout": "逾時時間",
|
||||||
"Title": "標題",
|
"Title": "標題",
|
||||||
"Title Auto-Generation": "自動產生標題",
|
"Title Auto-Generation": "自動產生標題",
|
||||||
"Title cannot be an empty string.": "標題不能是空字串。",
|
"Title cannot be an empty string.": "標題不能是空字串。",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export const settings: Writable<Settings> = writable({});
|
||||||
|
|
||||||
export const audioQueue = writable(null);
|
export const audioQueue = writable(null);
|
||||||
|
|
||||||
|
export const sidebarWidth = writable(260);
|
||||||
|
|
||||||
export const showSidebar = writable(false);
|
export const showSidebar = writable(false);
|
||||||
export const showSearch = writable(false);
|
export const showSearch = writable(false);
|
||||||
export const showSettings = writable(false);
|
export const showSettings = writable(false);
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,10 @@
|
||||||
console.log('Shortcut triggered: GENERATE_MESSAGE_PAIR');
|
console.log('Shortcut triggered: GENERATE_MESSAGE_PAIR');
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
document.getElementById('generate-message-pair-button')?.click();
|
document.getElementById('generate-message-pair-button')?.click();
|
||||||
} else if (isShortcutMatch(event, shortcuts[Shortcut.REGENERATE_RESPONSE])) {
|
} else if (
|
||||||
|
isShortcutMatch(event, shortcuts[Shortcut.REGENERATE_RESPONSE]) &&
|
||||||
|
document.activeElement?.id === 'chat-input'
|
||||||
|
) {
|
||||||
console.log('Shortcut triggered: REGENERATE_RESPONSE');
|
console.log('Shortcut triggered: REGENERATE_RESPONSE');
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
[...document.getElementsByClassName('regenerate-response-button')]?.at(-1)?.click();
|
[...document.getElementsByClassName('regenerate-response-button')]?.at(-1)?.click();
|
||||||
|
|
@ -383,7 +386,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="w-full flex-1 h-full flex items-center justify-center {$showSidebar
|
class="w-full flex-1 h-full flex items-center justify-center {$showSidebar
|
||||||
? ' md:max-w-[calc(100%-260px)]'
|
? ' md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ' '}"
|
: ' '}"
|
||||||
>
|
>
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div
|
<div
|
||||||
class=" flex flex-col h-screen max-h-[100dvh] flex-1 transition-width duration-200 ease-in-out {$showSidebar
|
class=" flex flex-col h-screen max-h-[100dvh] flex-1 transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ' md:max-w-[calc(100%-49px)]'} w-full max-w-full"
|
: ' md:max-w-[calc(100%-49px)]'} w-full max-w-full"
|
||||||
>
|
>
|
||||||
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">
|
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ''} max-w-full"
|
: ''} max-w-full"
|
||||||
>
|
>
|
||||||
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">
|
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div
|
<div
|
||||||
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ''} max-w-full"
|
: ''} max-w-full"
|
||||||
>
|
>
|
||||||
<nav class=" px-2 pt-1.5 backdrop-blur-xl w-full drag-region">
|
<nav class=" px-2 pt-1.5 backdrop-blur-xl w-full drag-region">
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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 />
|
<Notes />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div
|
<div
|
||||||
id="note-container"
|
id="note-container"
|
||||||
class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}"
|
class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-var(--sidebar-width))]' : ''}"
|
||||||
>
|
>
|
||||||
<NoteEditor id={$page.params.id} />
|
<NoteEditor id={$page.params.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ''} max-w-full"
|
: ''} max-w-full"
|
||||||
>
|
>
|
||||||
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">
|
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div
|
<div
|
||||||
class=" relative flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
class=" relative flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||||
: ''} max-w-full"
|
: ''} max-w-full"
|
||||||
>
|
>
|
||||||
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">
|
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue