mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
Merge branch 'dev' into feat/google-oauth-groups-dev
This commit is contained in:
commit
4b46f7d802
42 changed files with 1298 additions and 352 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
159
src/lib/components/channel/PinnedMessagesModal.svelte
Normal file
159
src/lib/components/channel/PinnedMessagesModal.svelte
Normal 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}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
21
src/lib/components/icons/Pin.svelte
Normal file
21
src/lib/components/icons/Pin.svelte
Normal 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
|
||||||
|
>
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue