瀏覽代碼

Merge pull request #8015 from open-webui/channels

feat: channels
Timothy Jaeryang Baek 4 月之前
父節點
當前提交
f05dbb895e
共有 31 個文件被更改,包括 2569 次插入126 次删除
  1. 6 0
      backend/open_webui/config.py
  2. 7 0
      backend/open_webui/main.py
  3. 48 0
      backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py
  4. 132 0
      backend/open_webui/models/channels.py
  5. 141 0
      backend/open_webui/models/messages.py
  6. 7 0
      backend/open_webui/models/users.py
  7. 3 0
      backend/open_webui/routers/auths.py
  8. 336 0
      backend/open_webui/routers/channels.py
  9. 7 1
      backend/open_webui/socket/main.py
  10. 300 0
      src/lib/apis/channels/index.ts
  11. 11 1
      src/lib/components/admin/Settings/General.svelte
  12. 145 0
      src/lib/components/channel/Channel.svelte
  13. 299 0
      src/lib/components/channel/MessageInput.svelte
  14. 117 0
      src/lib/components/channel/Messages.svelte
  15. 203 0
      src/lib/components/channel/Messages/Message.svelte
  16. 84 0
      src/lib/components/channel/Navbar.svelte
  17. 2 2
      src/lib/components/chat/Chat.svelte
  18. 1 1
      src/lib/components/chat/MessageInput.svelte
  19. 1 1
      src/lib/components/chat/Messages/Name.svelte
  20. 194 0
      src/lib/components/chat/Navbar.svelte
  21. 7 4
      src/lib/components/common/ConfirmDialog.svelte
  22. 37 5
      src/lib/components/common/Folder.svelte
  23. 1 0
      src/lib/components/common/Image.svelte
  24. 3 0
      src/lib/components/common/Textarea.svelte
  25. 12 0
      src/lib/components/icons/Cog6Solid.svelte
  26. 156 108
      src/lib/components/layout/Sidebar.svelte
  27. 95 0
      src/lib/components/layout/Sidebar/ChannelItem.svelte
  28. 200 0
      src/lib/components/layout/Sidebar/ChannelModal.svelte
  29. 2 0
      src/lib/stores/index.ts
  30. 7 0
      src/routes/(app)/channels/[id]/+page.svelte
  31. 5 3
      src/routes/+layout.svelte

+ 6 - 0
backend/open_webui/config.py

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

+ 7 - 0
backend/open_webui/main.py

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

+ 48 - 0
backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py

@@ -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 - 0
backend/open_webui/models/channels.py

@@ -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 - 0
backend/open_webui/models/messages.py

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

+ 7 - 0
backend/open_webui/models/users.py

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

+ 3 - 0
backend/open_webui/routers/auths.py

@@ -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 - 0
backend/open_webui/routers/channels.py

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

+ 7 - 1
backend/open_webui/socket/main.py

@@ -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 - 0
src/lib/apis/channels/index.ts

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

+ 11 - 1
src/lib/components/admin/Settings/General.svelte

@@ -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 - 0
src/lib/components/channel/Channel.svelte

@@ -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 - 0
src/lib/components/channel/MessageInput.svelte

@@ -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 - 0
src/lib/components/channel/Messages.svelte

@@ -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 - 0
src/lib/components/channel/Messages/Message.svelte

@@ -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 - 0
src/lib/components/channel/Navbar.svelte

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

+ 2 - 2
src/lib/components/chat/Chat.svelte

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

+ 1 - 1
src/lib/components/chat/MessageInput.svelte

@@ -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 - 1
src/lib/components/chat/Messages/Name.svelte

@@ -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 - 0
src/lib/components/chat/Navbar.svelte

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

+ 7 - 4
src/lib/components/common/ConfirmDialog.svelte

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

+ 37 - 5
src/lib/components/common/Folder.svelte

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

+ 1 - 0
src/lib/components/common/Image.svelte

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

+ 3 - 0
src/lib/components/common/Textarea.svelte

@@ -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 - 0
src/lib/components/icons/Cog6Solid.svelte

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

+ 156 - 108
src/lib/components/layout/Sidebar.svelte

@@ -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) => {
-							initChatList();
-						}}
-						on:change={async () => {
-							initChatList();
-						}}
-					/>
-				{/if}
-
+			{#if $config?.features?.enable_channels && ($user.role === 'admin' || $channels.length > 0) && !search}
 				<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 ?? {});
+					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 (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 !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}
 
-								if (chat.pinned) {
-									const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
-								}
+			<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 ?? {});
+						}
 
-								initChatList();
+						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;
+									}
+								);
 							}
-						} else if (type === 'folder') {
-							if (folders[id].parent_id === null) {
-								return;
+
+							if (chat.pinned) {
+								const res = await toggleChatPinnedStatusById(localStorage.token, chat, id);
 							}
 
-							const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
-								(error) => {
-									toast.error(error);
-									return null;
-								}
-							);
+							initChatList();
+						}
+					} else if (type === 'folder') {
+						if (folders[id].parent_id === null) {
+							return;
+						}
 
-							if (res) {
-								await initFolders();
+						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}
+
+				<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 - 0
src/lib/components/layout/Sidebar/ChannelItem.svelte

@@ -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 - 0
src/lib/components/layout/Sidebar/ChannelModal.svelte

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

+ 2 - 0
src/lib/stores/index.ts

@@ -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 - 0
src/routes/(app)/channels/[id]/+page.svelte

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

+ 5 - 3
src/routes/+layout.svelte

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