Merge remote-tracking branch 'origin/dev' into feature/tools-system

This commit is contained in:
Oleg Yermolenko 2025-11-28 20:03:34 +02:00
commit 7e3d7b8a00
33 changed files with 1228 additions and 300 deletions

View file

@ -66,7 +66,6 @@ from open_webui.socket.main import (
periodic_usage_pool_cleanup,
get_event_emitter,
get_models_in_use,
get_active_user_ids,
)
from open_webui.routers import (
audio,
@ -2021,7 +2020,10 @@ async def get_current_usage(user=Depends(get_verified_user)):
This is an experimental endpoint and subject to change.
"""
try:
return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()}
return {
"model_ids": get_models_in_use(),
"user_count": Users.get_active_user_count(),
}
except Exception as e:
log.error(f"Error getting usage statistics: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error")

View file

@ -0,0 +1,251 @@
"""Update user table
Revision ID: b10670c03dd5
Revises: 2f1211949ecc
Create Date: 2025-11-28 04:55:31.737538
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import json
import time
# revision identifiers, used by Alembic.
revision: str = "b10670c03dd5"
down_revision: Union[str, None] = "2f1211949ecc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
"""
SQLite requires manual removal of any indexes referencing a column
before ALTER TABLE ... DROP COLUMN can succeed.
"""
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
for idx in indexes:
index_name = idx[1] # index name
# Get indexed columns
idx_info = conn.execute(
sa.text(f"PRAGMA index_info('{index_name}')")
).fetchall()
indexed_cols = [row[2] for row in idx_info] # col names
if column_name in indexed_cols:
conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}"))
def _convert_column_to_json(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == "sqlite":
# 1. Add temporary column
op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True))
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for row in rows:
uid, raw = row
if raw is None:
parsed = None
else:
try:
parsed = json.loads(raw)
except Exception:
parsed = None # fallback safe behavior
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{"val": json.dumps(parsed) if parsed else None, "id": uid},
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f"{column}_json", new_column_name=column)
else:
# PostgreSQL supports direct CAST
op.alter_column(
table,
column,
type_=sa.JSON(),
postgresql_using=f"{column}::json",
)
def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
if dialect == "sqlite":
op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True))
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for uid, raw in rows:
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{"val": json.dumps(raw) if raw else None, "id": uid},
)
op.drop_column(table, column)
op.alter_column(table, f"{column}_text", new_column_name=column)
else:
op.alter_column(
table,
column,
type_=sa.Text(),
postgresql_using=f"to_json({column})::text",
)
def upgrade() -> None:
op.add_column(
"user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True)
)
op.add_column("user", sa.Column("timezone", sa.String(), nullable=True))
op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True))
op.add_column(
"user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True)
)
op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json("user", "info")
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json("user", "settings")
op.create_table(
"api_key",
sa.Column("id", sa.Text(), primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")),
sa.Column("key", sa.Text(), unique=True, nullable=False),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.BigInteger(), nullable=True),
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')
).fetchall()
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if "@" in oauth_sub:
provider, sub = oauth_sub.split("@", 1)
else:
provider, sub = "oidc", oauth_sub
oauth_json = json.dumps({provider: {"sub": sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{"oauth": oauth_json, "id": uid},
)
users_with_keys = conn.execute(
sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')
).fetchall()
now = int(time.time())
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text(
"""
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""
),
{
"id": f"key_{uid}",
"user_id": uid,
"key": api_key,
"created_at": now,
"updated_at": now,
},
)
if conn.dialect.name == "sqlite":
_drop_sqlite_indexes_for_column("user", "api_key", conn)
_drop_sqlite_indexes_for_column("user", "oauth_sub", conn)
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("api_key")
batch_op.drop_column("oauth_sub")
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True))
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')
).fetchall()
for uid, oauth in users:
try:
data = json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get("sub")
oauth_sub = f"{provider}@{sub}"
except Exception:
oauth_sub = None
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{"oauth_sub": oauth_sub, "id": uid},
)
op.drop_column("user", "oauth")
# --- 2. Restore api_key field ---
op.add_column("user", sa.Column("api_key", sa.String(), nullable=True))
# Restore values from api_key
keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall()
for uid, key in keys:
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{"key": key, "id": uid},
)
# Drop new table
op.drop_table("api_key")
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("profile_banner_image_url")
batch_op.drop_column("timezone")
batch_op.drop_column("presence_state")
batch_op.drop_column("status_emoji")
batch_op.drop_column("status_message")
batch_op.drop_column("status_expires_at")
# Convert info (JSON) → TEXT
_convert_column_to_text("user", "info")
# Convert settings (JSON) → TEXT
_convert_column_to_text("user", "settings")

View file

@ -88,7 +88,7 @@ class AuthsTable:
name: str,
profile_image_url: str = "/user.png",
role: str = "pending",
oauth_sub: Optional[str] = None,
oauth: Optional[dict] = None,
) -> Optional[UserModel]:
with get_db() as db:
log.info("insert_new_auth")
@ -102,7 +102,7 @@ class AuthsTable:
db.add(result)
user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth_sub
id, name, email, profile_image_url, role, oauth=oauth
)
db.commit()

View file

@ -90,6 +90,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
temp_id: Optional[str] = None
content: str
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
@ -111,6 +112,10 @@ class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
class MessageWithReactionsResponse(MessageUserResponse):
reactions: list[Reactions]
class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int]
reply_count: int
@ -306,6 +311,20 @@ class MessageTable:
)
return MessageModel.model_validate(message) if message else None
def get_pinned_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, is_pinned=True)
.order_by(Message.pinned_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]:
@ -325,7 +344,7 @@ class MessageTable:
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def update_message_pin_by_id(
def update_is_pinned_by_id(
self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
) -> Optional[MessageModel]:
with get_db() as db:
@ -333,7 +352,6 @@ class MessageTable:
message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None
message.pinned_by = pinned_by if is_pinned else None
message.updated_at = int(time.time_ns())
db.commit()
db.refresh(message)
return MessageModel.model_validate(message) if message else None

View file

@ -11,7 +11,17 @@ from open_webui.utils.misc import throttle
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select
from sqlalchemy import (
BigInteger,
JSON,
Column,
String,
Boolean,
Text,
Date,
exists,
select,
)
from sqlalchemy import or_, case
import datetime
@ -21,59 +31,71 @@ import datetime
####################
class User(Base):
__tablename__ = "user"
id = Column(String, primary_key=True, unique=True)
name = Column(String)
email = Column(String)
username = Column(String(50), nullable=True)
role = Column(String)
profile_image_url = Column(Text)
bio = Column(Text, nullable=True)
gender = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
info = Column(JSONField, nullable=True)
settings = Column(JSONField, nullable=True)
api_key = Column(String, nullable=True, unique=True)
oauth_sub = Column(Text, unique=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class UserSettings(BaseModel):
ui: Optional[dict] = {}
model_config = ConfigDict(extra="allow")
pass
class User(Base):
__tablename__ = "user"
id = Column(String, primary_key=True, unique=True)
email = Column(String)
username = Column(String(50), nullable=True)
role = Column(String)
name = Column(String)
profile_image_url = Column(Text)
profile_banner_image_url = Column(Text, nullable=True)
bio = Column(Text, nullable=True)
gender = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
timezone = Column(String, nullable=True)
presence_state = Column(String, nullable=True)
status_emoji = Column(String, nullable=True)
status_message = Column(Text, nullable=True)
status_expires_at = Column(BigInteger, nullable=True)
info = Column(JSON, nullable=True)
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class UserModel(BaseModel):
id: str
name: str
email: str
username: Optional[str] = None
role: str = "pending"
name: str
profile_image_url: str
profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
timezone: Optional[str] = None
presence_state: Optional[str] = None
status_emoji: Optional[str] = None
status_message: Optional[str] = None
status_expires_at: Optional[int] = None
info: Optional[dict] = None
settings: Optional[UserSettings] = None
api_key: Optional[str] = None
oauth_sub: Optional[str] = None
oauth: Optional[dict] = None
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -82,6 +104,32 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ApiKey(Base):
__tablename__ = "api_key"
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text, nullable=False)
key = Column(Text, unique=True, nullable=False)
data = Column(JSON, nullable=True)
expires_at = Column(BigInteger, nullable=True)
last_used_at = Column(BigInteger, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
class ApiKeyModel(BaseModel):
id: str
user_id: str
key: str
data: Optional[dict] = None
expires_at: Optional[int] = None
last_used_at: Optional[int] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
@ -125,6 +173,12 @@ class UserIdNameResponse(BaseModel):
name: str
class UserIdNameStatusResponse(BaseModel):
id: str
name: str
is_active: bool = False
class UserInfoListResponse(BaseModel):
users: list[UserInfoResponse]
total: int
@ -171,20 +225,20 @@ class UsersTable:
email: str,
profile_image_url: str = "/user.png",
role: str = "pending",
oauth_sub: Optional[str] = None,
oauth: Optional[dict] = None,
) -> Optional[UserModel]:
with get_db() as db:
user = UserModel(
**{
"id": id,
"name": name,
"email": email,
"name": name,
"role": role,
"profile_image_url": profile_image_url,
"last_active_at": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
"oauth_sub": oauth_sub,
"oauth": oauth,
}
)
result = User(**user.model_dump())
@ -207,8 +261,13 @@ class UsersTable:
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
try:
with get_db() as db:
user = db.query(User).filter_by(api_key=api_key).first()
return UserModel.model_validate(user)
user = (
db.query(User)
.join(ApiKey, User.id == ApiKey.user_id)
.filter(ApiKey.key == api_key)
.first()
)
return UserModel.model_validate(user) if user else None
except Exception:
return None
@ -220,11 +279,15 @@ class UsersTable:
except Exception:
return None
def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]:
def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]:
try:
with get_db() as db:
user = db.query(User).filter_by(oauth_sub=sub).first()
return UserModel.model_validate(user)
user = (
db.query(User)
.filter(User.oauth.contains({provider: {"sub": sub}}))
.first()
)
return UserModel.model_validate(user) if user else None
except Exception:
return None
@ -426,7 +489,7 @@ class UsersTable:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
def update_last_active_by_id(self, id: str) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update(
@ -439,16 +502,35 @@ class UsersTable:
except Exception:
return None
def update_user_oauth_sub_by_id(
self, id: str, oauth_sub: str
def update_user_oauth_by_id(
self, id: str, provider: str, sub: str
) -> Optional[UserModel]:
"""
Update or insert an OAuth provider/sub pair into the user's oauth JSON field.
Example resulting structure:
{
"google": { "sub": "123" },
"github": { "sub": "abc" }
}
"""
try:
with get_db() as db:
db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub})
user = db.query(User).filter_by(id=id).first()
if not user:
return None
# Load existing oauth JSON or create empty
oauth = user.oauth or {}
# Update or insert provider entry
oauth[provider] = {"sub": sub}
# Persist updated JSON
db.query(User).filter_by(id=id).update({"oauth": oauth})
db.commit()
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
except Exception:
return None
@ -502,23 +584,45 @@ class UsersTable:
except Exception:
return False
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
try:
with get_db() as db:
result = db.query(User).filter_by(id=id).update({"api_key": api_key})
db.commit()
return True if result == 1 else False
except Exception:
return False
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
try:
with get_db() as db:
user = db.query(User).filter_by(id=id).first()
return user.api_key
api_key = db.query(ApiKey).filter_by(user_id=id).first()
return api_key.key if api_key else None
except Exception:
return None
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
try:
with get_db() as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
now = int(time.time())
new_api_key = ApiKey(
id=f"key_{id}",
user_id=id,
key=api_key,
created_at=now,
updated_at=now,
)
db.add(new_api_key)
db.commit()
return True
except Exception:
return False
def delete_user_api_key_by_id(self, id: str) -> bool:
try:
with get_db() as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
return True
except Exception:
return False
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
@ -532,5 +636,23 @@ class UsersTable:
else:
return None
def get_active_user_count(self) -> int:
with get_db() as db:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
count = (
db.query(User).filter(User.last_active_at >= three_minutes_ago).count()
)
return count
def is_user_active(self, user_id: str) -> bool:
with get_db() as db:
user = db.query(User).filter_by(id=user_id).first()
if user and user.last_active_at:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
return user.last_active_at >= three_minutes_ago
return False
Users = UsersTable()

View file

@ -1133,8 +1133,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
# delete api key
@router.delete("/api_key", response_model=bool)
async def delete_api_key(user=Depends(get_current_user)):
success = Users.update_user_api_key_by_id(user.id, None)
return success
return Users.delete_user_api_key_by_id(user.id)
# get api key

View file

@ -10,10 +10,10 @@ from pydantic import BaseModel
from open_webui.socket.main import (
sio,
get_user_ids_from_room,
get_active_status_by_user_id,
)
from open_webui.models.users import (
UserIdNameResponse,
UserIdNameStatusResponse,
UserListResponse,
UserModelResponse,
Users,
@ -31,6 +31,7 @@ from open_webui.models.messages import (
Messages,
MessageModel,
MessageResponse,
MessageWithReactionsResponse,
MessageForm,
)
@ -68,7 +69,7 @@ router = APIRouter()
class ChannelListItemResponse(ChannelModel):
user_ids: Optional[list[str]] = None # 'dm' channels only
users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only
users: Optional[list[UserIdNameStatusResponse]] = None # 'dm' channels only
last_message_at: Optional[int] = None # timestamp in epoch (time_ns)
unread_count: int = 0
@ -97,7 +98,9 @@ async def get_channels(user=Depends(get_verified_user)):
for member in Channels.get_members_by_channel_id(channel.id)
]
users = [
UserIdNameResponse(**user.model_dump())
UserIdNameStatusResponse(
**{**user.model_dump(), "is_active": Users.is_user_active(user.id)}
)
for user in Users.get_users_by_user_ids(user_ids)
]
@ -278,7 +281,7 @@ async def get_channel_members_by_id(
return {
"users": [
UserModelResponse(
**user.model_dump(), is_active=get_active_status_by_user_id(user.id)
**user.model_dump(), is_active=Users.is_user_active(user.id)
)
for user in users
],
@ -310,7 +313,7 @@ async def get_channel_members_by_id(
return {
"users": [
UserModelResponse(
**user.model_dump(), is_active=get_active_status_by_user_id(user.id)
**user.model_dump(), is_active=Users.is_user_active(user.id)
)
for user in users
],
@ -461,6 +464,62 @@ async def get_channel_messages(
return messages
############################
# GetPinnedChannelMessages
############################
PAGE_ITEM_COUNT_PINNED = 20
@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse])
async def get_pinned_channel_messages(
id: str, page: int = 1, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type == "dm":
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
page = max(1, page)
skip = (page - 1) * PAGE_ITEM_COUNT_PINNED
limit = PAGE_ITEM_COUNT_PINNED
message_list = Messages.get_pinned_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(
MessageWithReactionsResponse(
**{
**message.model_dump(),
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################
# PostNewMessage
############################
@ -706,7 +765,7 @@ async def new_message_handler(
"message_id": message.id,
"data": {
"type": "message",
"data": message.model_dump(),
"data": {"temp_id": form_data.temp_id, **message.model_dump()},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@ -832,6 +891,69 @@ async def get_channel_message(
)
############################
# PinChannelMessage
############################
class PinMessageForm(BaseModel):
is_pinned: bool
@router.post(
"/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse]
)
async def pin_channel_message(
id: str, message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type == "dm":
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id)
message = Messages.get_message_by_id(message_id)
return MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
),
}
)
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChannelThreadMessages
############################

View file

@ -879,6 +879,7 @@ async def delete_model(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
r = None
try:
headers = {
"Content-Type": "application/json",
@ -892,7 +893,7 @@ async def delete_model(
method="DELETE",
url=f"{url}/api/delete",
headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
json=form_data,
)
r.raise_for_status()
@ -949,10 +950,7 @@ async def show_model_info(
headers = include_user_info_headers(headers, user)
r = requests.request(
method="POST",
url=f"{url}/api/show",
headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
method="POST", url=f"{url}/api/show", headers=headers, json=form_data
)
r.raise_for_status()

View file

@ -26,12 +26,6 @@ from open_webui.models.users import (
UserUpdateForm,
)
from open_webui.socket.main import (
get_active_status_by_user_id,
get_active_user_ids,
get_user_active_status,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
@ -51,23 +45,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# GetActiveUsers
############################
@router.get("/active")
async def get_active_users(
user=Depends(get_verified_user),
):
"""
Get a list of active users.
"""
return {
"user_ids": get_active_user_ids(),
}
############################
# GetUsers
############################
@ -364,7 +341,7 @@ async def update_user_info_by_session_user(
class UserActiveResponse(BaseModel):
name: str
profile_image_url: Optional[str] = None
active: Optional[bool] = None
is_active: bool
model_config = ConfigDict(extra="allow")
@ -390,7 +367,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
**{
"id": user.id,
"name": user.name,
"active": get_active_status_by_user_id(user_id),
"is_active": Users.is_user_active(user_id),
}
)
else:
@ -457,7 +434,7 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
@router.get("/{user_id}/active", response_model=dict)
async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)):
return {
"active": get_user_active_status(user_id),
"active": Users.is_user_active(user_id),
}

View file

@ -132,12 +132,6 @@ if WEBSOCKET_MANAGER == "redis":
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USER_POOL = RedisDict(
f"{REDIS_KEY_PREFIX}:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USAGE_POOL = RedisDict(
f"{REDIS_KEY_PREFIX}:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
@ -159,7 +153,6 @@ else:
MODELS = {}
SESSION_POOL = {}
USER_POOL = {}
USAGE_POOL = {}
aquire_func = release_func = renew_func = lambda: True
@ -235,16 +228,6 @@ def get_models_in_use():
return models_in_use
def get_active_user_ids():
"""Get the list of active user IDs."""
return list(USER_POOL.keys())
def get_user_active_status(user_id):
"""Check if a user is currently active."""
return user_id in USER_POOL
def get_user_id_from_session_pool(sid):
user = SESSION_POOL.get(sid)
if user:
@ -270,12 +253,6 @@ def get_user_ids_from_room(room):
return active_user_ids
def get_active_status_by_user_id(user_id):
if user_id in USER_POOL:
return True
return False
@sio.on("usage")
async def usage(sid, data):
if sid in SESSION_POOL:
@ -303,11 +280,6 @@ async def connect(sid, environ, auth):
SESSION_POOL[sid] = user.model_dump(
exclude=["date_of_birth", "bio", "gender"]
)
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
await sio.enter_room(sid, f"user:{user.id}")
@ -326,11 +298,15 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"])
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
SESSION_POOL[sid] = user.model_dump(
exclude=[
"profile_image_url",
"profile_banner_image_url",
"date_of_birth",
"bio",
"gender",
]
)
await sio.enter_room(sid, f"user:{user.id}")
# Join all the channels
@ -341,6 +317,13 @@ async def user_join(sid, data):
return {"id": user.id, "name": user.name}
@sio.on("heartbeat")
async def heartbeat(sid, data):
user = SESSION_POOL.get(sid)
if user:
Users.update_last_active_by_id(user["id"])
@sio.on("join-channels")
async def join_channel(sid, data):
auth = data["auth"] if "auth" in data else None
@ -669,13 +652,6 @@ async def disconnect(sid):
if sid in SESSION_POOL:
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
user_id = user["id"]
USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid]
if len(USER_POOL[user_id]) == 0:
del USER_POOL[user_id]
await YDOC_MANAGER.remove_user_from_all_documents(sid)
else:
pass

View file

@ -344,9 +344,7 @@ async def get_current_user(
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(
Users.update_user_last_active_by_id, user.id
)
background_tasks.add_task(Users.update_last_active_by_id, user.id)
return user
else:
raise HTTPException(
@ -397,8 +395,7 @@ def get_current_user_by_api_key(request, api_key: str):
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
Users.update_user_last_active_by_id(user.id)
Users.update_last_active_by_id(user.id)
return user

View file

@ -32,7 +32,6 @@ from open_webui.models.users import Users
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
get_active_status_by_user_id,
)
from open_webui.routers.tasks import (
generate_queries,
@ -783,19 +782,23 @@ async def chat_image_generation_handler(
if not chat_id:
return form_data
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
__event_emitter__ = extra_params["__event_emitter__"]
await __event_emitter__(
{
"type": "status",
"data": {"description": "Creating image", "done": False},
}
)
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
message_list = get_message_list(messages_map, message_id)
if chat_id.startswith("local:"):
message_list = form_data.get("messages", [])
else:
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
await __event_emitter__(
{
"type": "status",
"data": {"description": "Creating image", "done": False},
}
)
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
message_list = get_message_list(messages_map, message_id)
user_message = get_last_user_message(message_list)
prompt = user_message
@ -1925,7 +1928,7 @@ async def process_chat_response(
)
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
if not Users.is_user_active(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(
@ -3220,7 +3223,7 @@ async def process_chat_response(
)
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
if not Users.is_user_active(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(

View file

@ -1329,7 +1329,10 @@ class OAuthManager:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}"
oauth_data = {}
oauth_data[provider] = {
"sub": sub,
}
# Email extraction
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
@ -1376,12 +1379,12 @@ class OAuthManager:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
elif ENABLE_OAUTH_EMAIL_FALLBACK:
email = f"{provider_sub}.local"
email = f"{provider}@{sub}.local"
else:
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
email = email.lower()
# If allowed domains are configured, check if the email domain is in the list
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
@ -1394,7 +1397,7 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
user = Users.get_user_by_oauth_sub(provider, sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
@ -1402,7 +1405,7 @@ class OAuthManager:
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
Users.update_user_oauth_by_id(user.id, provider, sub)
if user:
determined_role = self.get_user_role(user, user_data)
@ -1461,7 +1464,7 @@ class OAuthManager:
name=name,
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth_sub=provider_sub,
oauth=oauth_data,
)
if auth_manager_config.WEBHOOK_URL:

View file

@ -45,7 +45,6 @@ from open_webui.env import (
OTEL_METRICS_OTLP_SPAN_EXPORTER,
OTEL_METRICS_EXPORTER_OTLP_INSECURE,
)
from open_webui.socket.main import get_active_user_ids
from open_webui.models.users import Users
_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds
@ -135,7 +134,7 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
) -> Sequence[metrics.Observation]:
return [
metrics.Observation(
value=len(get_active_user_ids()),
value=Users.get_active_user_count(),
)
]

View file

@ -299,6 +299,44 @@ export const getChannelMessages = async (
return res;
};
export const getChannelPinnedMessages = async (
token: string = '',
channel_id: string,
page: number = 1
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/pinned?page=${page}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannelThreadMessages = async (
token: string = '',
channel_id: string,
@ -340,6 +378,7 @@ export const getChannelThreadMessages = async (
};
type MessageForm = {
temp_id?: string;
reply_to_id?: string;
parent_id?: string;
content: string;
@ -379,6 +418,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag
return res;
};
export const pinMessage = async (
token: string = '',
channel_id: string,
message_id: string,
is_pinned: boolean
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/pin`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ is_pinned })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateMessage = async (
token: string = '',
channel_id: string,

View file

@ -811,9 +811,8 @@
bind:value={deleteModelTag}
placeholder={$i18n.t('Select a model')}
>
{#if !deleteModelTag}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each ollamaModels as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}</option

View file

@ -355,14 +355,25 @@
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<div class="flex items-center">
<div class="flex items-center gap-2">
<img
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
class="rounded-full w-6 h-6 object-cover mr-0.5 flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
/>
<div class="font-medium truncate">{user.name}</div>
{#if user?.last_active_at && Date.now() / 1000 - user.last_active_at < 180}
<div>
<span class="relative flex size-1.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span class="relative inline-flex size-1.5 rounded-full bg-green-500"></span>
</span>
</div>
{/if}
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>

View file

@ -180,12 +180,17 @@
</div>
</div>
{#if _user?.oauth_sub}
{#if _user?.oauth}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
<div class="flex-1 text-sm break-all mb-1">
{_user.oauth_sub ?? ''}
<div class="flex-1 text-sm break-all mb-1 flex flex-col space-y-1">
{#each Object.keys(_user.oauth) as key}
<div>
<span class="text-gray-500">{key}</span>
<span class="">{_user.oauth[key]?.sub}</span>
</div>
{/each}
</div>
</div>
{/if}

View file

@ -4,6 +4,7 @@
import { onDestroy, onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { v4 as uuidv4 } from 'uuid';
import {
chatId,
@ -22,6 +23,7 @@
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte';
import i18n from '$lib/i18n';
import Spinner from '../common/Spinner.svelte';
export let id = '';
@ -117,7 +119,8 @@
if (type === 'message') {
if ((data?.parent_id ?? null) === null) {
messages = [data, ...messages];
const tempId = data?.temp_id ?? null;
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => m?.temp_id !== tempId)];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
@ -182,11 +185,30 @@
return;
}
const res = await sendMessage(localStorage.token, id, {
const tempId = uuidv4();
const message = {
temp_id: tempId,
content: content,
data: data,
reply_to_id: replyToMessage?.id ?? null
}).catch((error) => {
};
const ts = Date.now() * 1000000; // nanoseconds
messages = [
{
...message,
id: tempId,
user_id: $user?.id,
user: $user,
reply_to_message: replyToMessage ?? null,
created_at: ts,
updated_at: ts
},
...messages
];
const res = await sendMessage(localStorage.token, id, message).catch((error) => {
toast.error(`${error}`);
return null;
});
@ -274,10 +296,23 @@
>
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative">
<Navbar {channel} />
<Navbar
{channel}
onPin={(messageId, pinned) => {
messages = messages.map((message) => {
if (message.id === messageId) {
return {
...message,
is_pinned: pinned
};
}
return message;
});
}}
/>
<div class="flex-1 overflow-y-auto">
{#if channel}
{#if channel && messages !== null}
<div class="flex-1 overflow-y-auto">
<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"
@ -317,27 +352,33 @@
/>
{/key}
</div>
{/if}
</div>
</div>
<div class=" pb-[1rem] px-2.5">
<MessageInput
id="root"
bind:chatInputElement
bind:replyToMessage
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this channel.')
: $i18n.t('Type here...')}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
{scrollEnd}
/>
</div>
<div class=" pb-[1rem] px-2.5">
<MessageInput
id="root"
bind:chatInputElement
bind:replyToMessage
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this channel.')
: $i18n.t('Type here...')}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
{scrollEnd}
/>
</div>
{:else}
<div class=" flex items-center justify-center h-full w-full">
<div class="m-auto">
<Spinner className="size-5" />
</div>
</div>
{/if}
</Pane>
{#if !largeScreen}

View file

@ -865,7 +865,7 @@
<div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-96 overflow-auto"
>
{#key $settings?.richTextInput}
{#key $settings?.richTextInput && $settings?.showFormattingToolbar}
<RichTextInput
id="chat-input"
bind:this={chatInputElement}

View file

@ -16,7 +16,13 @@
import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels';
import {
addReaction,
deleteMessage,
pinMessage,
removeReaction,
updateMessage
} from '$lib/apis/channels';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@ -122,11 +128,12 @@
{message}
{thread}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access}
disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message}
message?.reply_to_message !== null}
onDelete={() => {
messages = messages.filter((m) => m.id !== message.id);
@ -155,6 +162,26 @@
onReply={(message) => {
onReply(message);
}}
onPin={async (message) => {
messages = messages.map((m) => {
if (m.id === message.id) {
m.is_pinned = !m.is_pinned;
m.pinned_by = !m.is_pinned ? null : $user?.id;
m.pinned_at = !m.is_pinned ? null : Date.now() * 1000000;
}
return m;
});
const updatedMessage = await pinMessage(
localStorage.token,
message.channel_id,
message.id,
message.is_pinned
).catch((error) => {
toast.error(`${error}`);
return null;
});
}}
onThread={(id) => {
onThread(id);
}}

View file

@ -36,6 +36,10 @@
import Emoji from '$lib/components/common/Emoji.svelte';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
export let className = '';
export let message;
export let showUserProfile = true;
@ -43,10 +47,12 @@
export let replyToMessage = false;
export let disabled = false;
export let pending = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
export let onReply: Function = () => {};
export let onPin: Function = () => {};
export let onThread: Function = () => {};
export let onReaction: Function = () => {};
@ -69,13 +75,17 @@
{#if message}
<div
id="message-{message.id}"
class="flex flex-col justify-between px-5 {showUserProfile
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {className
? className
: `px-5 ${
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''}"
>
{#if !edit && !disabled}
@ -85,37 +95,56 @@
<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-850"
>
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</EmojiPicker>
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
{#if onReaction}
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<ArrowUpLeftAlt className="size-5" />
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</EmojiPicker>
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{/if}
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onPin(message);
}}
>
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{/if}
</button>
</Tooltip>
{#if !thread}
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -129,31 +158,44 @@
{/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'}
<Tooltip content={$i18n.t('Edit')}>
<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>
</Tooltip>
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
<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>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
{/if}
</div>
</div>
{/if}
{#if message?.is_pinned}
<div class="flex {showUserProfile ? 'mb-0.5' : 'mt-0.5'}">
<div class="ml-8.5 flex items-center gap-1 px-1 rounded-full text-xs">
<Pin className="size-3 text-yellow-500 dark:text-yellow-300" />
<span class="text-gray-500">{$i18n.t('Pinned')}</span>
</div>
</div>
{/if}
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
@ -203,12 +245,13 @@
</button>
</div>
{/if}
<div
class=" flex w-full message-{message.id}"
class=" flex w-full message-{message.id} "
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 mr-3 w-9`}>
<div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
@ -239,7 +282,7 @@
{/if}
</div>
<div class="flex-auto w-0 pl-1">
<div class="flex-auto w-0 pl-2">
{#if showUserProfile}
<Name>
<div class=" self-end text-base shrink-0 font-medium truncate">
@ -338,7 +381,7 @@
</div>
</div>
{:else}
<div class=" min-w-full markdown-prose">
<div class=" min-w-full markdown-prose {pending ? 'opacity-50' : ''}">
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
@ -363,7 +406,9 @@
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
onReaction(reaction.name);
if (onReaction) {
onReaction(name);
}
}}
>
<Emoji shortCode={reaction.name} />
@ -377,19 +422,21 @@
</Tooltip>
{/each}
<EmojiPicker
onSubmit={(name) => {
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</EmojiPicker>
{#if onReaction}
<EmojiPicker
onSubmit={(name) => {
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</EmojiPicker>
{/if}
</div>
</div>
{/if}

View file

@ -11,11 +11,21 @@
export let align = 'center';
export let side = 'right';
export let sideOffset = 8;
let openPreview = false;
</script>
<LinkPreview.Root openDelay={0} closeDelay={0}>
<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
<slot />
<LinkPreview.Root openDelay={0} closeDelay={100} bind:open={openPreview}>
<LinkPreview.Trigger class="flex items-center">
<button
type="button"
class=" cursor-pointer no-underline! font-normal!"
on:click={() => {
openPreview = !openPreview;
}}
>
<slot />
</button>
</LinkPreview.Trigger>
<UserStatusLinkPreview id={user?.id} {side} {align} {sideOffset} />

View file

@ -23,7 +23,7 @@
</div>
<div class=" flex items-center gap-2">
{#if user?.active}
{#if user?.is_active}
<div>
<span class="relative flex size-2">
<span

View file

@ -18,16 +18,22 @@
import UserAlt from '../icons/UserAlt.svelte';
import ChannelInfoModal from './ChannelInfoModal.svelte';
import Users from '../icons/Users.svelte';
import Pin from '../icons/Pin.svelte';
import PinnedMessagesModal from './PinnedMessagesModal.svelte';
const i18n = getContext('i18n');
let showChannelPinnedMessagesModal = false;
let showChannelInfoModal = false;
export let channel;
export let onPin = (messageId, pinned) => {};
</script>
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} />
<nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region">
<nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region flex flex-col">
<div
id="navbar-bg-gradient-to-b"
class=" bg-linear-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]"
@ -111,25 +117,42 @@
</div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Count"
class=" flex cursor-pointer py-1.5 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="Pinned Messages"
type="button"
on:click={() => {
showChannelInfoModal = true;
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-sm">
{channel.user_count}
</div>
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
<button
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Count"
type="button"
on:click={() => {
showChannelInfoModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-sm">
{channel.user_count}
</div>
</div>
</button>
</Tooltip>
{/if}
{/if}
{#if $user !== undefined}

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getChannelPinnedMessages, pinMessage } from '$lib/apis/channels';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte';
export let show = false;
export let channel = null;
export let onPin = (messageId, pinned) => {};
let page = 1;
let pinnedMessages = null;
let allItemsLoaded = false;
let loading = false;
const getPinnedMessages = async () => {
if (!channel) return;
if (allItemsLoaded) return;
loading = true;
try {
const res = await getChannelPinnedMessages(localStorage.token, channel.id, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
pinnedMessages = [...(pinnedMessages ?? []), ...res];
}
if (res.length === 0) {
allItemsLoaded = true;
}
} catch (error) {
console.error('Error fetching pinned messages:', error);
} finally {
loading = false;
}
};
const init = () => {
page = 1;
pinnedMessages = null;
allItemsLoaded = false;
getPinnedMessages();
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
{#if channel}
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0">
{$i18n.t('Pinned Messages')}
</div>
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 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">
<div class="flex flex-col w-full h-full pb-2 gap-1">
{#if pinnedMessages === null}
<div class="my-10">
<Spinner className="size-5" />
</div>
{:else}
<div
class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent py-2"
>
{#if pinnedMessages.length === 0}
<div class=" text-center text-xs text-gray-500 dark:text-gray-400 py-6">
{$i18n.t('No pinned messages')}
</div>
{:else}
{#each pinnedMessages as message, messageIdx (message.id)}
<Message
className="rounded-xl px-2"
{message}
{channel}
onPin={async (message) => {
pinnedMessages = pinnedMessages.filter((m) => m.id !== message.id);
onPin(message.id, !message.is_pinned);
const updatedMessage = await pinMessage(
localStorage.token,
message.channel_id,
message.id,
!message.is_pinned
).catch((error) => {
toast.error(`${error}`);
return null;
});
init();
}}
onReaction={false}
onThread={false}
onReply={false}
onEdit={false}
onDelete={false}
/>
{#if messageIdx === pinnedMessages.length - 1 && !allItemsLoaded}
<Loader
on:visible={(e) => {
console.log('visible');
if (!loading) {
page += 1;
getPinnedMessages();
}
}}
>
<div
class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
</Modal>
{/if}

View file

@ -569,7 +569,7 @@
});
</script>
<Modal size="xl" bind:show>
<Modal size="2xl" bind:show>
<div class="text-gray-700 dark:text-gray-100 mx-1">
<div class=" flex justify-between dark:text-gray-300 px-4 md:px-4.5 pt-4.5 pb-0.5 md:pb-2.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>
@ -588,7 +588,7 @@
<div
role="tablist"
id="settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[36rem] md:max-h-[36rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[42rem] md:max-h-[42rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
>
<div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 mb-1.5"
@ -858,7 +858,7 @@
</a>
{/if}
</div>
<div class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[36rem] max-h-[36rem]">
<div class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[42rem] max-h-[42rem]">
{#if selectedTab === 'general'}
<General
{getModels}

View file

@ -0,0 +1,21 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="24"
height="24"
><path d="M9.5 14.5L3 21" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M5.00007 9.48528L14.1925 18.6777L15.8895 16.9806L15.4974 13.1944L21.0065 8.5211L15.1568 2.67141L10.4834 8.18034L6.69713 7.78823L5.00007 9.48528Z"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>

View file

@ -4,6 +4,7 @@
</script>
<svg
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
@ -12,7 +13,6 @@
fill="none"
width="24"
height="24"
viewBox="0 0 22 22"
><path d="M9.5 14.5L3 21" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M7.67602 7.8896L6.69713 7.78823L5.00007 9.48528L14.1925 18.6777L15.8895 16.9806L15.7879 16M11.4847 7L15.1568 2.67141L21.0065 8.5211L16.6991 12.175"
stroke-linecap="round"

View file

@ -707,6 +707,7 @@
{#if $user !== undefined && $user !== null}
<UserMenu
role={$user?.role}
showActiveUsers={false}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
@ -1256,6 +1257,7 @@
{#if $user !== undefined && $user !== null}
<UserMenu
role={$user?.role}
showActiveUsers={false}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);

View file

@ -5,6 +5,7 @@
import { page } from '$app/stores';
import { channels, mobile, showSidebar, user } from '$lib/stores';
import { getUserActiveStatusById } from '$lib/apis/users';
import { updateChannelById, updateChannelMemberActiveStatusById } from '$lib/apis/channels';
import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -83,8 +84,9 @@
<div>
{#if channel?.type === 'dm'}
{#if channel?.users}
<div class="flex ml-[1px] mr-0.5">
{#each channel.users.filter((u) => u.id !== $user?.id).slice(0, 2) as u, index}
{@const channelMembers = channel.users.filter((u) => u.id !== $user?.id)}
<div class="flex ml-[1px] mr-0.5 relative">
{#each channelMembers.slice(0, 2) as u, index}
<img
src={`${WEBUI_API_BASE_URL}/users/${u.id}/profile/image`}
alt={u.name}
@ -94,6 +96,23 @@
: ''}"
/>
{/each}
{#if channelMembers.length === 1}
<div class="absolute bottom-0 right-0">
<span class="relative flex size-2">
{#if channelMembers[0]?.is_active}
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
{/if}
<span
class="relative inline-flex size-2 rounded-full {channelMembers[0]?.is_active
? 'bg-green-500'
: 'bg-gray-300 dark:bg-gray-700'} border-[1.5px] border-white dark:border-gray-900"
></span>
</span>
</div>
{/if}
</div>
{:else}
<Users className="size-4 ml-1 mr-0.5" strokeWidth="2" />

View file

@ -29,6 +29,8 @@
export let help = false;
export let className = 'max-w-[240px]';
export let showActiveUsers = true;
const dispatch = createEventDispatcher();
let usage = null;
@ -219,8 +221,8 @@
<div class=" self-center truncate">{$i18n.t('Sign Out')}</div>
</DropdownMenu.Item>
{#if usage}
{#if usage?.user_ids?.length > 0}
{#if showActiveUsers && usage}
{#if usage?.user_count}
<hr class=" border-gray-50 dark:border-gray-800 my-1 p-0" />
<Tooltip
@ -248,7 +250,7 @@
{$i18n.t('Active Users')}:
</span>
<span class=" font-semibold">
{usage?.user_ids?.length}
{usage?.user_count}
</span>
</div>
</div>

View file

@ -90,6 +90,8 @@
let showRefresh = false;
let heartbeatInterval = null;
const BREAKPOINT = 768;
const setupSocket = async (enableWebsocket) => {
@ -126,6 +128,14 @@
}
}
// Send heartbeat every 30 seconds
heartbeatInterval = setInterval(() => {
if (_socket.connected) {
console.log('Sending heartbeat');
_socket.emit('heartbeat', {});
}
}, 30000);
if (deploymentId !== null) {
WEBUI_DEPLOYMENT_ID.set(deploymentId);
}
@ -154,6 +164,12 @@
_socket.on('disconnect', (reason, details) => {
console.log(`Socket ${_socket.id} disconnected due to ${reason}`);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (details) {
console.log('Additional details:', details);
}