mirror of
https://github.com/open-webui/open-webui.git
synced 2025-04-13 06:19:08 +02:00
commit
f05dbb895e
@ -847,6 +847,12 @@ USER_PERMISSIONS = PersistentConfig(
|
||||
},
|
||||
)
|
||||
|
||||
ENABLE_CHANNELS = PersistentConfig(
|
||||
"ENABLE_CHANNELS",
|
||||
"channels.enable",
|
||||
os.environ.get("ENABLE_CHANNELS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
|
||||
"ENABLE_EVALUATION_ARENA_MODELS",
|
||||
|
@ -58,6 +58,7 @@ from open_webui.routers import (
|
||||
pipelines,
|
||||
tasks,
|
||||
auths,
|
||||
channels,
|
||||
chats,
|
||||
folders,
|
||||
configs,
|
||||
@ -198,6 +199,7 @@ from open_webui.config import (
|
||||
ENABLE_SIGNUP,
|
||||
ENABLE_LOGIN_FORM,
|
||||
ENABLE_API_KEY,
|
||||
ENABLE_CHANNELS,
|
||||
ENABLE_COMMUNITY_SHARING,
|
||||
ENABLE_MESSAGE_RATING,
|
||||
ENABLE_EVALUATION_ARENA_MODELS,
|
||||
@ -406,6 +408,8 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
|
||||
app.state.config.BANNERS = WEBUI_BANNERS
|
||||
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
|
||||
|
||||
|
||||
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
|
||||
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
|
||||
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
|
||||
|
||||
@ -737,6 +741,8 @@ app.include_router(configs.router, prefix="/api/v1/configs", tags=["configs"])
|
||||
app.include_router(auths.router, prefix="/api/v1/auths", tags=["auths"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
|
||||
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
|
||||
|
||||
app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
|
||||
@ -969,6 +975,7 @@ async def get_app_config(request: Request):
|
||||
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
||||
**(
|
||||
{
|
||||
"enable_channels": app.state.config.ENABLE_CHANNELS,
|
||||
"enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
|
||||
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
|
||||
|
@ -0,0 +1,48 @@
|
||||
"""Add channel table
|
||||
|
||||
Revision ID: 57c599a3cb57
|
||||
Revises: 922e7a387820
|
||||
Create Date: 2024-12-22 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "57c599a3cb57"
|
||||
down_revision = "922e7a387820"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"channel",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("name", sa.Text()),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"message",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("channel_id", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text()),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("channel")
|
||||
|
||||
op.drop_table("message")
|
132
backend/open_webui/models/channels.py
Normal file
132
backend/open_webui/models/channels.py
Normal file
@ -0,0 +1,132 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy import or_, func, select, and_, text
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
####################
|
||||
# Channel DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Channel(Base):
|
||||
__tablename__ = "channel"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text)
|
||||
|
||||
name = Column(Text)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
class ChannelModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
description: Optional[str] = None
|
||||
|
||||
name: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class ChannelForm(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class ChannelTable:
|
||||
def insert_new_channel(
|
||||
self, form_data: ChannelForm, user_id: str
|
||||
) -> Optional[ChannelModel]:
|
||||
with get_db() as db:
|
||||
channel = ChannelModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
"name": form_data.name.lower(),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
}
|
||||
)
|
||||
|
||||
new_channel = Channel(**channel.model_dump())
|
||||
|
||||
db.add(new_channel)
|
||||
db.commit()
|
||||
return channel
|
||||
|
||||
def get_channels(self) -> list[ChannelModel]:
|
||||
with get_db() as db:
|
||||
channels = db.query(Channel).all()
|
||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||
|
||||
def get_channels_by_user_id(
|
||||
self, user_id: str, permission: str = "read"
|
||||
) -> list[ChannelModel]:
|
||||
channels = self.get_channels()
|
||||
return [
|
||||
channel
|
||||
for channel in channels
|
||||
if channel.user_id == user_id
|
||||
or has_access(user_id, permission, channel.access_control)
|
||||
]
|
||||
|
||||
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
|
||||
with get_db() as db:
|
||||
channel = db.query(Channel).filter(Channel.id == id).first()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
|
||||
def update_channel_by_id(
|
||||
self, id: str, form_data: ChannelForm
|
||||
) -> Optional[ChannelModel]:
|
||||
with get_db() as db:
|
||||
channel = db.query(Channel).filter(Channel.id == id).first()
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
channel.name = form_data.name
|
||||
channel.data = form_data.data
|
||||
channel.meta = form_data.meta
|
||||
channel.access_control = form_data.access_control
|
||||
channel.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
|
||||
def delete_channel_by_id(self, id: str):
|
||||
with get_db() as db:
|
||||
db.query(Channel).filter(Channel.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
Channels = ChannelTable()
|
141
backend/open_webui/models/messages.py
Normal file
141
backend/open_webui/models/messages.py
Normal file
@ -0,0 +1,141 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.tags import TagModel, Tag, Tags
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy import or_, func, select, and_, text
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
####################
|
||||
# Message DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "message"
|
||||
id = Column(Text, primary_key=True)
|
||||
|
||||
user_id = Column(Text)
|
||||
channel_id = Column(Text, nullable=True)
|
||||
|
||||
content = Column(Text)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger) # time_ns
|
||||
updated_at = Column(BigInteger) # time_ns
|
||||
|
||||
|
||||
class MessageModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
content: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
|
||||
class MessageTable:
|
||||
def insert_new_message(
|
||||
self, form_data: MessageForm, channel_id: str, user_id: str
|
||||
) -> Optional[MessageModel]:
|
||||
with get_db() as db:
|
||||
id = str(uuid.uuid4())
|
||||
|
||||
ts = int(time.time_ns())
|
||||
message = MessageModel(
|
||||
**{
|
||||
"id": id,
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
"content": form_data.content,
|
||||
"data": form_data.data,
|
||||
"meta": form_data.meta,
|
||||
"created_at": ts,
|
||||
"updated_at": ts,
|
||||
}
|
||||
)
|
||||
|
||||
result = Message(**message.model_dump())
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
return MessageModel.model_validate(result) if result else None
|
||||
|
||||
def get_message_by_id(self, id: str) -> Optional[MessageModel]:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, id)
|
||||
return MessageModel.model_validate(message) if message else None
|
||||
|
||||
def get_messages_by_channel_id(
|
||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
.filter_by(channel_id=channel_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
def get_messages_by_user_id(
|
||||
self, user_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
.filter_by(user_id=user_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
def update_message_by_id(
|
||||
self, id: str, form_data: MessageForm
|
||||
) -> Optional[MessageModel]:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, id)
|
||||
message.content = form_data.content
|
||||
message.data = form_data.data
|
||||
message.meta = form_data.meta
|
||||
message.updated_at = int(time.time_ns())
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
return MessageModel.model_validate(message) if message else None
|
||||
|
||||
def delete_message_by_id(self, id: str) -> bool:
|
||||
with get_db() as db:
|
||||
db.query(Message).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
Messages = MessageTable()
|
@ -70,6 +70,13 @@ class UserResponse(BaseModel):
|
||||
profile_image_url: str
|
||||
|
||||
|
||||
class UserNameResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
role: str
|
||||
profile_image_url: str
|
||||
|
||||
|
||||
class UserRoleUpdateForm(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
|
@ -616,6 +616,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
||||
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
|
||||
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
||||
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
||||
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||
@ -627,6 +628,7 @@ class AdminConfig(BaseModel):
|
||||
SHOW_ADMIN_DETAILS: bool
|
||||
ENABLE_SIGNUP: bool
|
||||
ENABLE_API_KEY: bool
|
||||
ENABLE_CHANNELS: bool
|
||||
DEFAULT_USER_ROLE: str
|
||||
JWT_EXPIRES_IN: str
|
||||
ENABLE_COMMUNITY_SHARING: bool
|
||||
@ -640,6 +642,7 @@ async def update_admin_config(
|
||||
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
|
||||
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
|
||||
request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
|
||||
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
||||
|
||||
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
|
||||
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
||||
|
336
backend/open_webui/routers/channels.py
Normal file
336
backend/open_webui/routers/channels.py
Normal file
@ -0,0 +1,336 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
from open_webui.socket.main import sio
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
|
||||
from open_webui.models.messages import Messages, MessageModel, MessageForm
|
||||
|
||||
|
||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
############################
|
||||
# GetChatList
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ChannelModel])
|
||||
async def get_channels(user=Depends(get_verified_user)):
|
||||
if user.role == "admin":
|
||||
return Channels.get_channels()
|
||||
else:
|
||||
return Channels.get_channels_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewChannel
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/create", response_model=Optional[ChannelModel])
|
||||
async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
|
||||
try:
|
||||
channel = Channels.insert_new_channel(form_data, user.id)
|
||||
return ChannelModel(**channel.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetChannelById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[ChannelModel])
|
||||
async def get_channel_by_id(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 not has_access(user.id, type="read", access_control=channel.access_control):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
return ChannelModel(**channel.model_dump())
|
||||
|
||||
|
||||
############################
|
||||
# UpdateChannelById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/{id}/update", response_model=Optional[ChannelModel])
|
||||
async def update_channel_by_id(
|
||||
id: str, form_data: ChannelForm, user=Depends(get_admin_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
|
||||
)
|
||||
|
||||
try:
|
||||
channel = Channels.update_channel_by_id(id, form_data)
|
||||
return ChannelModel(**channel.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteChannelById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/{id}/delete", response_model=bool)
|
||||
async def delete_channel_by_id(id: str, user=Depends(get_admin_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
|
||||
)
|
||||
|
||||
try:
|
||||
Channels.delete_channel_by_id(id)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetChannelMessages
|
||||
############################
|
||||
|
||||
|
||||
class MessageUserModel(MessageModel):
|
||||
user: UserNameResponse
|
||||
|
||||
|
||||
@router.get("/{id}/messages", response_model=list[MessageUserModel])
|
||||
async def get_channel_messages(
|
||||
id: str, skip: int = 0, limit: int = 50, 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 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_list = Messages.get_messages_by_channel_id(id, skip, limit)
|
||||
users = {}
|
||||
|
||||
messages = []
|
||||
for message in message_list:
|
||||
if message.user_id not in users:
|
||||
user = Users.get_user_by_id(message.user_id)
|
||||
users[message.user_id] = user
|
||||
|
||||
messages.append(
|
||||
MessageUserModel(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**users[message.user_id].model_dump()),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
############################
|
||||
# PostNewMessage
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
|
||||
async def post_new_message(
|
||||
id: str, form_data: MessageForm, 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 not has_access(user.id, type="read", access_control=channel.access_control):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
try:
|
||||
message = Messages.insert_new_message(form_data, channel.id, user.id)
|
||||
|
||||
if message:
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
},
|
||||
},
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
|
||||
return MessageModel(**message.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateMessageById
|
||||
############################
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/messages/{message_id}/update", response_model=Optional[MessageModel]
|
||||
)
|
||||
async def update_message_by_id(
|
||||
id: str, message_id: str, form_data: MessageForm, 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 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()
|
||||
)
|
||||
|
||||
try:
|
||||
message = Messages.update_message_by_id(message_id, form_data)
|
||||
if message:
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message:update",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
},
|
||||
},
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
|
||||
return MessageModel(**message.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteMessageById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/{id}/messages/{message_id}/delete", response_model=bool)
|
||||
async def delete_message_by_id(
|
||||
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 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()
|
||||
)
|
||||
|
||||
try:
|
||||
Messages.delete_message_by_id(message_id)
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message:delete",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
},
|
||||
},
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
@ -5,6 +5,7 @@ import sys
|
||||
import time
|
||||
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.env import (
|
||||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
WEBSOCKET_MANAGER,
|
||||
@ -162,7 +163,6 @@ async def connect(sid, environ, auth):
|
||||
|
||||
@sio.on("user-join")
|
||||
async def user_join(sid, data):
|
||||
# print("user-join", sid, data)
|
||||
|
||||
auth = data["auth"] if "auth" in data else None
|
||||
if not auth or "token" not in auth:
|
||||
@ -182,6 +182,12 @@ async def user_join(sid, data):
|
||||
else:
|
||||
USER_POOL[user.id] = [sid]
|
||||
|
||||
# Join all the channels
|
||||
channels = Channels.get_channels_by_user_id(user.id)
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
|
||||
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
|
||||
|
||||
await sio.emit("user-count", {"count": len(USER_POOL.items())})
|
||||
|
300
src/lib/apis/channels/index.ts
Normal file
300
src/lib/apis/channels/index.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
type ChannelForm = {
|
||||
name: string;
|
||||
data?: object;
|
||||
meta?: object;
|
||||
access_control?: object;
|
||||
}
|
||||
|
||||
export const createNewChannel = async (token: string = '', channel: ChannelForm) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...channel })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChannels = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/`, {
|
||||
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.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
||||
export const getChannelById = async (token: string = '', channel_id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}`, {
|
||||
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.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const updateChannelById = async (token: string = '', channel_id: string, channel: ChannelForm) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...channel })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const deleteChannelById = async (token: string = '', channel_id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/delete`, {
|
||||
method: 'DELETE',
|
||||
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.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
export const getChannelMessages = async (token: string = '', channel_id: string, skip: number = 0, limit: number = 50) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages?skip=${skip}&limit=${limit}`, {
|
||||
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.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
type MessageForm = {
|
||||
content: string;
|
||||
data?: object;
|
||||
meta?: object;
|
||||
|
||||
}
|
||||
|
||||
export const sendMessage = async (token: string = '', channel_id: string, message: MessageForm) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/post`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...message })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const updateMessage = async (token: string = '', channel_id: string, message_id: string, message: MessageForm) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...message })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const deleteMessage = 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}/delete`, {
|
||||
method: 'DELETE',
|
||||
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.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@ -112,7 +112,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between pr-2">
|
||||
<div class=" flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key Auth')}</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
|
||||
@ -180,6 +180,16 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
|
||||
|
||||
<div class="pt-1 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-sm font-medium">
|
||||
{$i18n.t('Channels')} ({$i18n.t('Beta')})
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
145
src/lib/components/channel/Channel.svelte
Normal file
145
src/lib/components/channel/Channel.svelte
Normal file
@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
|
||||
import { showSidebar, socket } from '$lib/stores';
|
||||
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
|
||||
|
||||
import Messages from './Messages.svelte';
|
||||
import MessageInput from './MessageInput.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Navbar from './Navbar.svelte';
|
||||
|
||||
export let id = '';
|
||||
|
||||
let scrollEnd = true;
|
||||
let messagesContainerElement = null;
|
||||
|
||||
let top = false;
|
||||
|
||||
let channel = null;
|
||||
let messages = null;
|
||||
|
||||
$: if (id) {
|
||||
initHandler();
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
};
|
||||
|
||||
const initHandler = async () => {
|
||||
top = false;
|
||||
messages = null;
|
||||
channel = null;
|
||||
|
||||
channel = await getChannelById(localStorage.token, id).catch((error) => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
messages = await getChannelMessages(localStorage.token, id, 0);
|
||||
|
||||
if (messages) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
|
||||
if (messages.length < 50) {
|
||||
top = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
};
|
||||
|
||||
const channelEventHandler = async (event) => {
|
||||
console.log(event);
|
||||
|
||||
if (event.channel_id === id) {
|
||||
const type = event?.data?.type ?? null;
|
||||
const data = event?.data?.data ?? null;
|
||||
|
||||
if (type === 'message') {
|
||||
console.log('message', data);
|
||||
messages = [data, ...messages];
|
||||
await tick();
|
||||
if (scrollEnd) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
} else if (type === 'message:update') {
|
||||
console.log('message:update', data);
|
||||
const idx = messages.findIndex((message) => message.id === data.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
messages[idx] = data;
|
||||
}
|
||||
} else if (type === 'message:delete') {
|
||||
console.log('message:delete', data);
|
||||
messages = messages.filter((message) => message.id !== data.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitHandler = async ({ content }) => {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await sendMessage(localStorage.token, id, { content: content }).catch((error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
$socket?.on('channel-events', channelEventHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$socket?.off('channel-events', channelEventHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-screen max-h-[100dvh] {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)]'
|
||||
: ''} w-full max-w-full flex flex-col"
|
||||
>
|
||||
{#if channel}
|
||||
<Navbar {channel} />
|
||||
<div
|
||||
class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto"
|
||||
id="messages-container"
|
||||
bind:this={messagesContainerElement}
|
||||
on:scroll={(e) => {
|
||||
scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
|
||||
}}
|
||||
>
|
||||
{#key id}
|
||||
<Messages
|
||||
{channel}
|
||||
{messages}
|
||||
{top}
|
||||
onLoad={async () => {
|
||||
const newMessages = await getChannelMessages(localStorage.token, id, messages.length);
|
||||
|
||||
messages = [...messages, ...newMessages];
|
||||
|
||||
if (newMessages.length < 50) {
|
||||
top = true;
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" pb-[1rem]">
|
||||
<MessageInput onSubmit={submitHandler} {scrollToBottom} {scrollEnd} />
|
||||
</div>
|
||||
</div>
|
299
src/lib/components/channel/MessageInput.svelte
Normal file
299
src/lib/components/channel/MessageInput.svelte
Normal file
@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { tick, getContext } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { mobile, settings } from '$lib/stores';
|
||||
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
|
||||
|
||||
export let placeholder = $i18n.t('Send a Message');
|
||||
export let transparentBackground = false;
|
||||
|
||||
let recording = false;
|
||||
|
||||
let content = '';
|
||||
|
||||
export let onSubmit: Function;
|
||||
export let scrollEnd = true;
|
||||
export let scrollToBottom: Function;
|
||||
|
||||
let submitHandler = async () => {
|
||||
if (content === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
content
|
||||
});
|
||||
|
||||
content = '';
|
||||
await tick();
|
||||
|
||||
const chatInputElement = document.getElementById('chat-input');
|
||||
chatInputElement?.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
|
||||
<div class="flex flex-col px-3 max-w-6xl w-full">
|
||||
<div class="relative">
|
||||
{#if scrollEnd === false}
|
||||
<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none">
|
||||
<button
|
||||
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
|
||||
on:click={() => {
|
||||
scrollEnd = true;
|
||||
scrollToBottom();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
|
||||
<div class="max-w-6xl px-2.5 mx-auto inset-x-0">
|
||||
<div class="">
|
||||
{#if recording}
|
||||
<VoiceRecording
|
||||
bind:recording
|
||||
on:cancel={async () => {
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}}
|
||||
on:confirm={async (e) => {
|
||||
const { text, filename } = e.detail;
|
||||
content = `${content}${text} `;
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<form
|
||||
class="w-full flex gap-1.5"
|
||||
on:submit|preventDefault={() => {
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
|
||||
dir={$settings?.chatDirection ?? 'LTR'}
|
||||
>
|
||||
<div class=" flex">
|
||||
<div class="ml-1 self-end mb-1.5 flex space-x-1">
|
||||
<button
|
||||
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
|
||||
type="button"
|
||||
aria-label="More"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $settings?.richTextInput ?? true}
|
||||
<div
|
||||
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
|
||||
>
|
||||
<RichTextInput
|
||||
bind:value={content}
|
||||
id="chat-input"
|
||||
messageInput={true}
|
||||
shiftEnter={!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
)}
|
||||
{placeholder}
|
||||
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
||||
on:keydown={async (e) => {
|
||||
e = e.detail.event;
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||
if (
|
||||
!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
)
|
||||
) {
|
||||
// Prevent Enter key from creating a new line
|
||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Submit the content when Enter key is pressed
|
||||
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
||||
submitHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
console.log('Escape');
|
||||
}
|
||||
}}
|
||||
on:paste={async (e) => {
|
||||
e = e.detail.event;
|
||||
console.log(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
id="chat-input"
|
||||
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
|
||||
{placeholder}
|
||||
bind:value={content}
|
||||
on:keydown={async (e) => {
|
||||
e = e.detail.event;
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||
if (
|
||||
!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
)
|
||||
) {
|
||||
// Prevent Enter key from creating a new line
|
||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Submit the content when Enter key is pressed
|
||||
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
||||
submitHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
console.log('Escape');
|
||||
}
|
||||
}}
|
||||
rows="1"
|
||||
on:input={async (e) => {
|
||||
e.target.style.height = '';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
|
||||
}}
|
||||
on:focus={async (e) => {
|
||||
e.target.style.height = '';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="self-end mb-1.5 flex space-x-1 mr-1">
|
||||
{#if content === ''}
|
||||
<Tooltip content={$i18n.t('Record voice')}>
|
||||
<button
|
||||
id="voice-input-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
|
||||
error: err
|
||||
})
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
aria-label="Voice Input"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 translate-y-[0.5px]"
|
||||
>
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<div class=" flex items-center">
|
||||
<div class=" flex items-center">
|
||||
<Tooltip content={$i18n.t('Send message')}>
|
||||
<button
|
||||
id="send-message-button"
|
||||
class="{content !== ''
|
||||
? '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={content === ''}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-6"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
117
src/lib/components/channel/Messages.svelte
Normal file
117
src/lib/components/channel/Messages.svelte
Normal file
@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
import Message from './Messages/Message.svelte';
|
||||
import Loader from '../common/Loader.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import { deleteMessage, updateMessage } from '$lib/apis/channels';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let channel = null;
|
||||
export let messages = [];
|
||||
export let top = false;
|
||||
|
||||
export let onLoad: Function = () => {};
|
||||
|
||||
let messagesLoading = false;
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
// scroll slightly down to disable continuous loading
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTop = element.scrollTop + 100;
|
||||
|
||||
messagesLoading = true;
|
||||
|
||||
await onLoad();
|
||||
|
||||
await tick();
|
||||
messagesLoading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if messages}
|
||||
{@const messageList = messages.slice().reverse()}
|
||||
<div>
|
||||
{#if !top}
|
||||
<Loader
|
||||
on:visible={(e) => {
|
||||
console.log('visible');
|
||||
if (!messagesLoading) {
|
||||
loadMoreMessages();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
|
||||
<Spinner className=" size-4" />
|
||||
<div class=" ">Loading...</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{:else}
|
||||
<div
|
||||
class="px-5
|
||||
|
||||
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
|
||||
>
|
||||
{#if channel}
|
||||
<div class="flex flex-col gap-1.5 py-5">
|
||||
<div class="text-2xl font-medium capitalize">{channel.name}</div>
|
||||
|
||||
<div class=" text-gray-500">
|
||||
This channel was created on {dayjs(channel.created_at / 1000000).format(
|
||||
'MMMM D, YYYY'
|
||||
)}. This is the very beginning of the {channel.name}
|
||||
channel.
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center text-xs items-center gap-2 py-5">
|
||||
<div class=" ">Start of the channel</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if messageList.length > 0}
|
||||
<hr class=" border-gray-50 dark:border-gray-700/20 py-2.5 w-full" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messageList as message, messageIdx (message.id)}
|
||||
<Message
|
||||
{message}
|
||||
showUserProfile={messageIdx === 0 ||
|
||||
messageList.at(messageIdx - 1)?.user_id !== message.user_id}
|
||||
onDelete={() => {
|
||||
const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}}
|
||||
onEdit={(content) => {
|
||||
const res = updateMessage(localStorage.token, message.channel_id, message.id, {
|
||||
content: content
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div class="pb-6" />
|
||||
</div>
|
||||
{/if}
|
203
src/lib/components/channel/Messages/Message.svelte
Normal file
203
src/lib/components/channel/Messages/Message.svelte
Normal file
@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
||||
|
||||
import { settings, user } from '$lib/stores';
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||
import Name from '$lib/components/chat/Messages/Name.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
|
||||
export let message;
|
||||
export let showUserProfile = true;
|
||||
|
||||
export let onDelete: Function = () => {};
|
||||
export let onEdit: Function = () => {};
|
||||
|
||||
let edit = false;
|
||||
let editedContent = null;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
const formatDate = (inputDate) => {
|
||||
const date = dayjs(inputDate);
|
||||
const now = dayjs();
|
||||
|
||||
if (date.isToday()) {
|
||||
return `Today at ${date.format('HH:mm')}`;
|
||||
} else if (date.isYesterday()) {
|
||||
return `Yesterday at ${date.format('HH:mm')}`;
|
||||
} else {
|
||||
return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
title={$i18n.t('Delete Message')}
|
||||
message={$i18n.t('Are you sure you want to delete this message?')}
|
||||
onConfirm={async () => {
|
||||
await onDelete();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class="flex flex-col justify-between px-5 {showUserProfile
|
||||
? 'pt-1.5 pb-0.5'
|
||||
: ''} w-full {($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-5xl'} mx-auto group hover:bg-gray-500/5 transition relative"
|
||||
>
|
||||
{#if message.user_id === $user.id && !edit}
|
||||
<div class=" absolute invisible group-hover:visible right-1 -top-2 z-30">
|
||||
<div
|
||||
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
|
||||
on:click={() => {
|
||||
edit = true;
|
||||
editedContent = message.content;
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
|
||||
on:click={() => (showDeleteConfirmDialog = true)}
|
||||
>
|
||||
<GarbageBin />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class=" flex w-full message-{message.id}"
|
||||
id="message-{message.id}"
|
||||
dir={$settings.chatDirection}
|
||||
>
|
||||
<div
|
||||
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
|
||||
>
|
||||
{#if showUserProfile}
|
||||
<ProfileImage
|
||||
src={message.user?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
className={'size-8 translate-y-1 ml-0.5'}
|
||||
/>
|
||||
{:else}
|
||||
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
|
||||
|
||||
{#if message.created_at}
|
||||
<div
|
||||
class="mt-1.5 flex flex-shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
|
||||
>
|
||||
<Tooltip
|
||||
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
|
||||
>
|
||||
{dayjs(message.created_at / 1000000).format('HH:mm')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-auto w-0 pl-1">
|
||||
{#if showUserProfile}
|
||||
<Name>
|
||||
<div class="text-sm">
|
||||
{message?.user?.name}
|
||||
</div>
|
||||
|
||||
{#if message.created_at}
|
||||
<div
|
||||
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 -mt-0.5"
|
||||
>
|
||||
<Tooltip
|
||||
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
|
||||
>
|
||||
{formatDate(message.created_at / 1000000)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</Name>
|
||||
{/if}
|
||||
|
||||
{#if edit}
|
||||
<div class="py-2">
|
||||
<Textarea
|
||||
className=" bg-transparent outline-none w-full resize-none"
|
||||
bind:value={editedContent}
|
||||
onKeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.getElementById('close-edit-message-button')?.click();
|
||||
}
|
||||
|
||||
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
|
||||
const isEnterPressed = e.key === 'Enter';
|
||||
|
||||
if (isCmdOrCtrlPressed && isEnterPressed) {
|
||||
document.getElementById('confirm-edit-message-button')?.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
|
||||
<div class="flex space-x-1.5">
|
||||
<button
|
||||
id="close-edit-message-button"
|
||||
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
|
||||
on:click={() => {
|
||||
edit = false;
|
||||
editedContent = null;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="confirm-edit-message-button"
|
||||
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
|
||||
on:click={async () => {
|
||||
onEdit(editedContent);
|
||||
edit = false;
|
||||
editedContent = null;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" min-w-full markdown-prose">
|
||||
<Markdown
|
||||
id={message.id}
|
||||
content={message.content}
|
||||
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
|
||||
>(edited)</span
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
84
src/lib/components/channel/Navbar.svelte
Normal file
84
src/lib/components/channel/Navbar.svelte
Normal file
@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { showArchivedChats, showSidebar, user } from '$lib/stores';
|
||||
|
||||
import { slide } from 'svelte/transition';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
|
||||
import MenuLines from '../icons/MenuLines.svelte';
|
||||
import PencilSquare from '../icons/PencilSquare.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let channel;
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
|
||||
<div
|
||||
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
|
||||
></div>
|
||||
|
||||
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
|
||||
<div class="flex items-center w-full max-w-full">
|
||||
<div
|
||||
class="{$showSidebar
|
||||
? 'md:hidden'
|
||||
: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<button
|
||||
id="sidebar-toggle-button"
|
||||
class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => {
|
||||
showSidebar.set(!$showSidebar);
|
||||
}}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<MenuLines />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-hidden max-w-full py-0.5
|
||||
{$showSidebar ? 'ml-1' : ''}
|
||||
"
|
||||
>
|
||||
<div class="line-clamp-1 capitalize font-medium font-primary text-lg">
|
||||
{channel.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
|
||||
{#if $user !== undefined}
|
||||
<UserMenu
|
||||
className="max-w-[200px]"
|
||||
role={$user.role}
|
||||
on:show={(e) => {
|
||||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class=" self-center">
|
||||
<img
|
||||
src={$user.profile_image_url}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -70,15 +70,15 @@
|
||||
generateMoACompletion,
|
||||
stopTask
|
||||
} from '$lib/apis';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
|
||||
import Banner from '../common/Banner.svelte';
|
||||
import MessageInput from '$lib/components/chat/MessageInput.svelte';
|
||||
import Messages from '$lib/components/chat/Messages.svelte';
|
||||
import Navbar from '$lib/components/layout/Navbar.svelte';
|
||||
import Navbar from '$lib/components/chat/Navbar.svelte';
|
||||
import ChatControls from './ChatControls.svelte';
|
||||
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Placeholder from './Placeholder.svelte';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
import NotificationToast from '../NotificationToast.svelte';
|
||||
|
||||
export let chatIdProp = '';
|
||||
|
@ -49,7 +49,7 @@
|
||||
|
||||
export let autoScroll = false;
|
||||
|
||||
export let atSelectedModel: Model | undefined;
|
||||
export let atSelectedModel: Model | undefined = undefined;
|
||||
export let selectedModels: [''];
|
||||
|
||||
let selectedModelIds = [];
|
||||
|
@ -1,3 +1,3 @@
|
||||
<div class=" self-center font-semibold mb-0.5 line-clamp-1 contents">
|
||||
<div class=" self-center font-semibold mb-0.5 line-clamp-1 flex gap-1 items-center">
|
||||
<slot />
|
||||
</div>
|
||||
|
194
src/lib/components/chat/Navbar.svelte
Normal file
194
src/lib/components/chat/Navbar.svelte
Normal file
@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import {
|
||||
WEBUI_NAME,
|
||||
chatId,
|
||||
mobile,
|
||||
settings,
|
||||
showArchivedChats,
|
||||
showControls,
|
||||
showSidebar,
|
||||
temporaryChatEnabled,
|
||||
user
|
||||
} from '$lib/stores';
|
||||
|
||||
import { slide } from 'svelte/transition';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import ShareChatModal from '../chat/ShareChatModal.svelte';
|
||||
import ModelSelector from '../chat/ModelSelector.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Menu from '$lib/components/layout/Navbar/Menu.svelte';
|
||||
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
|
||||
import MenuLines from '../icons/MenuLines.svelte';
|
||||
import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
|
||||
|
||||
import PencilSquare from '../icons/PencilSquare.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let initNewChat: Function;
|
||||
export let title: string = $WEBUI_NAME;
|
||||
export let shareEnabled: boolean = false;
|
||||
|
||||
export let chat;
|
||||
export let selectedModels;
|
||||
export let showModelSelector = true;
|
||||
|
||||
let showShareChatModal = false;
|
||||
let showDownloadChatModal = false;
|
||||
</script>
|
||||
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
|
||||
|
||||
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
|
||||
<div
|
||||
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
|
||||
></div>
|
||||
|
||||
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
|
||||
<div class="flex items-center w-full max-w-full">
|
||||
<div
|
||||
class="{$showSidebar
|
||||
? 'md:hidden'
|
||||
: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<button
|
||||
id="sidebar-toggle-button"
|
||||
class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => {
|
||||
showSidebar.set(!$showSidebar);
|
||||
}}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<MenuLines />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-hidden max-w-full py-0.5
|
||||
{$showSidebar ? 'ml-1' : ''}
|
||||
"
|
||||
>
|
||||
{#if showModelSelector}
|
||||
<ModelSelector bind:selectedModels showSetDefault={!shareEnabled} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
|
||||
<!-- <div class="md:hidden flex self-center w-[1px] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
|
||||
{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)}
|
||||
<Menu
|
||||
{chat}
|
||||
{shareEnabled}
|
||||
shareHandler={() => {
|
||||
showShareChatModal = !showShareChatModal;
|
||||
}}
|
||||
downloadHandler={() => {
|
||||
showDownloadChatModal = !showDownloadChatModal;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
id="chat-context-menu-button"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</Menu>
|
||||
{:else if $mobile}
|
||||
<Tooltip content={$i18n.t('Controls')}>
|
||||
<button
|
||||
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
on:click={async () => {
|
||||
await showControls.set(!$showControls);
|
||||
}}
|
||||
aria-label="Controls"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if !$mobile}
|
||||
<Tooltip content={$i18n.t('Controls')}>
|
||||
<button
|
||||
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
on:click={async () => {
|
||||
await showControls.set(!$showControls);
|
||||
}}
|
||||
aria-label="Controls"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('New Chat')}>
|
||||
<button
|
||||
id="new-chat-button"
|
||||
class=" flex {$showSidebar
|
||||
? 'md:hidden'
|
||||
: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => {
|
||||
initNewChat();
|
||||
}}
|
||||
aria-label="New Chat"
|
||||
>
|
||||
<div class=" m-auto self-center">
|
||||
<PencilSquare className=" size-5" strokeWidth="2" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if $user !== undefined}
|
||||
<UserMenu
|
||||
className="max-w-[200px]"
|
||||
role={$user.role}
|
||||
on:show={(e) => {
|
||||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class=" self-center">
|
||||
<img
|
||||
src={$user.profile_image_url}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -37,7 +37,6 @@
|
||||
|
||||
const confirmHandler = async () => {
|
||||
show = false;
|
||||
|
||||
await onConfirm();
|
||||
dispatch('confirm', inputValue);
|
||||
};
|
||||
@ -47,11 +46,15 @@
|
||||
});
|
||||
|
||||
$: if (mounted) {
|
||||
if (show) {
|
||||
if (show && modalElement) {
|
||||
document.body.appendChild(modalElement);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
} else if (modalElement) {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.removeChild(modalElement);
|
||||
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
}
|
||||
@ -62,7 +65,7 @@
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-[99999] overflow-hidden overscroll-contain"
|
||||
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-[99999999] overflow-hidden overscroll-contain"
|
||||
in:fade={{ duration: 10 }}
|
||||
on:mousedown={() => {
|
||||
show = false;
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
@ -7,6 +7,8 @@
|
||||
import ChevronDown from '../icons/ChevronDown.svelte';
|
||||
import ChevronRight from '../icons/ChevronRight.svelte';
|
||||
import Collapsible from './Collapsible.svelte';
|
||||
import Tooltip from './Tooltip.svelte';
|
||||
import Plus from '../icons/Plus.svelte';
|
||||
|
||||
export let open = true;
|
||||
|
||||
@ -14,6 +16,11 @@
|
||||
export let name = '';
|
||||
export let collapsible = true;
|
||||
|
||||
export let onAddLabel: string = '';
|
||||
export let onAdd: null | Function = null;
|
||||
|
||||
export let dragAndDrop = true;
|
||||
|
||||
export let className = '';
|
||||
|
||||
let folderElement;
|
||||
@ -84,12 +91,18 @@
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!dragAndDrop) {
|
||||
return;
|
||||
}
|
||||
folderElement.addEventListener('dragover', onDragOver);
|
||||
folderElement.addEventListener('drop', onDrop);
|
||||
folderElement.addEventListener('dragleave', onDragLeave);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!dragAndDrop) {
|
||||
return;
|
||||
}
|
||||
folderElement.addEventListener('dragover', onDragOver);
|
||||
folderElement.removeEventListener('drop', onDrop);
|
||||
folderElement.removeEventListener('dragleave', onDragLeave);
|
||||
@ -113,10 +126,10 @@
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
>
|
||||
<div
|
||||
class="w-full group rounded-md relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 text-gray-500 dark:text-gray-500 transition"
|
||||
>
|
||||
<button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium">
|
||||
<div class="text-gray-300 dark:text-gray-600">
|
||||
{#if open}
|
||||
<ChevronDown className=" size-3" strokeWidth="2.5" />
|
||||
@ -129,6 +142,25 @@
|
||||
{name}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if onAdd}
|
||||
<button
|
||||
class="absolute z-10 right-2 self-center flex items-center"
|
||||
on:pointerup={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
<Tooltip content={onAddLabel}>
|
||||
<button
|
||||
class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto"
|
||||
on:click={(e) => {}}
|
||||
>
|
||||
<Plus className=" size-3" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div slot="content" class="w-full">
|
||||
|
@ -19,6 +19,7 @@
|
||||
on:click={() => {
|
||||
showImagePreview = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<img src={_src} {alt} class={imageClassName} draggable="false" data-cy="image" />
|
||||
</button>
|
||||
|
@ -6,6 +6,8 @@
|
||||
export let className =
|
||||
'w-full rounded-lg px-3 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none h-full';
|
||||
|
||||
export let onKeydown: Function = () => {};
|
||||
|
||||
let textareaElement;
|
||||
|
||||
$: if (textareaElement) {
|
||||
@ -48,6 +50,7 @@
|
||||
value = text;
|
||||
}}
|
||||
on:paste={handlePaste}
|
||||
on:keydown={onKeydown}
|
||||
data-placeholder={placeholder}
|
||||
/>
|
||||
|
||||
|
12
src/lib/components/icons/Cog6Solid.svelte
Normal file
12
src/lib/components/icons/Cog6Solid.svelte
Normal file
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class={className}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.455 1.45A.5.5 0 0 1 6.952 1h2.096a.5.5 0 0 1 .497.45l.186 1.858a4.996 4.996 0 0 1 1.466.848l1.703-.769a.5.5 0 0 1 .639.206l1.047 1.814a.5.5 0 0 1-.14.656l-1.517 1.09a5.026 5.026 0 0 1 0 1.694l1.516 1.09a.5.5 0 0 1 .141.656l-1.047 1.814a.5.5 0 0 1-.639.206l-1.703-.768c-.433.36-.928.649-1.466.847l-.186 1.858a.5.5 0 0 1-.497.45H6.952a.5.5 0 0 1-.497-.45l-.186-1.858a4.993 4.993 0 0 1-1.466-.848l-1.703.769a.5.5 0 0 1-.639-.206l-1.047-1.814a.5.5 0 0 1 .14-.656l1.517-1.09a5.033 5.033 0 0 1 0-1.694l-1.516-1.09a.5.5 0 0 1-.141-.656L2.46 3.593a.5.5 0 0 1 .639-.206l1.703.769c.433-.36.928-.65 1.466-.848l.186-1.858Zm-.177 7.567-.022-.037a2 2 0 0 1 3.466-1.997l.022.037a2 2 0 0 1-3.466 1.997Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
@ -16,7 +16,10 @@
|
||||
pinnedChats,
|
||||
scrollPaginationEnabled,
|
||||
currentChatPage,
|
||||
temporaryChatEnabled
|
||||
temporaryChatEnabled,
|
||||
channels,
|
||||
socket,
|
||||
config
|
||||
} from '$lib/stores';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
|
||||
@ -49,6 +52,10 @@
|
||||
import Plus from '../icons/Plus.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Folders from './Sidebar/Folders.svelte';
|
||||
import { getChannels, createNewChannel } from '$lib/apis/channels';
|
||||
import ChannelModal from './Sidebar/ChannelModal.svelte';
|
||||
import ChannelItem from './Sidebar/ChannelItem.svelte';
|
||||
import PencilSquare from '../icons/PencilSquare.svelte';
|
||||
|
||||
const BREAKPOINT = 768;
|
||||
|
||||
@ -61,6 +68,8 @@
|
||||
let showDropdown = false;
|
||||
let showPinnedChat = true;
|
||||
|
||||
let showCreateChannel = false;
|
||||
|
||||
// Pagination variables
|
||||
let chatListLoading = false;
|
||||
let allChatsLoaded = false;
|
||||
@ -143,6 +152,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const initChannels = async () => {
|
||||
await channels.set(await getChannels(localStorage.token));
|
||||
};
|
||||
|
||||
const initChatList = async () => {
|
||||
// Reset pagination variables
|
||||
tags.set(await getAllTags(localStorage.token));
|
||||
@ -346,6 +359,7 @@
|
||||
localStorage.sidebar = value;
|
||||
});
|
||||
|
||||
await initChannels();
|
||||
await initChatList();
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
@ -389,6 +403,24 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChannelModal
|
||||
bind:show={showCreateChannel}
|
||||
onSubmit={async ({ name, access_control }) => {
|
||||
const res = await createNewChannel(localStorage.token, {
|
||||
name: name,
|
||||
access_control: access_control
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
await initChannels();
|
||||
showCreateChannel = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
{#if $showSidebar}
|
||||
@ -415,36 +447,6 @@
|
||||
: 'invisible'}"
|
||||
>
|
||||
<div class="px-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400">
|
||||
<a
|
||||
id="sidebar-new-chat-button"
|
||||
class="flex flex-1 rounded-lg px-2 py-1 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={async () => {
|
||||
selectedChatId = null;
|
||||
await goto('/');
|
||||
const newChatButton = document.getElementById('new-chat-button');
|
||||
setTimeout(() => {
|
||||
newChatButton?.click();
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<div class="self-center mx-1.5">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||
class=" size-5 -translate-x-1.5 rounded-full"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
<div class=" self-center font-medium text-sm text-gray-850 dark:text-white font-primary">
|
||||
{$i18n.t('New Chat')}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class=" cursor-pointer p-[7px] flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
@ -468,6 +470,42 @@
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<a
|
||||
id="sidebar-new-chat-button"
|
||||
class="flex justify-between items-center flex-1 rounded-lg px-2 py-1 h-full text-right hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={async () => {
|
||||
selectedChatId = null;
|
||||
await goto('/');
|
||||
const newChatButton = document.getElementById('new-chat-button');
|
||||
setTimeout(() => {
|
||||
newChatButton?.click();
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="self-center mx-1.5">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||
class=" size-5 -translate-x-1.5 rounded-full"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
<div class=" self-center font-medium text-sm text-gray-850 dark:text-white font-primary">
|
||||
{$i18n.t('New Chat')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PencilSquare className=" size-5" strokeWidth="2" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
|
||||
@ -519,19 +557,6 @@
|
||||
on:input={searchDebounceHandler}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
|
||||
<div class="absolute z-40 right-3.5 top-1">
|
||||
<Tooltip content={$i18n.t('New folder')}>
|
||||
<button
|
||||
class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
createFolder();
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -539,10 +564,6 @@
|
||||
? 'opacity-20'
|
||||
: ''}"
|
||||
>
|
||||
{#if $temporaryChatEnabled}
|
||||
<div class="absolute z-40 w-full h-full flex justify-center"></div>
|
||||
{/if}
|
||||
|
||||
{#if !search && $pinnedChats.length > 0}
|
||||
<div class="flex flex-col space-y-1 rounded-xl">
|
||||
<Folder
|
||||
@ -619,76 +640,103 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
|
||||
{#if !search && folders}
|
||||
<Folders
|
||||
{folders}
|
||||
on:import={(e) => {
|
||||
const { folderId, items } = e.detail;
|
||||
importChatHandler(items, false, folderId);
|
||||
}}
|
||||
on:update={async (e) => {
|
||||
{#if $config?.features?.enable_channels && ($user.role === 'admin' || $channels.length > 0) && !search}
|
||||
<Folder
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Channels')}
|
||||
dragAndDrop={false}
|
||||
onAdd={$user.role === 'admin'
|
||||
? () => {
|
||||
showCreateChannel = true;
|
||||
}
|
||||
: null}
|
||||
onAddLabel={$i18n.t('Create Channel')}
|
||||
>
|
||||
{#each $channels as channel}
|
||||
<ChannelItem
|
||||
{channel}
|
||||
onUpdate={async () => {
|
||||
await initChannels();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</Folder>
|
||||
{/if}
|
||||
|
||||
{#if !search && folders}
|
||||
<Folders
|
||||
{folders}
|
||||
on:import={(e) => {
|
||||
const { folderId, items } = e.detail;
|
||||
importChatHandler(items, false, folderId);
|
||||
}}
|
||||
on:update={async (e) => {
|
||||
initChatList();
|
||||
}}
|
||||
on:change={async () => {
|
||||
initChatList();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Folder
|
||||
collapsible={!search}
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Chats')}
|
||||
on:import={(e) => {
|
||||
importChatHandler(e.detail);
|
||||
}}
|
||||
on:drop={async (e) => {
|
||||
const { type, id, item } = e.detail;
|
||||
|
||||
if (type === 'chat') {
|
||||
let chat = await getChatById(localStorage.token, id).catch((error) => {
|
||||
return null;
|
||||
});
|
||||
if (!chat && item) {
|
||||
chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
|
||||
}
|
||||
|
||||
if (chat) {
|
||||
console.log(chat);
|
||||
if (chat.folder_id) {
|
||||
const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (chat.pinned) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
|
||||
}
|
||||
|
||||
initChatList();
|
||||
}}
|
||||
on:change={async () => {
|
||||
initChatList();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
} else if (type === 'folder') {
|
||||
if (folders[id].parent_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
await initFolders();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if $temporaryChatEnabled}
|
||||
<div class="absolute z-40 w-full h-full flex justify-center"></div>
|
||||
{/if}
|
||||
|
||||
<Folder
|
||||
collapsible={!search}
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('All chats')}
|
||||
on:import={(e) => {
|
||||
importChatHandler(e.detail);
|
||||
}}
|
||||
on:drop={async (e) => {
|
||||
const { type, id, item } = e.detail;
|
||||
|
||||
if (type === 'chat') {
|
||||
let chat = await getChatById(localStorage.token, id).catch((error) => {
|
||||
return null;
|
||||
});
|
||||
if (!chat && item) {
|
||||
chat = await importChat(localStorage.token, item.chat, item?.meta ?? {});
|
||||
}
|
||||
|
||||
if (chat) {
|
||||
console.log(chat);
|
||||
if (chat.folder_id) {
|
||||
const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (chat.pinned) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
|
||||
}
|
||||
|
||||
initChatList();
|
||||
}
|
||||
} else if (type === 'folder') {
|
||||
if (folders[id].parent_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
await initFolders();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
|
||||
<div class="pt-1.5">
|
||||
{#if $chats}
|
||||
{#each $chats as chat, idx}
|
||||
@ -766,8 +814,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Folder>
|
||||
</div>
|
||||
</div>
|
||||
</Folder>
|
||||
</div>
|
||||
|
||||
<div class="px-2">
|
||||
|
95
src/lib/components/layout/Sidebar/ChannelItem.svelte
Normal file
95
src/lib/components/layout/Sidebar/ChannelItem.svelte
Normal file
@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { mobile, showSidebar, user } from '$lib/stores';
|
||||
import { updateChannelById } from '$lib/apis/channels';
|
||||
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import ChannelModal from './ChannelModal.svelte';
|
||||
|
||||
export let onUpdate: Function = () => {};
|
||||
|
||||
export let className = '';
|
||||
export let channel;
|
||||
|
||||
let showEditChannelModal = false;
|
||||
|
||||
let itemElement;
|
||||
</script>
|
||||
|
||||
<ChannelModal
|
||||
bind:show={showEditChannelModal}
|
||||
{channel}
|
||||
edit={true}
|
||||
{onUpdate}
|
||||
onSubmit={async ({ name, access_control }) => {
|
||||
const res = await updateChannelById(localStorage.token, channel.id, {
|
||||
name,
|
||||
access_control
|
||||
}).catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success('Channel updated successfully');
|
||||
}
|
||||
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={itemElement}
|
||||
class=" w-full {className} rounded-lg flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page
|
||||
.url.pathname === `/channels/${channel.id}`
|
||||
? 'bg-gray-100 dark:bg-gray-900'
|
||||
: ''} px-2.5 py-1"
|
||||
>
|
||||
<a
|
||||
class=" w-full flex justify-between"
|
||||
href="/channels/{channel.id}"
|
||||
on:click={() => {
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.487 2.89a.75.75 0 1 0-1.474-.28l-.455 2.388H3.61a.75.75 0 0 0 0 1.5h1.663l-.571 2.998H2.75a.75.75 0 0 0 0 1.5h1.666l-.403 2.114a.75.75 0 0 0 1.474.28l.456-2.394h2.973l-.403 2.114a.75.75 0 0 0 1.474.28l.456-2.394h1.947a.75.75 0 0 0 0-1.5h-1.661l.57-2.998h1.95a.75.75 0 0 0 0-1.5h-1.664l.402-2.108a.75.75 0 0 0-1.474-.28l-.455 2.388H7.085l.402-2.108ZM6.8 6.498l-.571 2.998h2.973l.57-2.998H6.8Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class=" text-left self-center overflow-hidden w-full line-clamp-1">
|
||||
{channel.name}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if $user?.role === 'admin'}
|
||||
<button
|
||||
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
|
||||
on:pointerup={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
showEditChannelModal = true;
|
||||
}}
|
||||
>
|
||||
<button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}>
|
||||
<Cog6 className="size-3.5" />
|
||||
</button>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
200
src/lib/components/layout/Sidebar/ChannelModal.svelte
Normal file
200
src/lib/components/layout/Sidebar/ChannelModal.svelte
Normal file
@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { getContext, createEventDispatcher, onMount } from 'svelte';
|
||||
import { createNewChannel, deleteChannelById } from '$lib/apis/channels';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
||||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onUpdate: Function = () => {};
|
||||
|
||||
export let channel = null;
|
||||
export let edit = false;
|
||||
|
||||
let name = '';
|
||||
let accessControl = null;
|
||||
|
||||
let loading = false;
|
||||
|
||||
$: if (name) {
|
||||
name = name.replace(/\s/g, '-').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
await onSubmit({
|
||||
name: name.replace(/\s/g, '-'),
|
||||
access_control: accessControl
|
||||
});
|
||||
show = false;
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
name = channel.name;
|
||||
accessControl = channel.access_control;
|
||||
};
|
||||
|
||||
$: if (channel) {
|
||||
init();
|
||||
}
|
||||
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
const deleteHandler = async () => {
|
||||
showDeleteConfirmDialog = false;
|
||||
|
||||
const res = await deleteChannelById(localStorage.token, channel.id).catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success('Channel deleted successfully');
|
||||
onUpdate();
|
||||
|
||||
if ($page.url.pathname === `/channels/${channel.id}`) {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
|
||||
show = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
|
||||
<div class=" text-lg font-medium self-center">
|
||||
{#if edit}
|
||||
{$i18n.t('Edit Channel')}
|
||||
{:else}
|
||||
{$i18n.t('Create Channel')}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
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"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
on:submit|preventDefault={() => {
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Channel Name')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('new-channel')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-black/90 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if edit}
|
||||
{$i18n.t('Update')}
|
||||
{:else}
|
||||
{$i18n.t('Create')}
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<svg
|
||||
class=" w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style><path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/><path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to delete this channel?')}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => {
|
||||
deleteHandler();
|
||||
}}
|
||||
/>
|
@ -23,6 +23,8 @@ export const theme = writable('system');
|
||||
export const chatId = writable('');
|
||||
export const chatTitle = writable('');
|
||||
|
||||
|
||||
export const channels = writable([]);
|
||||
export const chats = writable([]);
|
||||
export const pinnedChats = writable([]);
|
||||
export const tags = writable([]);
|
||||
|
7
src/routes/(app)/channels/[id]/+page.svelte
Normal file
7
src/routes/(app)/channels/[id]/+page.svelte
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import Channel from '$lib/components/channel/Channel.svelte';
|
||||
</script>
|
||||
|
||||
<Channel id={$page.params.id} />
|
@ -38,7 +38,7 @@
|
||||
let loaded = false;
|
||||
const BREAKPOINT = 768;
|
||||
|
||||
const setupSocket = (enableWebsocket) => {
|
||||
const setupSocket = async (enableWebsocket) => {
|
||||
const _socket = io(`${WEBUI_BASE_URL}` || undefined, {
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
@ -49,7 +49,7 @@
|
||||
auth: { token: localStorage.token }
|
||||
});
|
||||
|
||||
socket.set(_socket);
|
||||
await socket.set(_socket);
|
||||
|
||||
_socket.on('connect_error', (err) => {
|
||||
console.log('connect_error', err);
|
||||
@ -127,7 +127,7 @@
|
||||
await WEBUI_NAME.set(backendConfig.name);
|
||||
|
||||
if ($config) {
|
||||
setupSocket($config.features?.enable_websocket ?? true);
|
||||
await setupSocket($config.features?.enable_websocket ?? true);
|
||||
|
||||
if (localStorage.token) {
|
||||
// Get Session User Info
|
||||
@ -138,6 +138,8 @@
|
||||
|
||||
if (sessionUser) {
|
||||
// Save Session User to Store
|
||||
$socket.emit('user-join', { auth: { token: sessionUser.token } });
|
||||
|
||||
await user.set(sessionUser);
|
||||
await config.set(await getBackendConfig());
|
||||
} else {
|
||||
|
Loading…
x
Reference in New Issue
Block a user