mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
Merge remote-tracking branch 'origin/dev' into feature/tools-system
This commit is contained in:
commit
7e3d7b8a00
33 changed files with 1228 additions and 300 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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, unique=True)
|
|
||||||
name = Column(String)
|
|
||||||
|
|
||||||
email = Column(String)
|
|
||||||
username = Column(String(50), nullable=True)
|
|
||||||
|
|
||||||
role = Column(String)
|
|
||||||
profile_image_url = Column(Text)
|
|
||||||
|
|
||||||
bio = Column(Text, nullable=True)
|
|
||||||
gender = Column(Text, nullable=True)
|
|
||||||
date_of_birth = Column(Date, nullable=True)
|
|
||||||
|
|
||||||
info = Column(JSONField, nullable=True)
|
|
||||||
settings = Column(JSONField, nullable=True)
|
|
||||||
|
|
||||||
api_key = Column(String, nullable=True, unique=True)
|
|
||||||
oauth_sub = Column(Text, unique=True)
|
|
||||||
|
|
||||||
last_active_at = Column(BigInteger)
|
|
||||||
|
|
||||||
updated_at = Column(BigInteger)
|
|
||||||
created_at = Column(BigInteger)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSettings(BaseModel):
|
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,
|
||||||
|
|
@ -783,9 +782,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",
|
||||||
|
|
@ -796,6 +798,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
|
||||||
|
|
@ -1925,7 +1928,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(
|
||||||
|
|
@ -3220,7 +3223,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(
|
||||||
|
|
|
||||||
|
|
@ -1329,7 +1329,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
|
||||||
|
|
@ -1376,12 +1379,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
|
||||||
|
|
@ -1394,7 +1397,7 @@ class OAuthManager:
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||||
|
|
||||||
# Check if the user exists
|
# Check if the user exists
|
||||||
user = Users.get_user_by_oauth_sub(provider_sub)
|
user = Users.get_user_by_oauth_sub(provider, sub)
|
||||||
if not user:
|
if not user:
|
||||||
# If the user does not exist, check if merging is enabled
|
# If the user does not exist, check if merging is enabled
|
||||||
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||||
|
|
@ -1402,7 +1405,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 = self.get_user_role(user, user_data)
|
determined_role = self.get_user_role(user, user_data)
|
||||||
|
|
@ -1461,7 +1464,7 @@ class OAuthManager:
|
||||||
name=name,
|
name=name,
|
||||||
profile_image_url=picture_url,
|
profile_image_url=picture_url,
|
||||||
role=self.get_user_role(None, user_data),
|
role=self.get_user_role(None, user_data),
|
||||||
oauth_sub=provider_sub,
|
oauth=oauth_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if auth_manager_config.WEBHOOK_URL:
|
if auth_manager_config.WEBHOOK_URL:
|
||||||
|
|
|
||||||
|
|
@ -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,6 +4,7 @@
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
chatId,
|
chatId,
|
||||||
|
|
@ -22,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 = '';
|
||||||
|
|
||||||
|
|
@ -117,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);
|
||||||
|
|
@ -182,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;
|
||||||
});
|
});
|
||||||
|
|
@ -274,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"
|
||||||
|
|
@ -317,7 +352,6 @@
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" pb-[1rem] px-2.5">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
|
|
@ -338,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}
|
||||||
|
|
@ -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