mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
Merge 8e661a4e73 into 6f1486ffd0
This commit is contained in:
commit
870e02a4be
118 changed files with 5020 additions and 2613 deletions
|
|
@ -3,8 +3,6 @@ pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
kubernetes/
|
|
||||||
|
|
||||||
# Copy of .gitignore
|
# Copy of .gitignore
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
### Installing Both Ollama and Open WebUI Using Kustomize
|
|
||||||
|
|
||||||
For cpu-only pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f ./kubernetes/manifest/base
|
|
||||||
```
|
|
||||||
|
|
||||||
For gpu-enabled pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -k ./kubernetes/manifest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Installing Both Ollama and Open WebUI Using Helm
|
|
||||||
|
|
||||||
Package Helm file first
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm package ./kubernetes/helm/
|
|
||||||
```
|
|
||||||
|
|
||||||
For cpu-only pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm install ollama-webui ./ollama-webui-*.tgz
|
|
||||||
```
|
|
||||||
|
|
||||||
For gpu-enabled pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,4 +1,4 @@
|
||||||
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
|
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,12 @@ OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
|
||||||
== "true"
|
== "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OAUTH_AUDIENCE = PersistentConfig(
|
||||||
|
"OAUTH_AUDIENCE",
|
||||||
|
"oauth.audience",
|
||||||
|
os.environ.get("OAUTH_AUDIENCE", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_oauth_providers():
|
def load_oauth_providers():
|
||||||
OAUTH_PROVIDERS.clear()
|
OAUTH_PROVIDERS.clear()
|
||||||
|
|
@ -1300,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"
|
||||||
)
|
)
|
||||||
|
|
@ -1339,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2994,6 +3000,12 @@ WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
|
||||||
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
|
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WEB_LOADER_TIMEOUT = PersistentConfig(
|
||||||
|
"WEB_LOADER_TIMEOUT",
|
||||||
|
"rag.web.loader.timeout",
|
||||||
|
os.getenv("WEB_LOADER_TIMEOUT", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
|
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
|
||||||
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
|
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,13 @@ try:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
||||||
|
|
||||||
|
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "")
|
||||||
|
try:
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
|
||||||
|
except ValueError:
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# UVICORN WORKERS
|
# UVICORN WORKERS
|
||||||
####################################
|
####################################
|
||||||
|
|
@ -620,9 +627,16 @@ ENABLE_WEBSOCKET_SUPPORT = (
|
||||||
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
||||||
|
|
||||||
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
|
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
|
||||||
|
|
||||||
|
|
||||||
if WEBSOCKET_REDIS_OPTIONS == "":
|
if WEBSOCKET_REDIS_OPTIONS == "":
|
||||||
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
|
if REDIS_SOCKET_CONNECT_TIMEOUT:
|
||||||
WEBSOCKET_REDIS_OPTIONS = None
|
WEBSOCKET_REDIS_OPTIONS = {
|
||||||
|
"socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
|
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ from open_webui.config import (
|
||||||
FIRECRAWL_API_KEY,
|
FIRECRAWL_API_KEY,
|
||||||
WEB_LOADER_ENGINE,
|
WEB_LOADER_ENGINE,
|
||||||
WEB_LOADER_CONCURRENT_REQUESTS,
|
WEB_LOADER_CONCURRENT_REQUESTS,
|
||||||
|
WEB_LOADER_TIMEOUT,
|
||||||
WHISPER_MODEL,
|
WHISPER_MODEL,
|
||||||
WHISPER_VAD_FILTER,
|
WHISPER_VAD_FILTER,
|
||||||
WHISPER_LANGUAGE,
|
WHISPER_LANGUAGE,
|
||||||
|
|
@ -922,6 +923,7 @@ app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
|
||||||
|
|
||||||
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
|
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
|
||||||
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
|
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
|
||||||
|
app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT
|
||||||
|
|
||||||
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
|
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
|
||||||
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
|
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
|
||||||
|
|
@ -1031,6 +1033,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||||
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.state.RERANKING_FUNCTION = get_reranking_function(
|
app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from open_webui.models.users import Users, User, UserNameResponse
|
||||||
from open_webui.models.channels import Channels, ChannelMember
|
from open_webui.models.channels import Channels, ChannelMember
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
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 import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
|
@ -108,11 +108,24 @@ class MessageUserResponse(MessageModel):
|
||||||
user: Optional[UserNameResponse] = None
|
user: Optional[UserNameResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageUserSlimResponse(MessageUserResponse):
|
||||||
|
data: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("data", mode="before")
|
||||||
|
def convert_data_to_bool(cls, v):
|
||||||
|
# No data or not a dict → False
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# True if ANY value in the dict is non-empty
|
||||||
|
return any(bool(val) for val in v.values())
|
||||||
|
|
||||||
|
|
||||||
class MessageReplyToResponse(MessageUserResponse):
|
class MessageReplyToResponse(MessageUserResponse):
|
||||||
reply_to_message: Optional[MessageUserResponse] = None
|
reply_to_message: Optional[MessageUserSlimResponse] = None
|
||||||
|
|
||||||
|
|
||||||
class MessageWithReactionsResponse(MessageUserResponse):
|
class MessageWithReactionsResponse(MessageUserSlimResponse):
|
||||||
reactions: list[Reactions]
|
reactions: list[Reactions]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,19 +144,17 @@ class DoclingLoader:
|
||||||
with open(self.file_path, "rb") as f:
|
with open(self.file_path, "rb") as f:
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
headers["X-Api-Key"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
files = {
|
|
||||||
"files": (
|
|
||||||
self.file_path,
|
|
||||||
f,
|
|
||||||
self.mime_type or "application/octet-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{self.url}/v1/convert/file",
|
f"{self.url}/v1/convert/file",
|
||||||
files=files,
|
files={
|
||||||
|
"files": (
|
||||||
|
self.file_path,
|
||||||
|
f,
|
||||||
|
self.mime_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
},
|
||||||
data={
|
data={
|
||||||
"image_export_mode": "placeholder",
|
"image_export_mode": "placeholder",
|
||||||
**self.params,
|
**self.params,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from open_webui.config import (
|
||||||
PLAYWRIGHT_WS_URL,
|
PLAYWRIGHT_WS_URL,
|
||||||
PLAYWRIGHT_TIMEOUT,
|
PLAYWRIGHT_TIMEOUT,
|
||||||
WEB_LOADER_ENGINE,
|
WEB_LOADER_ENGINE,
|
||||||
|
WEB_LOADER_TIMEOUT,
|
||||||
FIRECRAWL_API_BASE_URL,
|
FIRECRAWL_API_BASE_URL,
|
||||||
FIRECRAWL_API_KEY,
|
FIRECRAWL_API_KEY,
|
||||||
TAVILY_API_KEY,
|
TAVILY_API_KEY,
|
||||||
|
|
@ -674,6 +675,20 @@ def get_web_loader(
|
||||||
|
|
||||||
if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web":
|
if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web":
|
||||||
WebLoaderClass = SafeWebBaseLoader
|
WebLoaderClass = SafeWebBaseLoader
|
||||||
|
|
||||||
|
request_kwargs = {}
|
||||||
|
if WEB_LOADER_TIMEOUT.value:
|
||||||
|
try:
|
||||||
|
timeout_value = float(WEB_LOADER_TIMEOUT.value)
|
||||||
|
except ValueError:
|
||||||
|
timeout_value = None
|
||||||
|
|
||||||
|
if timeout_value:
|
||||||
|
request_kwargs["timeout"] = timeout_value
|
||||||
|
|
||||||
|
if request_kwargs:
|
||||||
|
web_loader_args["requests_kwargs"] = request_kwargs
|
||||||
|
|
||||||
if WEB_LOADER_ENGINE.value == "playwright":
|
if WEB_LOADER_ENGINE.value == "playwright":
|
||||||
WebLoaderClass = SafePlaywrightURLLoader
|
WebLoaderClass = SafePlaywrightURLLoader
|
||||||
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
|
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -288,13 +288,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
||||||
"cn",
|
"cn",
|
||||||
]
|
]
|
||||||
|
|
||||||
if ENABLE_LDAP_GROUP_MANAGEMENT:
|
if ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||||
search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}")
|
search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}")
|
||||||
log.info(
|
log.info(
|
||||||
f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes"
|
f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes"
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(f"LDAP search attributes: {search_attributes}")
|
log.info(f"LDAP search attributes: {search_attributes}")
|
||||||
|
|
||||||
search_success = connection_app.search(
|
search_success = connection_app.search(
|
||||||
|
|
@ -302,15 +300,22 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||||
attributes=search_attributes,
|
attributes=search_attributes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not search_success or not connection_app.entries:
|
if not search_success or not connection_app.entries:
|
||||||
raise HTTPException(400, detail="User not found in the LDAP server")
|
raise HTTPException(400, detail="User not found in the LDAP server")
|
||||||
|
|
||||||
entry = connection_app.entries[0]
|
entry = connection_app.entries[0]
|
||||||
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
|
entry_username = entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"].value
|
||||||
email = entry[
|
email = entry[
|
||||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}"
|
f"{LDAP_ATTRIBUTE_FOR_MAIL}"
|
||||||
].value # retrieve the Attribute value
|
].value # retrieve the Attribute value
|
||||||
|
|
||||||
|
username_list = [] # list of usernames from LDAP attribute
|
||||||
|
if isinstance(entry_username, list):
|
||||||
|
username_list = [str(name).lower() for name in entry_username]
|
||||||
|
else:
|
||||||
|
username_list = [str(entry_username).lower()]
|
||||||
|
|
||||||
|
# TODO: support multiple emails if LDAP returns a list
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(400, "User does not have a valid email address.")
|
raise HTTPException(400, "User does not have a valid email address.")
|
||||||
elif isinstance(email, str):
|
elif isinstance(email, str):
|
||||||
|
|
@ -320,13 +325,13 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||||
else:
|
else:
|
||||||
email = str(email).lower()
|
email = str(email).lower()
|
||||||
|
|
||||||
cn = str(entry["cn"])
|
cn = str(entry["cn"]) # common name
|
||||||
user_dn = entry.entry_dn
|
user_dn = entry.entry_dn # user distinguished name
|
||||||
|
|
||||||
user_groups = []
|
user_groups = []
|
||||||
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
|
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
|
||||||
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
|
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
|
||||||
log.info(f"LDAP raw group DNs for user {username}: {group_dns}")
|
log.info(f"LDAP raw group DNs for user {username_list}: {group_dns}")
|
||||||
|
|
||||||
if group_dns:
|
if group_dns:
|
||||||
log.info(f"LDAP group_dns original: {group_dns}")
|
log.info(f"LDAP group_dns original: {group_dns}")
|
||||||
|
|
@ -377,16 +382,16 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
f"LDAP groups for user {username}: {user_groups} (total: {len(user_groups)})"
|
f"LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.info(f"No groups found for user {username}")
|
log.info(f"No groups found for user {username_list}")
|
||||||
elif ENABLE_LDAP_GROUP_MANAGEMENT:
|
elif ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry"
|
f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry"
|
||||||
)
|
)
|
||||||
|
|
||||||
if username == form_data.user.lower():
|
if username_list and form_data.user.lower() in username_list:
|
||||||
connection_user = Connection(
|
connection_user = Connection(
|
||||||
server,
|
server,
|
||||||
user_dn,
|
user_dn,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
from open_webui.socket.main import (
|
from open_webui.socket.main import (
|
||||||
emit_to_users,
|
emit_to_users,
|
||||||
|
|
@ -39,6 +39,8 @@ from open_webui.models.messages import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.utils.files import get_image_base64_from_file_id
|
||||||
|
|
||||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
from open_webui.config import 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
|
||||||
|
|
@ -666,7 +668,16 @@ async def delete_channel_by_id(
|
||||||
|
|
||||||
|
|
||||||
class MessageUserResponse(MessageResponse):
|
class MessageUserResponse(MessageResponse):
|
||||||
pass
|
data: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("data", mode="before")
|
||||||
|
def convert_data_to_bool(cls, v):
|
||||||
|
# No data or not a dict → False
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# True if ANY value in the dict is non-empty
|
||||||
|
return any(bool(val) for val in v.values())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
|
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
|
||||||
|
|
@ -906,6 +917,10 @@ async def model_response_handler(request, channel, message, user):
|
||||||
for file in thread_message_files:
|
for file in thread_message_files:
|
||||||
if file.get("type", "") == "image":
|
if file.get("type", "") == "image":
|
||||||
images.append(file.get("url", ""))
|
images.append(file.get("url", ""))
|
||||||
|
elif file.get("content_type", "").startswith("image/"):
|
||||||
|
image = get_image_base64_from_file_id(file.get("id", ""))
|
||||||
|
if image:
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
thread_history_string = "\n\n".join(thread_history)
|
thread_history_string = "\n\n".join(thread_history)
|
||||||
system_message = {
|
system_message = {
|
||||||
|
|
@ -1078,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():
|
||||||
|
|
@ -1108,7 +1132,7 @@ async def post_new_message(
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
|
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageResponse])
|
||||||
async def get_channel_message(
|
async def get_channel_message(
|
||||||
id: str, message_id: str, user=Depends(get_verified_user)
|
id: str, message_id: str, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
|
|
@ -1142,7 +1166,7 @@ async def get_channel_message(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||||
)
|
)
|
||||||
|
|
||||||
return MessageUserResponse(
|
return MessageResponse(
|
||||||
**{
|
**{
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
"user": UserNameResponse(
|
"user": UserNameResponse(
|
||||||
|
|
@ -1152,6 +1176,48 @@ async def get_channel_message(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# GetChannelMessageData
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict])
|
||||||
|
async def get_channel_message_data(
|
||||||
|
id: str, message_id: str, user=Depends(get_verified_user)
|
||||||
|
):
|
||||||
|
channel = Channels.get_channel_by_id(id)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if channel.type in ["group", "dm"]:
|
||||||
|
if not Channels.is_user_channel_member(channel.id, user.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if user.role != "admin" and not has_access(
|
||||||
|
user.id, type="read", access_control=channel.access_control
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||||
|
)
|
||||||
|
|
||||||
|
message = Messages.get_message_by_id(message_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if message.channel_id != id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||||
|
)
|
||||||
|
|
||||||
|
return message.data
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# PinChannelMessage
|
# PinChannelMessage
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -179,7 +176,7 @@ def upload_file_handler(
|
||||||
user=Depends(get_verified_user),
|
user=Depends(get_verified_user),
|
||||||
background_tasks: Optional[BackgroundTasks] = None,
|
background_tasks: Optional[BackgroundTasks] = None,
|
||||||
):
|
):
|
||||||
log.info(f"file.content_type: {file.content_type}")
|
log.info(f"file.content_type: {file.content_type} {process}")
|
||||||
|
|
||||||
if isinstance(metadata, str):
|
if isinstance(metadata, str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -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 [
|
|
||||||
KnowledgeUserResponse(
|
|
||||||
**knowledge_base.model_dump(),
|
|
||||||
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
|
|
||||||
)
|
|
||||||
for knowledge_base in knowledge_bases
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=list[KnowledgeUserResponse])
|
class KnowledgeAccessResponse(KnowledgeUserResponse):
|
||||||
async def get_knowledge_list(user=Depends(get_verified_user)):
|
write_access: Optional[bool] = False
|
||||||
# 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(
|
class KnowledgeAccessListResponse(BaseModel):
|
||||||
**knowledge_base.model_dump(),
|
items: list[KnowledgeAccessResponse]
|
||||||
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
|
total: int
|
||||||
)
|
|
||||||
for knowledge_base in knowledge_bases
|
|
||||||
]
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
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", 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)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -536,6 +536,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||||
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
||||||
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
||||||
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
||||||
|
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
|
||||||
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||||
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
|
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
|
||||||
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
|
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
|
||||||
|
|
@ -594,6 +595,7 @@ class WebConfig(BaseModel):
|
||||||
SOUGOU_API_SID: Optional[str] = None
|
SOUGOU_API_SID: Optional[str] = None
|
||||||
SOUGOU_API_SK: Optional[str] = None
|
SOUGOU_API_SK: Optional[str] = None
|
||||||
WEB_LOADER_ENGINE: Optional[str] = None
|
WEB_LOADER_ENGINE: Optional[str] = None
|
||||||
|
WEB_LOADER_TIMEOUT: Optional[str] = None
|
||||||
ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
|
ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
|
||||||
PLAYWRIGHT_WS_URL: Optional[str] = None
|
PLAYWRIGHT_WS_URL: Optional[str] = None
|
||||||
PLAYWRIGHT_TIMEOUT: Optional[int] = None
|
PLAYWRIGHT_TIMEOUT: Optional[int] = None
|
||||||
|
|
@ -1071,6 +1073,8 @@ async def update_rag_config(
|
||||||
|
|
||||||
# Web loader settings
|
# Web loader settings
|
||||||
request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE
|
request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE
|
||||||
|
request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT
|
||||||
|
|
||||||
request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = (
|
request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = (
|
||||||
form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION
|
form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION
|
||||||
)
|
)
|
||||||
|
|
@ -1206,6 +1210,7 @@ async def update_rag_config(
|
||||||
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
||||||
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
||||||
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
||||||
|
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
|
||||||
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||||
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
|
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
|
||||||
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
|
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
|
||||||
|
|
@ -1401,6 +1406,7 @@ def save_docs_to_vector_db(
|
||||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run async embedding in sync context
|
# Run async embedding in sync context
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,7 @@ async def update_user_info_by_session_user(
|
||||||
class UserActiveResponse(UserStatus):
|
class UserActiveResponse(UserStatus):
|
||||||
name: str
|
name: str
|
||||||
profile_image_url: Optional[str] = None
|
profile_image_url: Optional[str] = None
|
||||||
|
groups: Optional[list] = []
|
||||||
|
|
||||||
is_active: bool
|
is_active: bool
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
@ -412,11 +413,12 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
|
||||||
)
|
)
|
||||||
|
|
||||||
user = Users.get_user_by_id(user_id)
|
user = Users.get_user_by_id(user_id)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
|
groups = Groups.get_groups_by_member_id(user_id)
|
||||||
return UserActiveResponse(
|
return UserActiveResponse(
|
||||||
**{
|
**{
|
||||||
**user.model_dump(),
|
**user.model_dump(),
|
||||||
|
"groups": [{"id": group.id, "name": group.name} for group in groups],
|
||||||
"is_active": Users.is_user_active(user_id),
|
"is_active": Users.is_user_active(user_id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,9 @@ class YdocManager:
|
||||||
|
|
||||||
async def remove_user_from_all_documents(self, user_id: str):
|
async def remove_user_from_all_documents(self, user_id: str):
|
||||||
if self._redis:
|
if self._redis:
|
||||||
keys = await self._redis.keys(f"{self._redis_key_prefix}:*")
|
keys = []
|
||||||
|
async for key in self._redis.scan_iter(match=f"{self._redis_key_prefix}:*", count=100):
|
||||||
|
keys.append(key)
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key.endswith(":users"):
|
if key.endswith(":users"):
|
||||||
await self._redis.srem(key, user_id)
|
await self._redis.srem(key, user_id)
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -10,7 +10,11 @@ from fastapi import (
|
||||||
Request,
|
Request,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
)
|
)
|
||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from open_webui.storage.provider import Storage
|
||||||
|
from open_webui.models.files import Files
|
||||||
from open_webui.routers.files import upload_file_handler
|
from open_webui.routers.files import upload_file_handler
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
@ -113,3 +117,26 @@ def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
||||||
elif "data:audio/wav;base64" in base64_file_string:
|
elif "data:audio/wav;base64" in base64_file_string:
|
||||||
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_base64_from_file_id(id: str) -> Optional[str]:
|
||||||
|
file = Files.get_file_by_id(id)
|
||||||
|
if not file:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_path = Storage.get_file(file.path)
|
||||||
|
file_path = Path(file_path)
|
||||||
|
|
||||||
|
# Check if the file already exists in the cache
|
||||||
|
if file_path.is_file():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
with open(file_path, "rb") as image_file:
|
||||||
|
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||||
|
content_type, _ = mimetypes.guess_type(file_path.name)
|
||||||
|
return f"data:{content_type};base64,{encoded_string}"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -716,17 +716,18 @@ async def chat_web_search_handler(
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
|
|
||||||
def get_last_images(message_list):
|
def get_images_from_messages(message_list):
|
||||||
images = []
|
images = []
|
||||||
|
|
||||||
for message in reversed(message_list):
|
for message in reversed(message_list):
|
||||||
images_flag = False
|
|
||||||
|
message_images = []
|
||||||
for file in message.get("files", []):
|
for file in message.get("files", []):
|
||||||
if file.get("type") == "image":
|
if file.get("type") == "image":
|
||||||
images.append(file.get("url"))
|
message_images.append(file.get("url"))
|
||||||
images_flag = True
|
|
||||||
|
|
||||||
if images_flag:
|
if message_images:
|
||||||
break
|
images.append(message_images)
|
||||||
|
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
@ -780,7 +781,16 @@ async def chat_image_generation_handler(
|
||||||
user_message = get_last_user_message(message_list)
|
user_message = get_last_user_message(message_list)
|
||||||
|
|
||||||
prompt = user_message
|
prompt = user_message
|
||||||
input_images = get_last_images(message_list)
|
message_images = get_images_from_messages(message_list)
|
||||||
|
|
||||||
|
# Limit to first 2 sets of images
|
||||||
|
# We may want to change this in the future to allow more images
|
||||||
|
input_images = []
|
||||||
|
for idx, images in enumerate(message_images):
|
||||||
|
if idx >= 2:
|
||||||
|
break
|
||||||
|
for image in images:
|
||||||
|
input_images.append(image)
|
||||||
|
|
||||||
system_message_content = ""
|
system_message_content = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ from open_webui.config import (
|
||||||
OAUTH_ALLOWED_DOMAINS,
|
OAUTH_ALLOWED_DOMAINS,
|
||||||
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
||||||
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
|
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
|
||||||
|
OAUTH_AUDIENCE,
|
||||||
WEBHOOK_URL,
|
WEBHOOK_URL,
|
||||||
JWT_EXPIRES_IN,
|
JWT_EXPIRES_IN,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
|
@ -126,6 +127,7 @@ auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
|
||||||
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
|
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
|
||||||
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||||
|
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
|
||||||
|
|
||||||
|
|
||||||
FERNET = None
|
FERNET = None
|
||||||
|
|
@ -1270,7 +1272,12 @@ class OAuthManager:
|
||||||
client = self.get_client(provider)
|
client = self.get_client(provider)
|
||||||
if client is None:
|
if client is None:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
return await client.authorize_redirect(request, redirect_uri)
|
|
||||||
|
kwargs = {}
|
||||||
|
if (auth_manager_config.OAUTH_AUDIENCE):
|
||||||
|
kwargs["audience"] = auth_manager_config.OAUTH_AUDIENCE
|
||||||
|
|
||||||
|
return await client.authorize_redirect(request, redirect_uri, **kwargs)
|
||||||
|
|
||||||
async def handle_callback(self, request, provider, response):
|
async def handle_callback(self, request, provider, response):
|
||||||
if provider not in OAUTH_PROVIDERS:
|
if provider not in OAUTH_PROVIDERS:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import redis
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
REDIS_CLUSTER,
|
REDIS_CLUSTER,
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT,
|
||||||
REDIS_SENTINEL_HOSTS,
|
REDIS_SENTINEL_HOSTS,
|
||||||
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
||||||
REDIS_SENTINEL_PORT,
|
REDIS_SENTINEL_PORT,
|
||||||
|
|
@ -162,6 +163,7 @@ def get_redis_connection(
|
||||||
username=redis_config["username"],
|
username=redis_config["username"],
|
||||||
password=redis_config["password"],
|
password=redis_config["password"],
|
||||||
decode_responses=decode_responses,
|
decode_responses=decode_responses,
|
||||||
|
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
|
||||||
)
|
)
|
||||||
connection = SentinelRedisProxy(
|
connection = SentinelRedisProxy(
|
||||||
sentinel,
|
sentinel,
|
||||||
|
|
@ -188,6 +190,7 @@ def get_redis_connection(
|
||||||
username=redis_config["username"],
|
username=redis_config["username"],
|
||||||
password=redis_config["password"],
|
password=redis_config["password"],
|
||||||
decode_responses=decode_responses,
|
decode_responses=decode_responses,
|
||||||
|
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
|
||||||
)
|
)
|
||||||
connection = SentinelRedisProxy(
|
connection = SentinelRedisProxy(
|
||||||
sentinel,
|
sentinel,
|
||||||
|
|
|
||||||
|
|
@ -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,28 +24,28 @@ 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
|
||||||
langchain-community==0.3.29
|
langchain-community==0.3.29
|
||||||
fake-useragent==2.2.0
|
fake-useragent==2.2.0
|
||||||
|
|
||||||
chromadb==1.1.0
|
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,44 +13,45 @@ 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
|
||||||
langchain-community==0.3.29
|
langchain-community==0.3.29
|
||||||
|
|
||||||
fake-useragent==2.2.0
|
fake-useragent==2.2.0
|
||||||
chromadb==1.1.0
|
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.4
|
pymilvus==2.6.5
|
||||||
qdrant-client==1.14.3
|
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.49.1-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.49.1 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:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
# Helm Charts
|
|
||||||
Open WebUI Helm Charts are now hosted in a separate repo, which can be found here: https://github.com/open-webui/helm-charts
|
|
||||||
|
|
||||||
The charts are released at https://helm.openwebui.com.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
resources:
|
|
||||||
- open-webui.yaml
|
|
||||||
- ollama-service.yaml
|
|
||||||
- ollama-statefulset.yaml
|
|
||||||
- webui-deployment.yaml
|
|
||||||
- webui-service.yaml
|
|
||||||
- webui-ingress.yaml
|
|
||||||
- webui-pvc.yaml
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: ollama-service
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: ollama
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 11434
|
|
||||||
targetPort: 11434
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: StatefulSet
|
|
||||||
metadata:
|
|
||||||
name: ollama
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
serviceName: "ollama"
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: ollama
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: ollama
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: ollama
|
|
||||||
image: ollama/ollama:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 11434
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "2000m"
|
|
||||||
memory: "2Gi"
|
|
||||||
limits:
|
|
||||||
cpu: "4000m"
|
|
||||||
memory: "4Gi"
|
|
||||||
nvidia.com/gpu: "0"
|
|
||||||
volumeMounts:
|
|
||||||
- name: ollama-volume
|
|
||||||
mountPath: /root/.ollama
|
|
||||||
tty: true
|
|
||||||
volumeClaimTemplates:
|
|
||||||
- metadata:
|
|
||||||
name: ollama-volume
|
|
||||||
spec:
|
|
||||||
accessModes: [ "ReadWriteOnce" ]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 30Gi
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: open-webui
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: open-webui-deployment
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: open-webui
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: open-webui
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: open-webui
|
|
||||||
image: ghcr.io/open-webui/open-webui:main
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "500m"
|
|
||||||
memory: "500Mi"
|
|
||||||
limits:
|
|
||||||
cpu: "1000m"
|
|
||||||
memory: "1Gi"
|
|
||||||
env:
|
|
||||||
- name: OLLAMA_BASE_URL
|
|
||||||
value: "http://ollama-service.open-webui.svc.cluster.local:11434"
|
|
||||||
tty: true
|
|
||||||
volumeMounts:
|
|
||||||
- name: webui-volume
|
|
||||||
mountPath: /app/backend/data
|
|
||||||
volumes:
|
|
||||||
- name: webui-volume
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: open-webui-pvc
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: open-webui-ingress
|
|
||||||
namespace: open-webui
|
|
||||||
#annotations:
|
|
||||||
# Use appropriate annotations for your Ingress controller, e.g., for NGINX:
|
|
||||||
# nginx.ingress.kubernetes.io/rewrite-target: /
|
|
||||||
spec:
|
|
||||||
rules:
|
|
||||||
- host: open-webui.minikube.local
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: open-webui-service
|
|
||||||
port:
|
|
||||||
number: 8080
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: open-webui
|
|
||||||
name: open-webui-pvc
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
accessModes: ["ReadWriteOnce"]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 2Gi
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: open-webui-service
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
type: NodePort # Use LoadBalancer if you're on a cloud that supports it
|
|
||||||
selector:
|
|
||||||
app: open-webui
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8080
|
|
||||||
targetPort: 8080
|
|
||||||
# If using NodePort, you can optionally specify the nodePort:
|
|
||||||
# nodePort: 30000
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
|
|
||||||
resources:
|
|
||||||
- ../base
|
|
||||||
|
|
||||||
patches:
|
|
||||||
- path: ollama-statefulset-gpu.yaml
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: StatefulSet
|
|
||||||
metadata:
|
|
||||||
name: ollama
|
|
||||||
namespace: open-webui
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: ollama
|
|
||||||
serviceName: "ollama"
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: ollama
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
nvidia.com/gpu: "1"
|
|
||||||
|
|
@ -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,97 +21,98 @@ 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",
|
||||||
"langchain-community==0.3.29",
|
"langchain-community==0.3.29",
|
||||||
|
|
||||||
"fake-useragent==2.2.0",
|
"fake-useragent==2.2.0",
|
||||||
"chromadb==1.0.20",
|
"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.14.3",
|
"qdrant-client==1.16.1",
|
||||||
"weaviate-client==4.17.0",
|
|
||||||
"pymilvus==2.6.4",
|
"pymilvus==2.6.4",
|
||||||
|
"weaviate-client==4.18.3",
|
||||||
|
"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 */
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -491,6 +491,44 @@ export const getChannelThreadMessages = async (
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMessageData = async (
|
||||||
|
token: string = '',
|
||||||
|
channel_id: string,
|
||||||
|
message_id: string
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/data`,
|
||||||
|
{
|
||||||
|
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 MessageForm = {
|
type MessageForm = {
|
||||||
temp_id?: string;
|
temp_id?: string;
|
||||||
reply_to_id?: string;
|
reply_to_id?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
import { splitStream } from '$lib/utils';
|
import { splitStream } from '$lib/utils';
|
||||||
|
|
||||||
export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
|
export const uploadFile = async (
|
||||||
|
token: string,
|
||||||
|
file: File,
|
||||||
|
metadata?: object | null,
|
||||||
|
process?: boolean | null
|
||||||
|
) => {
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('file', file);
|
data.append('file', file);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
data.append('metadata', JSON.stringify(metadata));
|
data.append('metadata', JSON.stringify(metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (process !== undefined && process !== null) {
|
||||||
|
searchParams.append('process', String(process));
|
||||||
|
}
|
||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/files/?${searchParams.toString()}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
if (pipeline && (pipeline?.valves ?? false)) {
|
if (pipeline && (pipeline?.valves ?? false)) {
|
||||||
for (const property in valves_spec.properties) {
|
for (const property in valves_spec.properties) {
|
||||||
if (valves_spec.properties[property]?.type === 'array') {
|
if (valves_spec.properties[property]?.type === 'array') {
|
||||||
valves[property] = valves[property].split(',').map((v) => v.trim());
|
valves[property] = (valves[property] ?? '').split(',').map((v) => v.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -767,6 +767,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if webConfig.WEB_LOADER_ENGINE === '' || webConfig.WEB_LOADER_ENGINE === 'safe_web'}
|
{#if webConfig.WEB_LOADER_ENGINE === '' || webConfig.WEB_LOADER_ENGINE === 'safe_web'}
|
||||||
|
<div class=" mb-2.5 flex w-full justify-between">
|
||||||
|
<div class=" self-center text-xs font-medium">
|
||||||
|
{$i18n.t('Timeout')}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center relative">
|
||||||
|
<input
|
||||||
|
class="flex-1 w-full text-sm bg-transparent outline-hidden"
|
||||||
|
placeholder={$i18n.t('Timeout')}
|
||||||
|
bind:value={webConfig.WEB_LOADER_TIMEOUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class=" mb-2.5 flex w-full justify-between">
|
<div class=" mb-2.5 flex w-full justify-between">
|
||||||
<div class=" self-center text-xs font-medium">
|
<div class=" self-center text-xs font-medium">
|
||||||
{$i18n.t('Verify SSL Certificate')}
|
{$i18n.t('Verify SSL Certificate')}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -421,13 +422,10 @@
|
||||||
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
files = [
|
const blob = await (await fetch(imageUrl)).blob();
|
||||||
...files,
|
const compressedFile = new File([blob], file.name, { type: file.type });
|
||||||
{
|
|
||||||
type: 'image',
|
uploadFileHandler(compressedFile, false);
|
||||||
url: `${imageUrl}`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
|
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
|
||||||
|
|
@ -437,7 +435,7 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFileHandler = async (file) => {
|
const uploadFileHandler = async (file, process = true) => {
|
||||||
const tempItemId = uuidv4();
|
const tempItemId = uuidv4();
|
||||||
const fileItem = {
|
const fileItem = {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
|
|
@ -461,19 +459,19 @@
|
||||||
|
|
||||||
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);
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata, process);
|
||||||
|
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
console.info('File upload completed:', {
|
console.info('File upload completed:', {
|
||||||
|
|
@ -492,6 +490,7 @@
|
||||||
fileItem.id = uploadedFile.id;
|
fileItem.id = uploadedFile.id;
|
||||||
fileItem.collection_name =
|
fileItem.collection_name =
|
||||||
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
|
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
|
||||||
|
fileItem.content_type = uploadedFile.meta?.content_type || uploadedFile.content_type;
|
||||||
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
||||||
|
|
||||||
files = files;
|
files = files;
|
||||||
|
|
@ -807,11 +806,11 @@
|
||||||
{#if files.length > 0}
|
{#if files.length > 0}
|
||||||
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
||||||
{#each files as file, fileIdx}
|
{#each files as file, fileIdx}
|
||||||
{#if file.type === 'image'}
|
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
|
||||||
<div class=" relative group">
|
<div class=" relative group">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Image
|
<Image
|
||||||
src={file.url}
|
src={`${file.url}${file?.content_type ? '/content' : ''}`}
|
||||||
alt=""
|
alt=""
|
||||||
imageClassName=" size-10 rounded-xl object-cover"
|
imageClassName=" size-10 rounded-xl object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@
|
||||||
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
|
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
|
||||||
<Message
|
<Message
|
||||||
{message}
|
{message}
|
||||||
|
{channel}
|
||||||
{thread}
|
{thread}
|
||||||
replyToMessage={replyToMessage?.id === message.id}
|
replyToMessage={replyToMessage?.id === message.id}
|
||||||
disabled={!channel?.write_access || message?.temp_id}
|
disabled={!channel?.write_access || message?.temp_id}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
||||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
import { getMessageData } from '$lib/apis/channels';
|
||||||
|
|
||||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||||
|
|
@ -42,6 +43,8 @@
|
||||||
export let className = '';
|
export let className = '';
|
||||||
|
|
||||||
export let message;
|
export let message;
|
||||||
|
export let channel;
|
||||||
|
|
||||||
export let showUserProfile = true;
|
export let showUserProfile = true;
|
||||||
export let thread = false;
|
export let thread = false;
|
||||||
|
|
||||||
|
|
@ -61,6 +64,21 @@
|
||||||
let edit = false;
|
let edit = false;
|
||||||
let editedContent = null;
|
let editedContent = null;
|
||||||
let showDeleteConfirmDialog = false;
|
let showDeleteConfirmDialog = false;
|
||||||
|
|
||||||
|
const loadMessageData = async () => {
|
||||||
|
if (message && message?.data) {
|
||||||
|
const res = await getMessageData(localStorage.token, channel?.id, message.id);
|
||||||
|
if (res) {
|
||||||
|
message.data = res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (message && message?.data) {
|
||||||
|
await loadMessageData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|
@ -314,12 +332,27 @@
|
||||||
</Name>
|
</Name>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (message?.data?.files ?? []).length > 0}
|
{#if message?.data === true}
|
||||||
|
<!-- loading indicator -->
|
||||||
|
<div class=" my-2">
|
||||||
|
<Skeleton />
|
||||||
|
</div>
|
||||||
|
{:else if (message?.data?.files ?? []).length > 0}
|
||||||
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
|
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
|
||||||
{#each message?.data?.files as file}
|
{#each message?.data?.files as file}
|
||||||
<div>
|
<div>
|
||||||
{#if file.type === 'image'}
|
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
|
||||||
<Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
|
<Image
|
||||||
|
src={`${file.url}${file?.content_type ? '/content' : ''}`}
|
||||||
|
alt={file.name}
|
||||||
|
imageClassName=" max-h-96 rounded-lg"
|
||||||
|
/>
|
||||||
|
{:else if file.type === 'video' || (file?.content_type ?? '').startsWith('video/')}
|
||||||
|
<video
|
||||||
|
src={`${file.url}${file?.content_type ? '/content' : ''}`}
|
||||||
|
controls
|
||||||
|
class=" max-h-96 rounded-lg"
|
||||||
|
></video>
|
||||||
{:else}
|
{:else}
|
||||||
<FileItem
|
<FileItem
|
||||||
item={file}
|
item={file}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,18 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if (user?.groups ?? []).length > 0}
|
||||||
|
<div class="mx-3.5 mt-2 flex gap-0.5">
|
||||||
|
{#each user.groups as group}
|
||||||
|
<div
|
||||||
|
class="px-1.5 py-0.5 rounded-lg bg-gray-50 dark:text-white dark:bg-gray-900/50 text-black transition text-xs"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $_user?.id !== user.id}
|
{#if $_user?.id !== user.id}
|
||||||
<hr class="border-gray-100/50 dark:border-gray-800/50 my-2.5" />
|
<hr class="border-gray-100/50 dark:border-gray-800/50 my-2.5" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -902,8 +902,15 @@
|
||||||
|
|
||||||
const initNewChat = async () => {
|
const initNewChat = async () => {
|
||||||
console.log('initNewChat');
|
console.log('initNewChat');
|
||||||
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
if ($user?.role !== 'admin') {
|
||||||
await temporaryChatEnabled.set(true);
|
if ($user?.permissions?.chat?.temporary_enforced) {
|
||||||
|
await temporaryChatEnabled.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user?.permissions?.chat?.temporary) {
|
||||||
|
await temporaryChatEnabled.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($settings?.temporaryChatByDefault ?? false) {
|
if ($settings?.temporaryChatByDefault ?? false) {
|
||||||
|
|
@ -2377,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,57 +1686,7 @@
|
||||||
</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 (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
|
||||||
<!-- {$i18n.t('Record voice')} -->
|
|
||||||
<Tooltip content={$i18n.t('Dictate')}>
|
|
||||||
<button
|
|
||||||
id="voice-input-button"
|
|
||||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
|
|
||||||
type="button"
|
|
||||||
on:click={async () => {
|
|
||||||
try {
|
|
||||||
let stream = await navigator.mediaDevices
|
|
||||||
.getUserMedia({ audio: true })
|
|
||||||
.catch(function (err) {
|
|
||||||
toast.error(
|
|
||||||
$i18n.t(
|
|
||||||
`Permission denied when accessing microphone: {{error}}`,
|
|
||||||
{
|
|
||||||
error: err
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
recording = true;
|
|
||||||
const tracks = stream.getTracks();
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
}
|
|
||||||
stream = null;
|
|
||||||
} catch {
|
|
||||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Voice Input"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5 translate-y-[0.5px]"
|
|
||||||
>
|
|
||||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
|
||||||
<path
|
|
||||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
||||||
<div class=" flex items-center">
|
<div class=" flex items-center">
|
||||||
<Tooltip content={$i18n.t('Stop')}>
|
<Tooltip content={$i18n.t('Stop')}>
|
||||||
|
|
@ -1695,95 +1711,163 @@
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
{:else}
|
||||||
<div class=" flex items-center">
|
{#if prompt !== '' && !history?.currentId && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
|
||||||
<!-- {$i18n.t('Call')} -->
|
<Tooltip content={$i18n.t('Create note')} className=" flex items-center">
|
||||||
<Tooltip content={$i18n.t('Voice mode')}>
|
|
||||||
<button
|
<button
|
||||||
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
id="send-message-button"
|
||||||
|
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center"
|
||||||
|
type="button"
|
||||||
|
disabled={prompt === '' && files.length === 0}
|
||||||
|
on:click={() => {
|
||||||
|
createNote();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PageEdit className="size-4.5 translate-y-[0.5px]" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
||||||
|
<!-- {$i18n.t('Record voice')} -->
|
||||||
|
<Tooltip content={$i18n.t('Dictate')}>
|
||||||
|
<button
|
||||||
|
id="voice-input-button"
|
||||||
|
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={async () => {
|
on:click={async () => {
|
||||||
if (selectedModels.length > 1) {
|
|
||||||
toast.error($i18n.t('Select only one model to call'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($config.audio.stt.engine === 'web') {
|
|
||||||
toast.error(
|
|
||||||
$i18n.t('Call feature is not supported when using Web STT engine')
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// check if user has access to getUserMedia
|
|
||||||
try {
|
try {
|
||||||
let stream = await navigator.mediaDevices.getUserMedia({
|
let stream = await navigator.mediaDevices
|
||||||
audio: true
|
.getUserMedia({ audio: true })
|
||||||
});
|
.catch(function (err) {
|
||||||
// If the user grants the permission, proceed to show the call overlay
|
toast.error(
|
||||||
|
$i18n.t(
|
||||||
|
`Permission denied when accessing microphone: {{error}}`,
|
||||||
|
{
|
||||||
|
error: err
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
|
recording = true;
|
||||||
const tracks = stream.getTracks();
|
const tracks = stream.getTracks();
|
||||||
tracks.forEach((track) => track.stop());
|
tracks.forEach((track) => track.stop());
|
||||||
}
|
}
|
||||||
|
|
||||||
stream = null;
|
stream = null;
|
||||||
|
} catch {
|
||||||
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||||
// If the user has not initialized the TTS worker, initialize it
|
|
||||||
if (!$TTSWorker) {
|
|
||||||
await TTSWorker.set(
|
|
||||||
new KokoroWorker({
|
|
||||||
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await $TTSWorker.init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showCallOverlay.set(true);
|
|
||||||
showControls.set(true);
|
|
||||||
} catch (err) {
|
|
||||||
// If the user denies the permission or an error occurs, show an error message
|
|
||||||
toast.error(
|
|
||||||
$i18n.t('Permission denied when accessing media devices')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={$i18n.t('Voice mode')}
|
aria-label="Voice Input"
|
||||||
>
|
|
||||||
<Voice className="size-5" strokeWidth="2.5" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class=" flex items-center">
|
|
||||||
<Tooltip content={$i18n.t('Send message')}>
|
|
||||||
<button
|
|
||||||
id="send-message-button"
|
|
||||||
class="{!(prompt === '' && files.length === 0)
|
|
||||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
|
||||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
|
||||||
type="submit"
|
|
||||||
disabled={prompt === '' && files.length === 0}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="size-5"
|
class="size-5 translate-y-[0.5px]"
|
||||||
>
|
>
|
||||||
|
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
|
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
||||||
|
<div class=" flex items-center">
|
||||||
|
<!-- {$i18n.t('Call')} -->
|
||||||
|
<Tooltip content={$i18n.t('Voice mode')}>
|
||||||
|
<button
|
||||||
|
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
||||||
|
type="button"
|
||||||
|
on:click={async () => {
|
||||||
|
if (selectedModels.length > 1) {
|
||||||
|
toast.error($i18n.t('Select only one model to call'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($config.audio.stt.engine === 'web') {
|
||||||
|
toast.error(
|
||||||
|
$i18n.t('Call feature is not supported when using Web STT engine')
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// check if user has access to getUserMedia
|
||||||
|
try {
|
||||||
|
let stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true
|
||||||
|
});
|
||||||
|
// If the user grants the permission, proceed to show the call overlay
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
const tracks = stream.getTracks();
|
||||||
|
tracks.forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
stream = null;
|
||||||
|
|
||||||
|
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
||||||
|
// If the user has not initialized the TTS worker, initialize it
|
||||||
|
if (!$TTSWorker) {
|
||||||
|
await TTSWorker.set(
|
||||||
|
new KokoroWorker({
|
||||||
|
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await $TTSWorker.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showCallOverlay.set(true);
|
||||||
|
showControls.set(true);
|
||||||
|
} catch (err) {
|
||||||
|
// If the user denies the permission or an error occurs, show an error message
|
||||||
|
toast.error(
|
||||||
|
$i18n.t('Permission denied when accessing media devices')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={$i18n.t('Voice mode')}
|
||||||
|
>
|
||||||
|
<Voice className="size-5" strokeWidth="2.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class=" flex items-center">
|
||||||
|
<Tooltip content={$i18n.t('Send message')}>
|
||||||
|
<button
|
||||||
|
id="send-message-button"
|
||||||
|
class="{!(prompt === '' && files.length === 0)
|
||||||
|
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||||
|
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
||||||
|
type="submit"
|
||||||
|
disabled={prompt === '' && files.length === 0}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</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,35 +23,24 @@
|
||||||
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.startsWith('http')
|
||||||
...(query
|
? isYoutubeUrl(query)
|
||||||
? fuse.search(query).map((e) => {
|
? [{ type: 'youtube', name: query, description: query }]
|
||||||
return e.item;
|
: [
|
||||||
})
|
{
|
||||||
: items),
|
type: 'web',
|
||||||
|
name: query,
|
||||||
...(query.startsWith('http')
|
description: query
|
||||||
? isYoutubeUrl(query)
|
}
|
||||||
? [{ type: 'youtube', name: query, description: query }]
|
]
|
||||||
: [
|
: []),
|
||||||
{
|
...items
|
||||||
type: 'web',
|
];
|
||||||
name: query,
|
|
||||||
description: query
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: [])
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (query) {
|
$: if (query) {
|
||||||
selectedIdx = 0;
|
selectedIdx = 0;
|
||||||
|
|
@ -71,105 +62,70 @@
|
||||||
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderItems = async () => {
|
||||||
|
folderItems = $folders
|
||||||
|
.map((folder) => ({
|
||||||
|
...folder,
|
||||||
|
type: 'folder',
|
||||||
|
description: $i18n.t('Folder'),
|
||||||
|
title: folder.name
|
||||||
|
}))
|
||||||
|
.filter((folder) => folder.name.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKnowledgeItems = async () => {
|
||||||
|
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
knowledgeItems = res.items.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'collection'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKnowledgeFileItems = async () => {
|
||||||
|
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 () => {
|
onMount(async () => {
|
||||||
let legacy_documents = knowledge
|
if ($folders === null) {
|
||||||
.filter((item) => item?.meta?.document)
|
await folders.set(await getFolders(localStorage.token));
|
||||||
.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,
|
|
||||||
type: 'folder',
|
|
||||||
description: $i18n.t('Folder'),
|
|
||||||
title: folder.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
items = [
|
|
||||||
...folder_items,
|
|
||||||
...collections,
|
|
||||||
...collection_files,
|
|
||||||
...legacy_collections,
|
|
||||||
...legacy_documents
|
|
||||||
].map((item) => {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
fuse = new Fuse(items, {
|
|
||||||
keys: ['name', 'description']
|
|
||||||
});
|
|
||||||
|
|
||||||
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,37 +239,35 @@
|
||||||
</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')
|
: !fileUploadEnabled
|
||||||
: !fileUploadEnabled
|
? $i18n.t('You do not have permission to upload files.')
|
||||||
? $i18n.t('You do not have permission to upload files.')
|
: ''}
|
||||||
: ''}
|
className="w-full"
|
||||||
className="w-full"
|
>
|
||||||
|
<button
|
||||||
|
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
|
||||||
|
? 'opacity-50'
|
||||||
|
: ''}"
|
||||||
|
on:click={() => {
|
||||||
|
tab = 'knowledge';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Database />
|
||||||
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
|
|
||||||
? 'opacity-50'
|
|
||||||
: ''}"
|
|
||||||
on:click={() => {
|
|
||||||
tab = 'knowledge';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Database />
|
|
||||||
|
|
||||||
<div class="flex items-center w-full justify-between">
|
<div class="flex items-center w-full justify-between">
|
||||||
<div class=" line-clamp-1">
|
<div class=" line-clamp-1">
|
||||||
{$i18n.t('Attach Knowledge')}
|
{$i18n.t('Attach Knowledge')}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-gray-500">
|
|
||||||
<ChevronRight />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
</Tooltip>
|
<div class="text-gray-500">
|
||||||
{/if}
|
<ChevronRight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{#if ($chats ?? []).length > 0}
|
{#if ($chats ?? []).length > 0}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
|
||||||
|
|
@ -4,157 +4,296 @@
|
||||||
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initSelectedFileItems = async () => {
|
||||||
|
selectedFileItemsPage = 1;
|
||||||
|
selectedFileItems = null;
|
||||||
|
selectedFileItemsTotal = null;
|
||||||
|
selectedFileAllItemsLoaded = false;
|
||||||
|
selectedFileItemsLoading = false;
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let legacy_documents = $knowledge
|
selectedFileItemsLoading = false;
|
||||||
.filter((item) => item?.meta?.document)
|
return res;
|
||||||
.map((item) => ({
|
};
|
||||||
...item,
|
|
||||||
type: 'file'
|
|
||||||
}));
|
|
||||||
|
|
||||||
let legacy_collections =
|
let page = 1;
|
||||||
legacy_documents.length > 0
|
let items = null;
|
||||||
? [
|
let total = null;
|
||||||
{
|
|
||||||
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
|
let itemsLoading = false;
|
||||||
.reduce((a, item) => {
|
let allItemsLoaded = false;
|
||||||
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
|
$: if (loaded) {
|
||||||
.filter((item) => !item?.meta?.document)
|
init();
|
||||||
.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 } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
await tick();
|
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">
|
||||||
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
|
{$i18n.t('No knowledge bases found.')}
|
||||||
selectedIdx
|
</div>
|
||||||
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
|
{:else}
|
||||||
: ''}"
|
{#each items as item, idx (item.id)}
|
||||||
type="button"
|
<div
|
||||||
on:click={() => {
|
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
|
||||||
console.log(item);
|
selectedIdx
|
||||||
onSelect(item);
|
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
|
||||||
}}
|
: ''}"
|
||||||
on:mousemove={() => {
|
>
|
||||||
selectedIdx = idx;
|
<button
|
||||||
}}
|
class="w-full flex-1"
|
||||||
on:mouseleave={() => {
|
type="button"
|
||||||
if (idx === 0) {
|
on:click={() => {
|
||||||
selectedIdx = -1;
|
onSelect({
|
||||||
}
|
type: 'collection',
|
||||||
}}
|
...item
|
||||||
data-selected={idx === selectedIdx}
|
});
|
||||||
>
|
}}
|
||||||
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
|
on:mousemove={() => {
|
||||||
<Tooltip
|
selectedIdx = idx;
|
||||||
content={item?.legacy
|
}}
|
||||||
? $i18n.t('Legacy')
|
on:mouseleave={() => {
|
||||||
: item?.type === 'file'
|
if (idx === 0) {
|
||||||
? $i18n.t('File')
|
selectedIdx = -1;
|
||||||
: item?.type === 'collection'
|
}
|
||||||
? $i18n.t('Collection')
|
}}
|
||||||
: ''}
|
data-selected={idx === selectedIdx}
|
||||||
placement="top"
|
|
||||||
>
|
>
|
||||||
{#if item?.type === 'collection'}
|
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
|
||||||
<Database className="size-4" />
|
<Tooltip content={$i18n.t('Collection')} placement="top">
|
||||||
{:else}
|
<Database className="size-4" />
|
||||||
<DocumentPage className="size-4" />
|
</Tooltip>
|
||||||
{/if}
|
|
||||||
</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>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
{/each}
|
{#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}
|
||||||
|
|
||||||
|
{#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 {
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,11 @@
|
||||||
`<sup class="footnote-ref footnote-ref-text">${token.escapedText}</sup>`
|
`<sup class="footnote-ref footnote-ref-text">${token.escapedText}</sup>`
|
||||||
) || ''}
|
) || ''}
|
||||||
{:else if token.type === 'citation'}
|
{:else if token.type === 'citation'}
|
||||||
<SourceToken {id} {token} {sourceIds} onClick={onSourceClick} />
|
{#if (sourceIds ?? []).length > 0}
|
||||||
<!-- {#if token.ids && token.ids.length > 0}
|
<SourceToken {id} {token} {sourceIds} onClick={onSourceClick} />
|
||||||
{#each token.ids as sourceId}
|
{:else}
|
||||||
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} onClick={onSourceClick} />
|
<TextToken {token} {done} />
|
||||||
{/each}
|
{/if}
|
||||||
{/if} -->
|
|
||||||
{:else if token.type === 'text'}
|
{:else if token.type === 'text'}
|
||||||
<TextToken {token} {done} />
|
<TextToken {token} {done} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -39,37 +39,41 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if (token?.ids ?? []).length == 1}
|
{#if sourceIds}
|
||||||
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
|
{#if (token?.ids ?? []).length == 1}
|
||||||
{:else}
|
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
|
||||||
<LinkPreview.Root openDelay={0} bind:open={openPreview}>
|
{:else}
|
||||||
<LinkPreview.Trigger>
|
<LinkPreview.Root openDelay={0} bind:open={openPreview}>
|
||||||
<button
|
<LinkPreview.Trigger>
|
||||||
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
|
<button
|
||||||
on:click={() => {
|
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
|
||||||
openPreview = !openPreview;
|
on:click={() => {
|
||||||
}}
|
openPreview = !openPreview;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="line-clamp-1">
|
||||||
|
{getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))}
|
||||||
|
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</LinkPreview.Trigger>
|
||||||
|
<LinkPreview.Content
|
||||||
|
class="z-[999]"
|
||||||
|
align="start"
|
||||||
|
strategy="fixed"
|
||||||
|
sideOffset={6}
|
||||||
|
el={containerElement}
|
||||||
>
|
>
|
||||||
<span class="line-clamp-1">
|
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
|
||||||
{getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))}
|
{#each token.ids as sourceId}
|
||||||
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
|
<div class="">
|
||||||
</span>
|
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} {onClick} />
|
||||||
</button>
|
</div>
|
||||||
</LinkPreview.Trigger>
|
{/each}
|
||||||
<LinkPreview.Content
|
</div>
|
||||||
class="z-[999]"
|
</LinkPreview.Content>
|
||||||
align="start"
|
</LinkPreview.Root>
|
||||||
strategy="fixed"
|
{/if}
|
||||||
sideOffset={6}
|
{:else}
|
||||||
el={containerElement}
|
<span>{token.raw}</span>
|
||||||
>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
|
|
||||||
{#each token.ids as sourceId}
|
|
||||||
<div class="">
|
|
||||||
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} {onClick} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</LinkPreview.Content>
|
|
||||||
</LinkPreview.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1460,37 +1461,35 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLastMessage}
|
{#each model?.actions ?? [] as action}
|
||||||
{#each model?.actions ?? [] as action}
|
<Tooltip content={action.name} placement="bottom">
|
||||||
<Tooltip content={action.name} placement="bottom">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
aria-label={action.name}
|
||||||
aria-label={action.name}
|
class="{isLastMessage || ($settings?.highContrastMode ?? false)
|
||||||
class="{isLastMessage || ($settings?.highContrastMode ?? false)
|
? 'visible'
|
||||||
? 'visible'
|
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
|
||||||
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
|
on:click={() => {
|
||||||
on:click={() => {
|
actionMessage(action.id, message);
|
||||||
actionMessage(action.id, message);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{#if action?.icon}
|
||||||
{#if action?.icon}
|
<div class="size-4">
|
||||||
<div class="size-4">
|
<img
|
||||||
<img
|
src={action.icon}
|
||||||
src={action.icon}
|
class="w-4 h-4 {action.icon.includes('svg')
|
||||||
class="w-4 h-4 {action.icon.includes('svg')
|
? 'dark:invert-[80%]'
|
||||||
? 'dark:invert-[80%]'
|
: ''}"
|
||||||
: ''}"
|
style="fill: currentColor;"
|
||||||
style="fill: currentColor;"
|
alt={action.name}
|
||||||
alt={action.name}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
{:else}
|
<Sparkles strokeWidth="2.1" className="size-4" />
|
||||||
<Sparkles strokeWidth="2.1" className="size-4" />
|
{/if}
|
||||||
{/if}
|
</button>
|
||||||
</button>
|
</Tooltip>
|
||||||
</Tooltip>
|
{/each}
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -364,7 +364,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
textScale = Math.max(1, textScale);
|
textScale = Math.max(1, parseFloat((textScale - 0.1).toFixed(2)));
|
||||||
setTextScaleHandler(textScale);
|
setTextScaleHandler(textScale);
|
||||||
}}
|
}}
|
||||||
aria-labelledby="ui-scale-label"
|
aria-labelledby="ui-scale-label"
|
||||||
|
|
@ -397,7 +397,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
textScale = Math.min(1.5, textScale);
|
textScale = Math.min(1.5, parseFloat((textScale + 0.1).toFixed(2)));
|
||||||
setTextScaleHandler(textScale);
|
setTextScaleHandler(textScale);
|
||||||
}}
|
}}
|
||||||
aria-labelledby="ui-scale-label"
|
aria-labelledby="ui-scale-label"
|
||||||
|
|
@ -713,24 +713,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{#if $user.role === 'admin' || $user?.permissions?.chat?.temporary}
|
||||||
<div class=" py-0.5 flex w-full justify-between">
|
<div>
|
||||||
<div id="temp-chat-default-label" class=" self-center text-xs">
|
<div class=" py-0.5 flex w-full justify-between">
|
||||||
{$i18n.t('Temporary Chat by Default')}
|
<div id="temp-chat-default-label" class=" self-center text-xs">
|
||||||
</div>
|
{$i18n.t('Temporary Chat by Default')}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 p-1">
|
<div class="flex items-center gap-2 p-1">
|
||||||
<Switch
|
<Switch
|
||||||
ariaLabelledbyId="temp-chat-default-label"
|
ariaLabelledbyId="temp-chat-default-label"
|
||||||
tooltip={true}
|
tooltip={true}
|
||||||
bind:state={temporaryChatByDefault}
|
bind:state={temporaryChatByDefault}
|
||||||
on:change={() => {
|
on:change={() => {
|
||||||
saveSettings({ temporaryChatByDefault });
|
saveSettings({ temporaryChatByDefault });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class=" py-0.5 flex w-full justify-between">
|
<div class=" py-0.5 flex w-full justify-between">
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
document.body.removeChild(popupElement);
|
if (popupElement && popupElement.parentNode) {
|
||||||
|
try {
|
||||||
|
popupElement.parentNode.removeChild(popupElement);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to remove popupElement:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -437,33 +437,37 @@
|
||||||
{#if !$temporaryChatEnabled && chat?.id}
|
{#if !$temporaryChatEnabled && chat?.id}
|
||||||
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1" />
|
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1" />
|
||||||
|
|
||||||
<DropdownMenu.Sub>
|
{#if $folders.length > 0}
|
||||||
<DropdownMenu.SubTrigger
|
<DropdownMenu.Sub>
|
||||||
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 select-none w-full"
|
<DropdownMenu.SubTrigger
|
||||||
>
|
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 select-none w-full"
|
||||||
<Folder strokeWidth="1.5" />
|
>
|
||||||
|
<Folder strokeWidth="1.5" />
|
||||||
|
|
||||||
<div class="flex items-center">{$i18n.t('Move')}</div>
|
<div class="flex items-center">{$i18n.t('Move')}</div>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
|
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
|
||||||
transition={flyAndScale}
|
transition={flyAndScale}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
|
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
|
||||||
<DropdownMenu.Item
|
{#if folder?.id}
|
||||||
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"
|
<DropdownMenu.Item
|
||||||
on:click={() => {
|
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"
|
||||||
moveChatHandler(chat?.id, folder?.id);
|
on:click={() => {
|
||||||
}}
|
moveChatHandler(chat.id, folder.id);
|
||||||
>
|
}}
|
||||||
<Folder strokeWidth="1.5" />
|
>
|
||||||
|
<Folder strokeWidth="1.5" />
|
||||||
|
|
||||||
<div class="flex items-center">{folder?.name ?? 'Folder'}</div>
|
<div class="flex items-center">{folder.name ?? 'Folder'}</div>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/each}
|
{/if}
|
||||||
</DropdownMenu.SubContent>
|
{/each}
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
@ -182,12 +183,18 @@
|
||||||
|
|
||||||
const initChannels = async () => {
|
const initChannels = async () => {
|
||||||
// default (none), group, dm type
|
// default (none), group, dm type
|
||||||
await channels.set(
|
const res = await getChannels(localStorage.token).catch((error) => {
|
||||||
(await getChannels(localStorage.token)).sort(
|
return null;
|
||||||
(a, b) =>
|
});
|
||||||
['', null, 'group', 'dm'].indexOf(a.type) - ['', null, 'group', 'dm'].indexOf(b.type)
|
|
||||||
)
|
if (res) {
|
||||||
);
|
await channels.set(
|
||||||
|
res.sort(
|
||||||
|
(a, b) =>
|
||||||
|
['', null, 'group', 'dm'].indexOf(a.type) - ['', null, 'group', 'dm'].indexOf(b.type)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initChatList = async () => {
|
const initChatList = async () => {
|
||||||
|
|
@ -365,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 = [
|
||||||
|
|
@ -564,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"
|
||||||
|
|
@ -769,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'}"
|
||||||
>
|
>
|
||||||
|
|
@ -1315,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,70 +959,72 @@ 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 editor}
|
{#if note?.write_access}
|
||||||
<div>
|
{#if editor}
|
||||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
<div>
|
||||||
<button
|
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
<button
|
||||||
on:click={() => {
|
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||||
editor.chain().focus().undo().run();
|
on:click={() => {
|
||||||
// versionNavigateHandler('prev');
|
editor.chain().focus().undo().run();
|
||||||
}}
|
// versionNavigateHandler('prev');
|
||||||
disabled={!editor.can().undo()}
|
}}
|
||||||
>
|
disabled={!editor.can().undo()}
|
||||||
<ArrowUturnLeft className="size-4" />
|
>
|
||||||
</button>
|
<ArrowUturnLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
editor.chain().focus().redo().run();
|
editor.chain().focus().redo().run();
|
||||||
// versionNavigateHandler('next');
|
// versionNavigateHandler('next');
|
||||||
}}
|
}}
|
||||||
disabled={!editor.can().redo()}
|
disabled={!editor.can().redo()}
|
||||||
>
|
>
|
||||||
<ArrowUturnRight className="size-4" />
|
<ArrowUturnRight className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
|
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||||
|
on:click={() => {
|
||||||
|
if (showPanel && selectedPanel === 'chat') {
|
||||||
|
showPanel = false;
|
||||||
|
} else {
|
||||||
|
if (!showPanel) {
|
||||||
|
showPanel = true;
|
||||||
|
}
|
||||||
|
selectedPanel = 'chat';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBubbleOval />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||||
|
on:click={() => {
|
||||||
|
if (showPanel && selectedPanel === 'settings') {
|
||||||
|
showPanel = false;
|
||||||
|
} else {
|
||||||
|
if (!showPanel) {
|
||||||
|
showPanel = true;
|
||||||
|
}
|
||||||
|
selectedPanel = 'settings';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AdjustmentsHorizontalOutline />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
|
||||||
<button
|
|
||||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
|
||||||
on:click={() => {
|
|
||||||
if (showPanel && selectedPanel === 'chat') {
|
|
||||||
showPanel = false;
|
|
||||||
} else {
|
|
||||||
if (!showPanel) {
|
|
||||||
showPanel = true;
|
|
||||||
}
|
|
||||||
selectedPanel = 'chat';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChatBubbleOval />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
|
||||||
<button
|
|
||||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
|
||||||
on:click={() => {
|
|
||||||
if (showPanel && selectedPanel === 'settings') {
|
|
||||||
showPanel = false;
|
|
||||||
} else {
|
|
||||||
if (!showPanel) {
|
|
||||||
showPanel = true;
|
|
||||||
}
|
|
||||||
selectedPanel = 'settings';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdjustmentsHorizontalOutline />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<NoteMenu
|
<NoteMenu
|
||||||
onDownload={(type) => {
|
onDownload={(type) => {
|
||||||
downloadHandler(type);
|
downloadHandler(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,17 +1102,21 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{#if note?.write_access}
|
||||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
<button
|
||||||
on:click={() => {
|
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||||
showAccessControlModal = true;
|
on:click={() => {
|
||||||
}}
|
showAccessControlModal = true;
|
||||||
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,194 +371,305 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4.5 @container h-full pt-2">
|
<div class="px-3 flex justify-between">
|
||||||
{#if Object.keys(notes).length > 0}
|
<div
|
||||||
<div class="pb-10">
|
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||||
{#each Object.keys(notes) as timeRange}
|
on:wheel={(e) => {
|
||||||
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5">
|
if (e.deltaY !== 0) {
|
||||||
{$i18n.t(timeRange)}
|
e.preventDefault();
|
||||||
</div>
|
e.currentTarget.scrollLeft += e.deltaY;
|
||||||
|
|
||||||
<div
|
|
||||||
class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
|
||||||
>
|
|
||||||
{#each notes[timeRange] as note, idx (note.id)}
|
|
||||||
<div
|
|
||||||
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
|
||||||
>
|
|
||||||
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
|
|
||||||
<a
|
|
||||||
href={`/notes/${note.id}`}
|
|
||||||
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class=" flex items-center gap-2 self-center mb-1 justify-between">
|
|
||||||
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<NoteMenu
|
|
||||||
onDownload={(type) => {
|
|
||||||
selectedNote = note;
|
|
||||||
|
|
||||||
downloadHandler(type);
|
|
||||||
}}
|
|
||||||
onCopyLink={async () => {
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
const res = await copyToClipboard(`${baseUrl}/notes/${note.id}`);
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
toast.success($i18n.t('Copied link to clipboard'));
|
|
||||||
} else {
|
|
||||||
toast.error($i18n.t('Failed to copy link'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
selectedNote = note;
|
|
||||||
showDeleteConfirm = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<EllipsisHorizontal className="size-5" />
|
|
||||||
</button>
|
|
||||||
</NoteMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
|
|
||||||
>
|
|
||||||
{#if note.data?.content?.md}
|
|
||||||
{note.data?.content?.md}
|
|
||||||
{:else}
|
|
||||||
{$i18n.t('No content')}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
{dayjs(note.updated_at / 1000000).fromNow()}
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
|
||||||
className="flex shrink-0"
|
|
||||||
placement="top-start"
|
|
||||||
>
|
|
||||||
<div class="shrink-0 text-gray-500">
|
|
||||||
{$i18n.t('By {{name}}', {
|
|
||||||
name: capitalizeFirstLetter(
|
|
||||||
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex flex-col items-center justify-center">
|
|
||||||
<div class="pb-20 text-center">
|
|
||||||
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
|
|
||||||
{$i18n.t('No Notes')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
|
|
||||||
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/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="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;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class=" self-center">
|
{#if [null, 'shared'].includes(viewOption)}
|
||||||
<svg
|
<DropdownOptions
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
align="start"
|
||||||
viewBox="0 0 16 16"
|
bind:value={permission}
|
||||||
fill="currentColor"
|
items={[
|
||||||
class="w-4 h-4"
|
{ value: null, label: $i18n.t('Write') },
|
||||||
>
|
{ value: 'read_only', label: $i18n.t('Read Only') }
|
||||||
<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>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DropdownOptions
|
||||||
|
align="start"
|
||||||
|
bind:value={displayOption}
|
||||||
|
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>
|
</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)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if displayOption === null}
|
||||||
|
<div
|
||||||
|
class="{Object.keys(notes).length - 1 !== idx
|
||||||
|
? 'mb-3'
|
||||||
|
: ''} gap-1.5 flex flex-col"
|
||||||
|
>
|
||||||
|
{#each notes[timeRange] as note, idx (note.id)}
|
||||||
|
<div
|
||||||
|
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||||
|
>
|
||||||
|
<a href={`/notes/${note.id}`} class="w-full flex flex-col justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class=" flex items-center gap-2 self-center justify-between">
|
||||||
|
<Tooltip
|
||||||
|
content={note.title}
|
||||||
|
className="flex-1"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class=" text-sm font-medium capitalize flex-1 w-full line-clamp-1"
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center text-xs gap-2.5">
|
||||||
|
<Tooltip content={dayjs(note.updated_at / 1000000).format('LLLL')}>
|
||||||
|
<div>
|
||||||
|
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||||
|
className="flex shrink-0"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="shrink-0 text-gray-500">
|
||||||
|
{$i18n.t('By {{name}}', {
|
||||||
|
name: capitalizeFirstLetter(
|
||||||
|
note?.user?.name ??
|
||||||
|
note?.user?.email ??
|
||||||
|
$i18n.t('Deleted User')
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NoteMenu
|
||||||
|
onDownload={(type) => {
|
||||||
|
selectedNote = note;
|
||||||
|
|
||||||
|
downloadHandler(type);
|
||||||
|
}}
|
||||||
|
onCopyLink={async () => {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const res = await copyToClipboard(
|
||||||
|
`${baseUrl}/notes/${note.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
toast.success($i18n.t('Copied link to clipboard'));
|
||||||
|
} else {
|
||||||
|
toast.error($i18n.t('Failed to copy link'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
selectedNote = note;
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisHorizontal className="size-5" />
|
||||||
|
</button>
|
||||||
|
</NoteMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if displayOption === 'grid'}
|
||||||
|
<div
|
||||||
|
class="{Object.keys(notes).length - 1 !== idx
|
||||||
|
? 'mb-5'
|
||||||
|
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||||
|
>
|
||||||
|
{#each notes[timeRange] as note, idx (note.id)}
|
||||||
|
<div
|
||||||
|
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||||
|
>
|
||||||
|
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
|
||||||
|
<a
|
||||||
|
href={`/notes/${note.id}`}
|
||||||
|
class="w-full -translate-y-0.5 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
class=" flex items-center gap-2 self-center mb-1 justify-between"
|
||||||
|
>
|
||||||
|
<div class=" font-semibold line-clamp-1 capitalize">
|
||||||
|
{note.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NoteMenu
|
||||||
|
onDownload={(type) => {
|
||||||
|
selectedNote = note;
|
||||||
|
|
||||||
|
downloadHandler(type);
|
||||||
|
}}
|
||||||
|
onCopyLink={async () => {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const res = await copyToClipboard(
|
||||||
|
`${baseUrl}/notes/${note.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
toast.success($i18n.t('Copied link to clipboard'));
|
||||||
|
} else {
|
||||||
|
toast.error($i18n.t('Failed to copy link'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
selectedNote = note;
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisHorizontal className="size-5" />
|
||||||
|
</button>
|
||||||
|
</NoteMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
|
||||||
|
>
|
||||||
|
{#if note.data?.content?.md}
|
||||||
|
{note.data?.content?.md}
|
||||||
|
{:else}
|
||||||
|
{$i18n.t('No content')}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
{dayjs(note.updated_at / 1000000).fromNow()}
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||||
|
className="flex shrink-0"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="shrink-0 text-gray-500">
|
||||||
|
{$i18n.t('By {{name}}', {
|
||||||
|
name: capitalizeFirstLetter(
|
||||||
|
note?.user?.name ??
|
||||||
|
note?.user?.email ??
|
||||||
|
$i18n.t('Deleted User')
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<div class="py-20 text-center">
|
||||||
|
<div class=" text-sm text-gray-400 dark:text-gray-600">
|
||||||
|
{$i18n.t('No Notes')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-gray-300 dark:text-gray-700">
|
||||||
|
{$i18n.t('Create your first note by clicking on the plus button below.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</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">
|
||||||
<Spinner className="size-5" />
|
<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;
|
||||||
|
} else {
|
||||||
|
allItemsLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
items = [...items, ...pageItems];
|
||||||
|
} else {
|
||||||
|
items = pageItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsLoading = false;
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (knowledgeBases.length > 0 && viewOption !== undefined) {
|
|
||||||
// Added a check for non-empty array, good practice
|
|
||||||
setFuse();
|
|
||||||
} else {
|
|
||||||
fuse = null; // Reset fuse if knowledgeBases is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
const setFilteredItems = () => {
|
|
||||||
filteredItems = query ? fuse.search(query).map((result) => result.item) : items;
|
|
||||||
};
|
|
||||||
|
|
||||||
$: 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}
|
||||||
<!-- The Aleph dreams itself into being, and the void learns its own name -->
|
{#if (items ?? []).length !== 0}
|
||||||
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
|
<!-- The Aleph dreams itself into being, and the void learns its own name -->
|
||||||
{#each filteredItems as item}
|
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||||
<Tooltip content={item?.description ?? item.name}>
|
{#each items as item}
|
||||||
<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,42 +221,49 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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="success" content={$i18n.t('Collection')} />
|
||||||
<Badge type="muted" content={$i18n.t('Document')} />
|
|
||||||
{:else}
|
|
||||||
<Badge type="success" content={$i18n.t('Collection')} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" text-xs text-gray-500 line-clamp-1">
|
{#if !item?.write_access}
|
||||||
{$i18n.t('Updated')}
|
<div>
|
||||||
{dayjs(item.updated_at * 1000).fromNow()}
|
<Badge type="muted" content={$i18n.t('Read Only')} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if item?.write_access}
|
||||||
<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
|
||||||
on:delete={() => {
|
on:delete={() => {
|
||||||
selectedItem = item;
|
selectedItem = item;
|
||||||
showDeleteConfirm = true;
|
showDeleteConfirm = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</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">
|
||||||
<div class=" flex items-center gap-2">
|
<Tooltip content={item?.description ?? item.name}>
|
||||||
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</div>
|
<div class=" flex items-center gap-2">
|
||||||
</div>
|
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</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,18 +281,37 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
{:else}
|
{#if !allItemsLoaded}
|
||||||
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
<Loader
|
||||||
<div class="max-w-md text-center">
|
on:visible={(e) => {
|
||||||
<div class=" text-3xl mb-3">😕</div>
|
if (!itemsLoading) {
|
||||||
<div class=" text-lg font-medium mb-1">{$i18n.t('No knowledge found')}</div>
|
loadMoreItems();
|
||||||
<div class=" text-gray-500 text-center text-xs">
|
}
|
||||||
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
<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=" text-3xl mb-3">😕</div>
|
||||||
|
<div class=" text-lg font-medium mb-1">{$i18n.t('No knowledge found')}</div>
|
||||||
|
<div class=" text-gray-500 text-center text-xs">
|
||||||
|
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 (fuse) {
|
$: if (
|
||||||
filteredItems = query
|
query !== undefined &&
|
||||||
? fuse.search(query).map((e) => {
|
viewOption !== undefined &&
|
||||||
return e.item;
|
sortKey !== undefined &&
|
||||||
})
|
direction !== undefined
|
||||||
: (knowledge?.files ?? []);
|
) {
|
||||||
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedFile = null;
|
const getItemsPage = async () => {
|
||||||
let selectedFileId = null;
|
if (knowledgeId === null) return;
|
||||||
let selectedFileContent = '';
|
|
||||||
|
|
||||||
// Add cache object
|
fileItems = null;
|
||||||
let fileContentCache = new Map();
|
fileItemsTotal = null;
|
||||||
|
|
||||||
$: if (selectedFileId) {
|
if (sortKey === null) {
|
||||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
direction = null;
|
||||||
if (file) {
|
|
||||||
fileSelectHandler(file);
|
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fuse = null;
|
const res = await searchKnowledgeFilesById(
|
||||||
let debounceTimeout = null;
|
localStorage.token,
|
||||||
let mediaQuery;
|
knowledge.id,
|
||||||
let dragged = false;
|
query,
|
||||||
let isSaving = false;
|
viewOption,
|
||||||
|
sortKey,
|
||||||
|
direction,
|
||||||
|
currentPage
|
||||||
|
).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
fileItems = res.items;
|
||||||
|
fileItemsTotal = res.total;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileSelectHandler = async (file) => {
|
||||||
|
try {
|
||||||
|
selectedFile = file;
|
||||||
|
selectedFileContent = selectedFile?.data?.content || '';
|
||||||
|
} catch (e) {
|
||||||
|
toast.error($i18n.t('Failed to load file content.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createFileFromText = (name, content) => {
|
const createFileFromText = (name, content) => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
|
@ -163,19 +203,18 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
knowledge.files = [...(knowledge.files ?? []), fileItem];
|
fileItems = [...(fileItems ?? []), fileItem];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If the file is an audio file, provide the language for STT.
|
let metadata = {
|
||||||
let metadata = null;
|
knowledge_id: knowledge.id,
|
||||||
if (
|
// If the file is an audio file, provide the language for STT.
|
||||||
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
...((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,57 +728,75 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<div class="self-center shrink-0">
|
{#if knowledge?.write_access}
|
||||||
<button
|
<div class="self-center shrink-0">
|
||||||
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"
|
<button
|
||||||
type="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"
|
||||||
on:click={() => {
|
type="button"
|
||||||
showAccessControlModal = true;
|
on:click={() => {
|
||||||
}}
|
showAccessControlModal = true;
|
||||||
>
|
}}
|
||||||
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
>
|
||||||
|
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
||||||
|
|
||||||
<div class="text-sm font-medium shrink-0">
|
<div class="text-sm font-medium shrink-0">
|
||||||
{$i18n.t('Access')}
|
{$i18n.t('Access')}
|
||||||
</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,204 +806,211 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3">
|
<div
|
||||||
{#if largeScreen}
|
class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
|
||||||
<div class="flex-1 flex justify-start w-full h-full max-h-full">
|
>
|
||||||
{#if selectedFile}
|
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||||
<div class=" flex flex-col w-full">
|
<div class="flex flex-1 items-center">
|
||||||
<div class="shrink-0 mb-2 flex items-center">
|
<div class=" self-center ml-1 mr-3">
|
||||||
{#if !showSidepanel}
|
<Search className="size-3.5" />
|
||||||
<div class="-translate-x-2">
|
</div>
|
||||||
<button
|
<input
|
||||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
on:click={() => {
|
bind:value={query}
|
||||||
pane.expand();
|
placeholder={`${$i18n.t('Search Collection')}`}
|
||||||
}}
|
on:focus={() => {
|
||||||
>
|
selectedFileId = null;
|
||||||
<ChevronLeft strokeWidth="2.5" />
|
}}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class=" flex-1 text-xl font-medium">
|
{#if knowledge?.write_access}
|
||||||
<a
|
<div>
|
||||||
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1"
|
<AddContentMenu
|
||||||
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
|
on:upload={(e) => {
|
||||||
target="_blank"
|
if (e.detail.type === 'directory') {
|
||||||
>
|
uploadDirectoryHandler();
|
||||||
{decodeString(selectedFile?.meta?.name)}
|
} else if (e.detail.type === 'text') {
|
||||||
</a>
|
showAddTextContentModal = true;
|
||||||
</div>
|
} else {
|
||||||
|
document.getElementById('files-input').click();
|
||||||
<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"
|
on:sync={(e) => {
|
||||||
disabled={isSaving}
|
showSyncConfirmModal = true;
|
||||||
on:click={() => {
|
}}
|
||||||
updateFileContentHandler();
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{$i18n.t('Save')}
|
|
||||||
{#if isSaving}
|
|
||||||
<div class="ml-2 self-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden"
|
|
||||||
>
|
|
||||||
{#key selectedFile.id}
|
|
||||||
<textarea
|
|
||||||
class="w-full h-full outline-none resize-none"
|
|
||||||
bind:value={selectedFileContent}
|
|
||||||
placeholder={$i18n.t('Add content here')}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full flex w-full">
|
|
||||||
<div class="m-auto text-xs text-center text-gray-200 dark:text-gray-700">
|
|
||||||
{$i18n.t('Drag and drop a file to upload or select a file to view')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if !largeScreen && selectedFileId !== null}
|
</div>
|
||||||
<Drawer
|
|
||||||
className="h-full"
|
<div class="px-3 flex justify-between">
|
||||||
show={selectedFileId !== null}
|
<div
|
||||||
onClose={() => {
|
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||||
selectedFileId = null;
|
on:wheel={(e) => {
|
||||||
|
if (e.deltaY !== 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-start h-full max-h-full p-2">
|
<div
|
||||||
<div class=" flex flex-col w-full h-full max-h-full">
|
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||||
<div class="shrink-0 mt-1 mb-2 flex items-center">
|
>
|
||||||
<div class="mr-2">
|
<DropdownOptions
|
||||||
<button
|
align="start"
|
||||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
|
||||||
on:click={() => {
|
bind:value={viewOption}
|
||||||
selectedFileId = null;
|
items={[
|
||||||
}}
|
{ value: null, label: $i18n.t('All') },
|
||||||
>
|
{ value: 'created', label: $i18n.t('Created by you') },
|
||||||
<ChevronLeft strokeWidth="2.5" />
|
{ value: 'shared', label: $i18n.t('Shared with you') }
|
||||||
</button>
|
]}
|
||||||
</div>
|
onChange={(value) => {
|
||||||
<div class=" flex-1 text-xl line-clamp-1">
|
if (value) {
|
||||||
{selectedFile?.meta?.name}
|
localStorage.workspaceViewOption = value;
|
||||||
</div>
|
} else {
|
||||||
|
delete localStorage.workspaceViewOption;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<DropdownOptions
|
||||||
<button
|
align="start"
|
||||||
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
bind:value={sortKey}
|
||||||
disabled={isSaving}
|
placeholder={$i18n.t('Sort')}
|
||||||
on:click={() => {
|
items={[
|
||||||
updateFileContentHandler();
|
{ value: 'name', label: $i18n.t('Name') },
|
||||||
}}
|
{ value: 'created_at', label: $i18n.t('Created At') },
|
||||||
>
|
{ value: 'updated_at', label: $i18n.t('Updated At') }
|
||||||
{$i18n.t('Save')}
|
]}
|
||||||
{#if isSaving}
|
/>
|
||||||
<div class="ml-2 self-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{#if sortKey}
|
||||||
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden"
|
<DropdownOptions
|
||||||
>
|
align="start"
|
||||||
{#key selectedFile.id}
|
bind:value={direction}
|
||||||
<textarea
|
items={[
|
||||||
class="w-full h-full outline-none resize-none"
|
{ value: 'asc', label: $i18n.t('Asc') },
|
||||||
bind:value={selectedFileContent}
|
{ value: null, label: $i18n.t('Desc') }
|
||||||
placeholder={$i18n.t('Add content here')}
|
]}
|
||||||
/>
|
/>
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="{largeScreen ? 'shrink-0 w-72 max-w-72' : 'flex-1'}
|
|
||||||
flex
|
|
||||||
py-2
|
|
||||||
rounded-2xl
|
|
||||||
border
|
|
||||||
border-gray-50
|
|
||||||
h-full
|
|
||||||
dark:border-gray-850"
|
|
||||||
>
|
|
||||||
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
|
||||||
<div class="w-full h-full flex flex-col">
|
|
||||||
<div class=" px-3">
|
|
||||||
<div class="flex mb-0.5">
|
|
||||||
<div class=" self-center ml-1 mr-3">
|
|
||||||
<Search />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
|
|
||||||
on:focus={() => {
|
|
||||||
selectedFileId = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<AddContentMenu
|
|
||||||
on:upload={(e) => {
|
|
||||||
if (e.detail.type === 'directory') {
|
|
||||||
uploadDirectoryHandler();
|
|
||||||
} else if (e.detail.type === 'text') {
|
|
||||||
showAddTextContentModal = true;
|
|
||||||
} else {
|
|
||||||
document.getElementById('files-input').click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:sync={(e) => {
|
|
||||||
showSyncConfirmModal = true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredItems.length > 0}
|
|
||||||
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
|
||||||
<Files
|
|
||||||
small
|
|
||||||
files={filteredItems}
|
|
||||||
{selectedFileId}
|
|
||||||
on:click={(e) => {
|
|
||||||
selectedFileId = selectedFileId === e.detail ? null : e.detail;
|
|
||||||
}}
|
|
||||||
on:delete={(e) => {
|
|
||||||
console.log(e.detail);
|
|
||||||
|
|
||||||
selectedFileId = null;
|
|
||||||
deleteFileHandler(e.detail);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
|
||||||
<div>
|
|
||||||
{$i18n.t('No content found')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if fileItems !== null && fileItemsTotal !== null}
|
||||||
|
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
|
||||||
|
<div class="flex-1 flex">
|
||||||
|
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
||||||
|
<div class="w-full h-full flex flex-col min-h-full">
|
||||||
|
{#if fileItems.length > 0}
|
||||||
|
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
||||||
|
<Files
|
||||||
|
files={fileItems}
|
||||||
|
{knowledge}
|
||||||
|
{selectedFileId}
|
||||||
|
onClick={(fileId) => {
|
||||||
|
selectedFileId = fileId;
|
||||||
|
|
||||||
|
if (fileItems) {
|
||||||
|
const file = fileItems.find((file) => file.id === selectedFileId);
|
||||||
|
if (file) {
|
||||||
|
fileSelectHandler(file);
|
||||||
|
} else {
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={(fileId) => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
|
||||||
|
deleteFileHandler(fileId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if fileItemsTotal > 30}
|
||||||
|
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
||||||
|
<div>
|
||||||
|
{$i18n.t('No content found')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedFileId !== null}
|
||||||
|
<Drawer
|
||||||
|
className="h-full"
|
||||||
|
show={selectedFileId !== null}
|
||||||
|
onClose={() => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-start h-full max-h-full">
|
||||||
|
<div class=" flex flex-col w-full h-full max-h-full">
|
||||||
|
<div class="shrink-0 flex items-center p-2">
|
||||||
|
<div class="mr-2">
|
||||||
|
<button
|
||||||
|
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||||
|
on:click={() => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft strokeWidth="2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class=" flex-1 text-lg line-clamp-1">
|
||||||
|
{selectedFile?.meta?.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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
|
|
||||||
on:click={() => {
|
|
||||||
if (file.status === 'uploading') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch('click', file.id);
|
|
||||||
}}
|
}}
|
||||||
on:dismiss={() => {
|
>
|
||||||
if (file.status === 'uploading') {
|
<div class="">
|
||||||
return;
|
<div class="flex gap-2 items-center line-clamp-1">
|
||||||
}
|
<div class="shrink-0">
|
||||||
|
{#if file?.status !== 'uploading'}
|
||||||
|
<DocumentPage className="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Spinner className="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
dispatch('delete', file.id);
|
<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={() => {
|
||||||
|
onDelete(file?.id ?? file?.tempId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XMark />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{/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
|
||||||
) {
|
) {
|
||||||
getModelList();
|
clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
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,213 +436,221 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if (models ?? []).length !== 0}
|
{#if models !== null}
|
||||||
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
|
{#if (models ?? []).length !== 0}
|
||||||
{#each models as model (model.id)}
|
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
{#each models as model (model.id)}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
class=" flex cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl w-full p-2.5"
|
<div
|
||||||
id="model-item-{model.id}"
|
class=" flex cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl w-full p-2.5"
|
||||||
on:click={() => {
|
id="model-item-{model.id}"
|
||||||
if (
|
on:click={() => {
|
||||||
$user?.role === 'admin' ||
|
if (
|
||||||
model.user_id === $user?.id ||
|
$user?.role === 'admin' ||
|
||||||
model.access_control.write.group_ids.some((wg) => groupIds.includes(wg))
|
model.user_id === $user?.id ||
|
||||||
) {
|
model.access_control.write.group_ids.some((wg) => groupIds.includes(wg))
|
||||||
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
|
) {
|
||||||
}
|
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<div class="flex group/item gap-3.5 w-full">
|
>
|
||||||
<div class="self-center pl-0.5">
|
<div class="flex group/item gap-3.5 w-full">
|
||||||
<div class="flex bg-white rounded-2xl">
|
<div class="self-center pl-0.5">
|
||||||
<div
|
<div class="flex bg-white rounded-2xl">
|
||||||
class="{model.is_active
|
<div
|
||||||
? ''
|
class="{model.is_active
|
||||||
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-2xl"
|
? ''
|
||||||
>
|
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-2xl"
|
||||||
<img
|
>
|
||||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
|
<img
|
||||||
alt="modelfile profile"
|
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
|
||||||
class=" rounded-2xl size-12 object-cover"
|
alt="modelfile profile"
|
||||||
/>
|
class=" rounded-2xl size-12 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class=" shrink-0 flex w-full min-w-0 flex-1 pr-1 self-center">
|
<div class=" shrink-0 flex w-full min-w-0 flex-1 pr-1 self-center">
|
||||||
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
|
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<Tooltip content={model.name} className=" w-fit" placement="top-start">
|
<Tooltip content={model.name} className=" w-fit" placement="top-start">
|
||||||
<a
|
<a
|
||||||
class=" font-medium line-clamp-1 hover:underline capitalize"
|
class=" font-medium line-clamp-1 hover:underline capitalize"
|
||||||
href={`/?models=${encodeURIComponent(model.id)}`}
|
href={`/?models=${encodeURIComponent(model.id)}`}
|
||||||
>
|
>
|
||||||
{model.name}
|
{model.name}
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div class=" flex items-center gap-1">
|
<div class=" flex items-center gap-1">
|
||||||
<div
|
<div
|
||||||
class="flex justify-end w-full {model.is_active ? '' : 'text-gray-500'}"
|
class="flex justify-end w-full {model.is_active ? '' : 'text-gray-500'}"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center w-full">
|
<div class="flex justify-between items-center w-full">
|
||||||
<div class=""></div>
|
<div class=""></div>
|
||||||
<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')
|
||||||
<button
|
: $i18n.t('Hide')}
|
||||||
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
>
|
||||||
type="button"
|
<button
|
||||||
on:click={(e) => {
|
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
e.stopPropagation();
|
type="button"
|
||||||
|
on:click={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hideModelHandler(model);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if model?.meta?.hidden}
|
||||||
|
<EyeSlash />
|
||||||
|
{:else}
|
||||||
|
<Eye />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={$i18n.t('Delete')}>
|
||||||
|
<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"
|
||||||
|
type="button"
|
||||||
|
on:click={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteModelHandler(model);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GarbageBin />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{:else}
|
||||||
|
<ModelMenu
|
||||||
|
user={$user}
|
||||||
|
{model}
|
||||||
|
editHandler={() => {
|
||||||
|
goto(
|
||||||
|
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
shareHandler={() => {
|
||||||
|
shareModelHandler(model);
|
||||||
|
}}
|
||||||
|
cloneHandler={() => {
|
||||||
|
cloneModelHandler(model);
|
||||||
|
}}
|
||||||
|
exportHandler={() => {
|
||||||
|
exportModelHandler(model);
|
||||||
|
}}
|
||||||
|
hideHandler={() => {
|
||||||
hideModelHandler(model);
|
hideModelHandler(model);
|
||||||
}}
|
}}
|
||||||
>
|
copyLinkHandler={() => {
|
||||||
{#if model?.meta?.hidden}
|
copyLinkHandler(model);
|
||||||
<EyeSlash />
|
|
||||||
{:else}
|
|
||||||
<Eye />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip content={$i18n.t('Delete')}>
|
|
||||||
<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"
|
|
||||||
type="button"
|
|
||||||
on:click={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteModelHandler(model);
|
|
||||||
}}
|
}}
|
||||||
|
deleteHandler={() => {
|
||||||
|
selectedModel = model;
|
||||||
|
showModelDeleteConfirm = true;
|
||||||
|
}}
|
||||||
|
onClose={() => {}}
|
||||||
>
|
>
|
||||||
<GarbageBin />
|
<div
|
||||||
</button>
|
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||||
</Tooltip>
|
>
|
||||||
|
<EllipsisHorizontal className="size-5" />
|
||||||
|
</div>
|
||||||
|
</ModelMenu>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
bind:state={model.is_active}
|
||||||
|
on:change={async () => {
|
||||||
|
toggleModelById(localStorage.token, model.id);
|
||||||
|
_models.set(
|
||||||
|
await getModels(
|
||||||
|
localStorage.token,
|
||||||
|
$config?.features?.enable_direct_connections &&
|
||||||
|
($settings?.directConnections ?? null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" flex gap-1 pr-2 -mt-1 items-center">
|
||||||
|
<Tooltip
|
||||||
|
content={model?.user?.email ?? $i18n.t('Deleted User')}
|
||||||
|
className="flex shrink-0"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="shrink-0 text-gray-500 text-xs">
|
||||||
|
{$i18n.t('By {{name}}', {
|
||||||
|
name: capitalizeFirstLetter(
|
||||||
|
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div>·</div>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={marked.parse(model?.meta?.description ?? model.id)}
|
||||||
|
className=" w-fit text-left"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="flex gap-1 text-xs overflow-hidden">
|
||||||
|
<div class="line-clamp-1">
|
||||||
|
{#if (model?.meta?.description ?? '').trim()}
|
||||||
|
{model?.meta?.description}
|
||||||
{:else}
|
{:else}
|
||||||
<ModelMenu
|
{model.id}
|
||||||
user={$user}
|
|
||||||
{model}
|
|
||||||
editHandler={() => {
|
|
||||||
goto(
|
|
||||||
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
shareHandler={() => {
|
|
||||||
shareModelHandler(model);
|
|
||||||
}}
|
|
||||||
cloneHandler={() => {
|
|
||||||
cloneModelHandler(model);
|
|
||||||
}}
|
|
||||||
exportHandler={() => {
|
|
||||||
exportModelHandler(model);
|
|
||||||
}}
|
|
||||||
hideHandler={() => {
|
|
||||||
hideModelHandler(model);
|
|
||||||
}}
|
|
||||||
copyLinkHandler={() => {
|
|
||||||
copyLinkHandler(model);
|
|
||||||
}}
|
|
||||||
deleteHandler={() => {
|
|
||||||
selectedModel = model;
|
|
||||||
showModelDeleteConfirm = true;
|
|
||||||
}}
|
|
||||||
onClose={() => {}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
|
||||||
>
|
|
||||||
<EllipsisHorizontal className="size-5" />
|
|
||||||
</div>
|
|
||||||
</ModelMenu>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
|
||||||
<button
|
|
||||||
on:click={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
bind:state={model.is_active}
|
|
||||||
on:change={async () => {
|
|
||||||
toggleModelById(localStorage.token, model.id);
|
|
||||||
_models.set(
|
|
||||||
await getModels(
|
|
||||||
localStorage.token,
|
|
||||||
$config?.features?.enable_direct_connections &&
|
|
||||||
($settings?.directConnections ?? null)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" flex gap-1 pr-2 -mt-1 items-center">
|
|
||||||
<Tooltip
|
|
||||||
content={model?.user?.email ?? $i18n.t('Deleted User')}
|
|
||||||
className="flex shrink-0"
|
|
||||||
placement="top-start"
|
|
||||||
>
|
|
||||||
<div class="shrink-0 text-gray-500 text-xs">
|
|
||||||
{$i18n.t('By {{name}}', {
|
|
||||||
name: capitalizeFirstLetter(
|
|
||||||
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div>·</div>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
content={marked.parse(model?.meta?.description ?? model.id)}
|
|
||||||
className=" w-fit text-left"
|
|
||||||
placement="top-start"
|
|
||||||
>
|
|
||||||
<div class="flex gap-1 text-xs overflow-hidden">
|
|
||||||
<div class="line-clamp-1">
|
|
||||||
{#if (model?.meta?.description ?? '').trim()}
|
|
||||||
{model?.meta?.description}
|
|
||||||
{:else}
|
|
||||||
{model.id}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if total > 30}
|
{#if total > 30}
|
||||||
<Pagination bind:page count={total} perPage={30} />
|
<Pagination bind:page count={total} perPage={30} />
|
||||||
{/if}
|
{/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">
|
||||||
<div class=" text-3xl mb-3">😕</div>
|
<div class=" text-3xl mb-3">😕</div>
|
||||||
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
|
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
|
||||||
<div class=" text-gray-500 text-center text-xs">
|
<div class=" text-gray-500 text-center text-xs">
|
||||||
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex justify-center items-center py-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue