Merge branch 'dev' into feat/google-oauth-groups-dev

This commit is contained in:
Luke Garceau 2025-11-28 14:51:27 -05:00 committed by GitHub
commit 4b46f7d802
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1298 additions and 352 deletions

View file

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

View file

@ -20,18 +20,46 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
columns = inspector.get_columns("user")
pk_columns = inspector.get_pk_constraint("user")["constrained_columns"]
id_column = next((col for col in columns if col["name"] == "id"), None)
if id_column and not id_column.get("unique", False):
unique_constraints = inspector.get_unique_constraints("user")
unique_columns = {tuple(u["column_names"]) for u in unique_constraints}
with op.batch_alter_table("user") as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ["id"]:
batch_op.drop_constraint(
inspector.get_pk_constraint("user")["name"], type_="primary"
)
# Add unique constraint if missing
if ("id",) not in unique_columns:
batch_op.create_unique_constraint("uq_user_id", ["id"])
# Re-create correct primary key
batch_op.create_primary_key("pk_user_id", ["id"])
# Create oauth_session table # Create oauth_session table
op.create_table( op.create_table(
"oauth_session", "oauth_session",
sa.Column("id", sa.Text(), nullable=False), sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column("user_id", sa.Text(), nullable=False), sa.Column(
"user_id",
sa.Text(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider", sa.Text(), nullable=False), sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False), sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
) )
# Create indexes for better performance # Create indexes for better performance

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

View file

@ -40,7 +40,7 @@ class MessageReactionModel(BaseModel):
class Message(Base): class Message(Base):
__tablename__ = "message" __tablename__ = "message"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text) user_id = Column(Text)
channel_id = Column(Text, nullable=True) channel_id = Column(Text, nullable=True)
@ -90,6 +90,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel): class MessageForm(BaseModel):
temp_id: Optional[str] = None
content: str content: str
reply_to_id: Optional[str] = None reply_to_id: Optional[str] = None
parent_id: Optional[str] = None parent_id: Optional[str] = None
@ -111,6 +112,10 @@ class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None reply_to_message: Optional[MessageUserResponse] = None
class MessageWithReactionsResponse(MessageUserResponse):
reactions: list[Reactions]
class MessageResponse(MessageReplyToResponse): class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int] latest_reply_at: Optional[int]
reply_count: int reply_count: int
@ -306,6 +311,20 @@ class MessageTable:
) )
return MessageModel.model_validate(message) if message else None 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( def update_message_by_id(
self, id: str, form_data: MessageForm self, id: str, form_data: MessageForm
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
@ -325,7 +344,7 @@ class MessageTable:
db.refresh(message) db.refresh(message)
return MessageModel.model_validate(message) if message else None 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 self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
with get_db() as db: with get_db() as db:
@ -333,7 +352,6 @@ class MessageTable:
message.is_pinned = is_pinned message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_at = int(time.time_ns()) if is_pinned else None
message.pinned_by = pinned_by 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.commit()
db.refresh(message) db.refresh(message)
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None

View file

@ -53,7 +53,7 @@ class ModelMeta(BaseModel):
class Model(Base): class Model(Base):
__tablename__ = "model" __tablename__ = "model"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True, unique=True)
""" """
The model's id as used in the API. If set to an existing model, it will override the model. The model's id as used in the API. If set to an existing model, it will override the model.
""" """

View file

@ -23,7 +23,7 @@ from sqlalchemy.sql import exists
class Note(Base): class Note(Base):
__tablename__ = "note" __tablename__ = "note"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text) user_id = Column(Text)
title = Column(Text) title = Column(Text)

View file

