Merge pull request #8015 from open-webui/channels

feat: channels
This commit is contained in:
Timothy Jaeryang Baek 2024-12-23 00:23:05 -08:00 committed by GitHub
commit f05dbb895e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2578 additions and 135 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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")

View 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()

View 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()

View File

@ -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

View File

@ -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

View 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()
)

View File

@ -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())})

View 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;
}

View File

@ -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}

View 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>

View 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>

View 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}

View 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}

View 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>

View File

@ -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 = '';

View File

@ -49,7 +49,7 @@
export let autoScroll = false;
export let atSelectedModel: Model | undefined;
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
let selectedModelIds = [];

View File

@ -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>

View 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>

View File

@ -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;

View File

@ -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">

View File

@ -19,6 +19,7 @@
on:click={() => {
showImagePreview = true;
}}
type="button"
>
<img src={_src} {alt} class={imageClassName} draggable="false" data-cy="image" />
</button>

View File

@ -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}
/>

View 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>

View File

@ -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">

View 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>

View 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();
}}
/>

View File

@ -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([]);

View 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} />

View File

@ -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 {