@ -25,7 +25,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class OAuthSession(Base): class OAuthSession(Base):
__tablename__ = "oauth_session" __tablename__ = "oauth_session"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text, nullable=False) user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False) provider = Column(Text, nullable=False)
token = Column( token = Column(

View file

@ -24,7 +24,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Tool(Base): class Tool(Base):
__tablename__ = "tool" __tablename__ = "tool"
id = Column(String, primary_key=True) id = Column(String, primary_key=True, unique=True)
user_id = Column(String) user_id = Column(String)
name = Column(Text) name = Column(Text)
content = Column(Text) content = Column(Text)

View file

@ -11,7 +11,17 @@ from open_webui.utils.misc import throttle
from pydantic import BaseModel, ConfigDict 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 from sqlalchemy import or_, case
import datetime import datetime
@ -21,59 +31,71 @@ import datetime
#################### ####################
class User(Base):
__tablename__ = "user"
id = Column(String, primary_key=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): class UserSettings(BaseModel):
ui: Optional[dict] = {} ui: Optional[dict] = {}
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
pass 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): class UserModel(BaseModel):
id: str id: str
name: str
email: str email: str
username: Optional[str] = None username: Optional[str] = None
role: str = "pending" role: str = "pending"
name: str
profile_image_url: str profile_image_url: str
profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None bio: Optional[str] = None
gender: Optional[str] = None gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = 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 info: Optional[dict] = None
settings: Optional[UserSettings] = None settings: Optional[UserSettings] = None
api_key: Optional[str] = None oauth: Optional[dict] = None
oauth_sub: Optional[str] = None
last_active_at: int # timestamp in epoch last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch updated_at: int # timestamp in epoch
@ -82,6 +104,32 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True) 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 # Forms
#################### ####################
@ -125,6 +173,12 @@ class UserIdNameResponse(BaseModel):
name: str name: str
class UserIdNameStatusResponse(BaseModel):
id: str
name: str
is_active: bool = False
class UserInfoListResponse(BaseModel): class UserInfoListResponse(BaseModel):
users: list[UserInfoResponse] users: list[UserInfoResponse]
total: int total: int
@ -171,20 +225,20 @@ class UsersTable:
email: str, email: str,
profile_image_url: str = "/user.png", profile_image_url: str = "/user.png",
role: str = "pending", role: str = "pending",
oauth_sub: Optional[str] = None, oauth: Optional[dict] = None,
) -> Optional[UserModel]: ) -> Optional[UserModel]:
with get_db() as db: with get_db() as db:
user = UserModel( user = UserModel(
**{ **{
"id": id, "id": id,
"name": name,
"email": email, "email": email,
"name": name,
"role": role, "role": role,
"profile_image_url": profile_image_url, "profile_image_url": profile_image_url,
"last_active_at": int(time.time()), "last_active_at": int(time.time()),
"created_at": int(time.time()), "created_at": int(time.time()),
"updated_at": int(time.time()), "updated_at": int(time.time()),
"oauth_sub": oauth_sub, "oauth": oauth,
} }
) )
result = User(**user.model_dump()) result = User(**user.model_dump())
@ -207,8 +261,13 @@ class UsersTable:
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db() as db:
user = db.query(User).filter_by(api_key=api_key).first() user = (
return UserModel.model_validate(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: except Exception:
return None return None
@ -220,11 +279,15 @@ class UsersTable:
except Exception: except Exception:
return None 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: try:
with get_db() as db: with get_db() as db:
user = db.query(User).filter_by(oauth_sub=sub).first() user = (
return UserModel.model_validate(user) db.query(User)
.filter(User.oauth.contains({provider: {"sub": sub}}))
.first()
)
return UserModel.model_validate(user) if user else None
except Exception: except Exception:
return None return None
@ -426,7 +489,7 @@ class UsersTable:
return None return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) @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: try:
with get_db() as db: with get_db() as db:
db.query(User).filter_by(id=id).update( db.query(User).filter_by(id=id).update(
@ -439,16 +502,35 @@ class UsersTable:
except Exception: except Exception:
return None return None
def update_user_oauth_sub_by_id( def update_user_oauth_by_id(
self, id: str, oauth_sub: str self, id: str, provider: str, sub: str
) -> Optional[UserModel]: ) -> 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: try:
with get_db() as db: 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() db.commit()
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user) return UserModel.model_validate(user)
except Exception: except Exception:
return None return None
@ -502,23 +584,45 @@ class UsersTable:
except Exception: except Exception:
return False 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]: def get_user_api_key_by_id(self, id: str) -> Optional[str]:
try: try:
with get_db() as db: with get_db() as db:
user = db.query(User).filter_by(id=id).first() api_key = db.query(ApiKey).filter_by(user_id=id).first()
return user.api_key return api_key.key if api_key else None
except Exception: except Exception:
return None 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]: def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db: with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all() users = db.query(User).filter(User.id.in_(user_ids)).all()
@ -532,5 +636,23 @@ class UsersTable:
else: else:
return None 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() Users = UsersTable()

View file

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

View file

@ -10,10 +10,10 @@ from pydantic import BaseModel
from open_webui.socket.main import ( from open_webui.socket.main import (
sio, sio,
get_user_ids_from_room, get_user_ids_from_room,
get_active_status_by_user_id,
) )
from open_webui.models.users import ( from open_webui.models.users import (
UserIdNameResponse, UserIdNameResponse,
UserIdNameStatusResponse,
UserListResponse, UserListResponse,
UserModelResponse, UserModelResponse,
Users, Users,
@ -31,6 +31,7 @@ from open_webui.models.messages import (
Messages, Messages,
MessageModel, MessageModel,
MessageResponse, MessageResponse,
MessageWithReactionsResponse,
MessageForm, MessageForm,
) )
@ -68,7 +69,7 @@ router = APIRouter()
class ChannelListItemResponse(ChannelModel): class ChannelListItemResponse(ChannelModel):
user_ids: Optional[list[str]] = None # 'dm' channels only 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) last_message_at: Optional[int] = None # timestamp in epoch (time_ns)
unread_count: int = 0 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) for member in Channels.get_members_by_channel_id(channel.id)
] ]
users = [ 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) for user in Users.get_users_by_user_ids(user_ids)
] ]
@ -278,7 +281,7 @@ async def get_channel_members_by_id(
return { return {
"users": [ "users": [
UserModelResponse( 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 for user in users
], ],
@ -310,7 +313,7 @@ async def get_channel_members_by_id(
return { return {
"users": [ "users": [
UserModelResponse( 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 for user in users
], ],
@ -461,6 +464,62 @@ async def get_channel_messages(
return 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 # PostNewMessage
############################ ############################
@ -706,7 +765,7 @@ async def new_message_handler(
"message_id": message.id, "message_id": message.id,
"data": { "data": {
"type": "message", "type": "message",
"data": message.model_dump(), "data": {"temp_id": form_data.temp_id, **message.model_dump()},
}, },
"user": UserNameResponse(**user.model_dump()).model_dump(), "user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.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 # GetChannelThreadMessages
############################ ############################

View file

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

View file

@ -26,12 +26,6 @@ from open_webui.models.users import (
UserUpdateForm, 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.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
@ -51,23 +45,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() 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 # GetUsers
############################ ############################
@ -364,7 +341,7 @@ async def update_user_info_by_session_user(
class UserActiveResponse(BaseModel): class UserActiveResponse(BaseModel):
name: str name: str
profile_image_url: Optional[str] = None profile_image_url: Optional[str] = None
active: Optional[bool] = None is_active: bool
model_config = ConfigDict(extra="allow") 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, "id": user.id,
"name": user.name, "name": user.name,
"active": get_active_status_by_user_id(user_id), "is_active": Users.is_user_active(user_id),
} }
) )
else: 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) @router.get("/{user_id}/active", response_model=dict)
async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)):
return { 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_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER, 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( USAGE_POOL = RedisDict(
f"{REDIS_KEY_PREFIX}:usage_pool", f"{REDIS_KEY_PREFIX}:usage_pool",
redis_url=WEBSOCKET_REDIS_URL, redis_url=WEBSOCKET_REDIS_URL,
@ -159,7 +153,6 @@ else:
MODELS = {} MODELS = {}
SESSION_POOL = {} SESSION_POOL = {}
USER_POOL = {}
USAGE_POOL = {} USAGE_POOL = {}
aquire_func = release_func = renew_func = lambda: True aquire_func = release_func = renew_func = lambda: True
@ -235,16 +228,6 @@ def get_models_in_use():
return 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): def get_user_id_from_session_pool(sid):
user = SESSION_POOL.get(sid) user = SESSION_POOL.get(sid)
if user: if user:
@ -270,12 +253,6 @@ def get_user_ids_from_room(room):
return active_user_ids 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") @sio.on("usage")
async def usage(sid, data): async def usage(sid, data):
if sid in SESSION_POOL: if sid in SESSION_POOL:
@ -303,11 +280,6 @@ async def connect(sid, environ, auth):
SESSION_POOL[sid] = user.model_dump( SESSION_POOL[sid] = user.model_dump(
exclude=["date_of_birth", "bio", "gender"] 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}") await sio.enter_room(sid, f"user:{user.id}")
@ -326,11 +298,15 @@ async def user_join(sid, data):
if not user: if not user:
return return
SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"]) SESSION_POOL[sid] = user.model_dump(
if user.id in USER_POOL: exclude=[
USER_POOL[user.id] = USER_POOL[user.id] + [sid] "profile_image_url",
else: "profile_banner_image_url",
USER_POOL[user.id] = [sid] "date_of_birth",
"bio",
"gender",
]
)
await sio.enter_room(sid, f"user:{user.id}") await sio.enter_room(sid, f"user:{user.id}")
# Join all the channels # Join all the channels
@ -341,6 +317,13 @@ async def user_join(sid, data):
return {"id": user.id, "name": user.name} 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") @sio.on("join-channels")
async def join_channel(sid, data): async def join_channel(sid, data):
auth = data["auth"] if "auth" in data else None auth = data["auth"] if "auth" in data else None
@ -669,13 +652,6 @@ async def disconnect(sid):
if sid in SESSION_POOL: if sid in SESSION_POOL:
user = SESSION_POOL[sid] user = SESSION_POOL[sid]
del 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) await YDOC_MANAGER.remove_user_from_all_documents(sid)
else: else:
pass pass

View file

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

View file

@ -32,7 +32,6 @@ from open_webui.models.users import Users
from open_webui.socket.main import ( from open_webui.socket.main import (
get_event_call, get_event_call,
get_event_emitter, get_event_emitter,
get_active_status_by_user_id,
) )
from open_webui.routers.tasks import ( from open_webui.routers.tasks import (
generate_queries, generate_queries,
@ -773,9 +772,12 @@ async def chat_image_generation_handler(
if not chat_id: if not chat_id:
return form_data return form_data
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
__event_emitter__ = extra_params["__event_emitter__"] __event_emitter__ = extra_params["__event_emitter__"]
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__( await __event_emitter__(
{ {
"type": "status", "type": "status",
@ -786,6 +788,7 @@ async def chat_image_generation_handler(
messages_map = chat.chat.get("history", {}).get("messages", {}) messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId") message_id = chat.chat.get("history", {}).get("currentId")
message_list = get_message_list(messages_map, message_id) message_list = get_message_list(messages_map, message_id)
user_message = get_last_user_message(message_list) user_message = get_last_user_message(message_list)
prompt = user_message prompt = user_message
@ -1915,7 +1918,7 @@ async def process_chat_response(
) )
# Send a webhook notification if the user is not active # 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) webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url: if webhook_url:
await post_webhook( await post_webhook(
@ -3210,7 +3213,7 @@ async def process_chat_response(
) )
# Send a webhook notification if the user is not active # 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) webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url: if webhook_url:
await post_webhook( await post_webhook(

View file

@ -54,7 +54,6 @@ def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> b
return True return True
allow_list, block_list = get_allow_block_lists(filter_list) allow_list, block_list = get_allow_block_lists(filter_list)
print(string, allow_list, block_list)
# If allow list is non-empty, require domain to match one of them # If allow list is non-empty, require domain to match one of them
if allow_list: if allow_list:

View file

@ -1496,7 +1496,10 @@ class OAuthManager:
log.warning(f"OAuth callback failed, sub is missing: {user_data}") log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}" oauth_data = {}
oauth_data[provider] = {
"sub": sub,
}
# Email extraction # Email extraction
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
@ -1543,13 +1546,12 @@ class OAuthManager:
log.warning(f"Error fetching GitHub email: {e}") log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
elif ENABLE_OAUTH_EMAIL_FALLBACK: elif ENABLE_OAUTH_EMAIL_FALLBACK:
email = f"{provider_sub}.local" email = f"{provider}@{sub}.local"
else: else:
log.warning(f"OAuth callback failed, email is missing: {user_data}") log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) 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 allowed domains are configured, check if the email domain is in the list
if ( if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
@ -1570,7 +1572,7 @@ class OAuthManager:
user = Users.get_user_by_email(email) user = Users.get_user_by_email(email)
if user: if user:
# Update the user with the new oauth sub # 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: if user:
determined_role = await self.get_user_role( determined_role = await self.get_user_role(

View file

@ -45,7 +45,6 @@ from open_webui.env import (
OTEL_METRICS_OTLP_SPAN_EXPORTER, OTEL_METRICS_OTLP_SPAN_EXPORTER,
OTEL_METRICS_EXPORTER_OTLP_INSECURE, OTEL_METRICS_EXPORTER_OTLP_INSECURE,
) )
from open_webui.socket.main import get_active_user_ids
from open_webui.models.users import Users from open_webui.models.users import Users
_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds _EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds
@ -135,7 +134,7 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
) -> Sequence[metrics.Observation]: ) -> Sequence[metrics.Observation]:
return [ return [
metrics.Observation( 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; 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 ( export const getChannelThreadMessages = async (
token: string = '', token: string = '',
channel_id: string, channel_id: string,
@ -340,6 +378,7 @@ export const getChannelThreadMessages = async (
}; };
type MessageForm = { type MessageForm = {
temp_id?: string;
reply_to_id?: string; reply_to_id?: string;
parent_id?: string; parent_id?: string;
content: string; content: string;
@ -379,6 +418,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag
return res; 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 ( export const updateMessage = async (
token: string = '', token: string = '',
channel_id: string, channel_id: string,

View file

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

View file

@ -355,14 +355,25 @@
</button> </button>
</td> </td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48"> <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 <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`} src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user" alt="user"
/> />
<div class="font-medium truncate">{user.name}</div> <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> </div>
</td> </td>
<td class=" px-3 py-1"> {user.email} </td> <td class=" px-3 py-1"> {user.email} </td>

View file

@ -180,12 +180,17 @@
</div> </div>
</div> </div>
{#if _user?.oauth_sub} {#if _user?.oauth}
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
<div class="flex-1 text-sm break-all mb-1"> <div class="flex-1 text-sm break-all mb-1 flex flex-col space-y-1">
{_user.oauth_sub ?? ''} {#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>
</div> </div>
{/if} {/if}

View file

@ -4,8 +4,16 @@
import { onDestroy, onMount, tick } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { v4 as uuidv4 } from 'uuid';
import { chatId, channelId as _channelId, showSidebar, socket, user } from '$lib/stores'; import {
chatId,
channels,
channelId as _channelId,
showSidebar,
socket,
user
} from '$lib/stores';
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
import Messages from './Messages.svelte'; import Messages from './Messages.svelte';
@ -15,6 +23,7 @@
import EllipsisVertical from '../icons/EllipsisVertical.svelte'; import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte'; import Thread from './Thread.svelte';
import i18n from '$lib/i18n'; import i18n from '$lib/i18n';
import Spinner from '../common/Spinner.svelte';
export let id = ''; export let id = '';
@ -53,6 +62,18 @@
type: 'last_read_at' type: 'last_read_at'
} }
}); });
channels.set(
$channels.map((channel) => {
if (channel.id === channelId) {
return {
...channel,
unread_count: 0
};
}
return channel;
})
);
}; };
const initHandler = async () => { const initHandler = async () => {
@ -98,7 +119,8 @@
if (type === 'message') { if (type === 'message') {
if ((data?.parent_id ?? null) === null) { 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)) { if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id); typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
@ -163,11 +185,30 @@
return; return;
} }
const res = await sendMessage(localStorage.token, id, { const tempId = uuidv4();
const message = {
temp_id: tempId,
content: content, content: content,
data: data, data: data,
reply_to_id: replyToMessage?.id ?? null 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}`); toast.error(`${error}`);
return null; return null;
}); });
@ -255,10 +296,23 @@
> >
<PaneGroup direction="horizontal" class="w-full h-full"> <PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative"> <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;
});
}}
/>
{#if channel && messages !== null}
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#if channel}
<div <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" 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" id="messages-container"
@ -298,7 +352,6 @@
/> />
{/key} {/key}
</div> </div>
{/if}
</div> </div>
<div class=" pb-[1rem] px-2.5"> <div class=" pb-[1rem] px-2.5">
@ -319,6 +372,13 @@
{scrollEnd} {scrollEnd}
/> />
</div> </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> </Pane>
{#if !largeScreen} {#if !largeScreen}

View file

@ -865,7 +865,7 @@
<div <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" 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 <RichTextInput
id="chat-input" id="chat-input"
bind:this={chatInputElement} bind:this={chatInputElement}

View file

@ -16,7 +16,13 @@
import Message from './Messages/Message.svelte'; import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte'; import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.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'; import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -122,11 +128,12 @@
{message} {message}
{thread} {thread}
replyToMessage={replyToMessage?.id === message.id} replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access} disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
showUserProfile={messageIdx === 0 || showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id || messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id || messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message} message?.reply_to_message !== null}
onDelete={() => { onDelete={() => {
messages = messages.filter((m) => m.id !== message.id); messages = messages.filter((m) => m.id !== message.id);
@ -155,6 +162,26 @@
onReply={(message) => { onReply={(message) => {
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) => {
onThread(id); onThread(id);
}} }}

View file

@ -36,6 +36,10 @@
import Emoji from '$lib/components/common/Emoji.svelte'; import Emoji from '$lib/components/common/Emoji.svelte';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.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 message;
export let showUserProfile = true; export let showUserProfile = true;
@ -43,10 +47,12 @@
export let replyToMessage = false; export let replyToMessage = false;
export let disabled = false; export let disabled = false;
export let pending = false;
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
export let onEdit: Function = () => {}; export let onEdit: Function = () => {};
export let onReply: Function = () => {}; export let onReply: Function = () => {};
export let onPin: Function = () => {};
export let onThread: Function = () => {}; export let onThread: Function = () => {};
export let onReaction: Function = () => {}; export let onReaction: Function = () => {};
@ -69,13 +75,17 @@
{#if message} {#if message}
<div <div
id="message-{message.id}" 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
? 'pt-1.5 pb-0.5' ? className
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage : `px-5 ${
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' 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) === } ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id $user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4' ? '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'
: ''}" : ''}"
> >
{#if !edit && !disabled} {#if !edit && !disabled}
@ -85,6 +95,7 @@
<div <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" 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"
> >
{#if onReaction}
<EmojiPicker <EmojiPicker
onClose={() => (showButtons = false)} onClose={() => (showButtons = false)}
onSubmit={(name) => { onSubmit={(name) => {
@ -103,7 +114,9 @@
</button> </button>
</Tooltip> </Tooltip>
</EmojiPicker> </EmojiPicker>
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}> <Tooltip content={$i18n.t('Reply')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
@ -114,8 +127,24 @@
<ArrowUpLeftAlt className="size-5" /> <ArrowUpLeftAlt className="size-5" />
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if !thread} <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 && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}> <Tooltip content={$i18n.t('Reply in Thread')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -129,6 +158,7 @@
{/if} {/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'} {#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}> <Tooltip content={$i18n.t('Edit')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -140,7 +170,9 @@
<Pencil /> <Pencil />
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}> <Tooltip content={$i18n.t('Delete')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -150,6 +182,16 @@
</button> </button>
</Tooltip> </Tooltip>
{/if} {/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>
</div> </div>
{/if} {/if}
@ -203,12 +245,13 @@
</button> </button>
</div> </div>
{/if} {/if}
<div <div
class=" flex w-full message-{message.id}" class=" flex w-full message-{message.id} "
id="message-{message.id}" id="message-{message.id}"
dir={$settings.chatDirection} dir={$settings.chatDirection}
> >
<div class={`shrink-0 mr-3 w-9`}> <div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile} {#if showUserProfile}
{#if message?.meta?.model_id} {#if message?.meta?.model_id}
<img <img
@ -239,7 +282,7 @@
{/if} {/if}
</div> </div>
<div class="flex-auto w-0 pl-1"> <div class="flex-auto w-0 pl-2">
{#if showUserProfile} {#if showUserProfile}
<Name> <Name>
<div class=" self-end text-base shrink-0 font-medium truncate"> <div class=" self-end text-base shrink-0 font-medium truncate">
@ -338,7 +381,7 @@
</div> </div>
</div> </div>
{:else} {: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} {#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton /> <Skeleton />
{:else} {:else}
@ -363,7 +406,9 @@
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1' ? ' 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'}" : '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={() => { on:click={() => {
onReaction(reaction.name); if (onReaction) {
onReaction(name);
}
}} }}
> >
<Emoji shortCode={reaction.name} /> <Emoji shortCode={reaction.name} />
@ -377,6 +422,7 @@
</Tooltip> </Tooltip>
{/each} {/each}
{#if onReaction}
<EmojiPicker <EmojiPicker
onSubmit={(name) => { onSubmit={(name) => {
onReaction(name); onReaction(name);
@ -390,6 +436,7 @@
</div> </div>
</Tooltip> </Tooltip>
</EmojiPicker> </EmojiPicker>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

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

View file

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

View file

@ -18,16 +18,22 @@
import UserAlt from '../icons/UserAlt.svelte'; import UserAlt from '../icons/UserAlt.svelte';
import ChannelInfoModal from './ChannelInfoModal.svelte'; import ChannelInfoModal from './ChannelInfoModal.svelte';
import Users from '../icons/Users.svelte'; import Users from '../icons/Users.svelte';
import Pin from '../icons/Pin.svelte';
import PinnedMessagesModal from './PinnedMessagesModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let showChannelPinnedMessagesModal = false;
let showChannelInfoModal = false; let showChannelInfoModal = false;
export let channel; export let channel;
export let onPin = (messageId, pinned) => {};
</script> </script>
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} /> <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 <div
id="navbar-bg-gradient-to-b" 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]" 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,6 +117,22 @@
</div> </div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1"> <div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
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={() => {
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{#if channel?.user_count !== undefined} {#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}> <Tooltip content={$i18n.t('Users')}>
<button <button
@ -131,6 +153,7 @@
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{/if}
{#if $user !== undefined} {#if $user !== undefined}
<UserMenu <UserMenu

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

@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { decodeString } from '$lib/utils';
export let id; export let id;
export let title: string = 'N/A'; export let title: string = 'N/A';
@ -15,6 +17,14 @@
return domain; return domain;
} }
const getDisplayTitle = (title: string) => {
if (!title) return 'N/A';
if (title.length > 30) {
return title.slice(0, 15) + '...' + title.slice(-10);
}
return title;
};
// Helper function to check if text is a URL and return the domain // Helper function to check if text is a URL and return the domain
function formattedTitle(title: string): string { function formattedTitle(title: string): string {
if (title.startsWith('http')) { if (title.startsWith('http')) {
@ -23,14 +33,6 @@
return title; return title;
} }
const getDisplayTitle = (title: string) => {
if (!title) return 'N/A';
if (title.length > 30) {
return title.slice(0, 15) + '...' + title.slice(-10);
}
return title;
};
</script> </script>
{#if title !== 'N/A'} {#if title !== 'N/A'}
@ -41,7 +43,7 @@
}} }}
> >
<span class="line-clamp-1"> <span class="line-clamp-1">
{getDisplayTitle(formattedTitle(decodeURIComponent(title)))} {getDisplayTitle(formattedTitle(decodeString(title)))}
</span> </span>
</button> </button>
{/if} {/if}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { LinkPreview } from 'bits-ui'; import { LinkPreview } from 'bits-ui';
import { decodeString } from '$lib/utils';
import Source from './Source.svelte'; import Source from './Source.svelte';
export let id; export let id;
@ -50,7 +51,7 @@
}} }}
> >
<span class="line-clamp-1"> <span class="line-clamp-1">
{getDisplayTitle(formattedTitle(decodeURIComponent(sourceIds[token.ids[0] - 1])))} {getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))}
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span> <span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
</span> </span>
</button> </button>

View file

@ -157,40 +157,10 @@
class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a
href="https://openwebui.com" href="https://openwebui.com"
target="_blank" target="_blank"
class="underline">Open WebUI (Timothy Jaeryang Baek)</a class="underline">Open WebUI Inc.</a
> <a href="https://github.com/open-webui/open-webui/blob/main/LICENSE" target="_blank"
>All rights reserved.</a
> >
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</pre> </pre>
</div> </div>

View file

@ -569,7 +569,7 @@
}); });
</script> </script>
<Modal size="xl" bind:show> <Modal size="2xl" bind:show>
<div class="text-gray-700 dark:text-gray-100 mx-1"> <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=" 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> <div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>
@ -588,7 +588,7 @@
<div <div
role="tablist" role="tablist"
id="settings-tabs-container" 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 <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" 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> </a>
{/if} {/if}
</div> </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'} {#if selectedTab === 'general'}
<General <General
{getModels} {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> </script>
<svg <svg
viewBox="0 0 24 24"
stroke-width={strokeWidth} stroke-width={strokeWidth}
stroke="currentColor" stroke="currentColor"
class={className} class={className}
@ -12,7 +13,6 @@
fill="none" fill="none"
width="24" width="24"
height="24" height="24"
viewBox="0 0 22 22"
><path d="M9.5 14.5L3 21" stroke-linecap="round" stroke-linejoin="round"></path><path ><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" 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" stroke-linecap="round"

View file

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

View file

@ -5,6 +5,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { channels, mobile, showSidebar, user } from '$lib/stores'; import { channels, mobile, showSidebar, user } from '$lib/stores';
import { getUserActiveStatusById } from '$lib/apis/users';
import { updateChannelById, updateChannelMemberActiveStatusById } from '$lib/apis/channels'; import { updateChannelById, updateChannelMemberActiveStatusById } from '$lib/apis/channels';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -83,8 +84,9 @@
<div> <div>
{#if channel?.type === 'dm'} {#if channel?.type === 'dm'}
{#if channel?.users} {#if channel?.users}
<div class="flex ml-[1px] mr-0.5"> {@const channelMembers = channel.users.filter((u) => u.id !== $user?.id)}
{#each channel.users.filter((u) => u.id !== $user?.id).slice(0, 2) as u, index} <div class="flex ml-[1px] mr-0.5 relative">
{#each channelMembers.slice(0, 2) as u, index}
<img <img
src={`${WEBUI_API_BASE_URL}/users/${u.id}/profile/image`} src={`${WEBUI_API_BASE_URL}/users/${u.id}/profile/image`}
alt={u.name} alt={u.name}
@ -94,6 +96,23 @@
: ''}" : ''}"
/> />
{/each} {/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> </div>
{:else} {:else}
<Users className="size-4 ml-1 mr-0.5" strokeWidth="2" /> <Users className="size-4 ml-1 mr-0.5" strokeWidth="2" />

View file

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

View file

@ -90,6 +90,8 @@
let showRefresh = false; let showRefresh = false;
let heartbeatInterval = null;
const BREAKPOINT = 768; const BREAKPOINT = 768;
const setupSocket = async (enableWebsocket) => { 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) { if (deploymentId !== null) {
WEBUI_DEPLOYMENT_ID.set(deploymentId); WEBUI_DEPLOYMENT_ID.set(deploymentId);
} }
@ -154,6 +164,12 @@
_socket.on('disconnect', (reason, details) => { _socket.on('disconnect', (reason, details) => {
console.log(`Socket ${_socket.id} disconnected due to ${reason}`); console.log(`Socket ${_socket.id} disconnected due to ${reason}`);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (details) { if (details) {
console.log('Additional details:', details); console.log('Additional details:', details);
} }