diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 747859db88..2bbede4141 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -68,6 +68,7 @@ from open_webui.socket.main import ( get_models_in_use, ) from open_webui.routers import ( + analytics, audio, images, ollama, @@ -1459,6 +1460,9 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function app.include_router( evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"] ) +app.include_router( + analytics.router, prefix="/api/v1/analytics", tags=["analytics"] +) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) # SCIM 2.0 API for identity management diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py new file mode 100644 index 0000000000..5a139db3e2 --- /dev/null +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -0,0 +1,173 @@ +"""Add chat_message table + +Revision ID: 8452d01d26d7 +Revises: 374d2f66af06 +Create Date: 2026-02-01 04:00:00.000000 + +""" + +import time +import json +import logging +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +log = logging.getLogger(__name__) + +revision: str = "8452d01d26d7" +down_revision: Union[str, None] = "374d2f66af06" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Create table + op.create_table( + "chat_message", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("chat_id", sa.Text(), nullable=False, index=True), + sa.Column("user_id", sa.Text(), index=True), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("parent_id", sa.Text(), nullable=True), + sa.Column("content", sa.JSON(), nullable=True), + sa.Column("output", sa.JSON(), nullable=True), + sa.Column("model_id", sa.Text(), nullable=True, index=True), + sa.Column("files", sa.JSON(), nullable=True), + sa.Column("sources", sa.JSON(), nullable=True), + sa.Column("embeds", sa.JSON(), nullable=True), + sa.Column("done", sa.Boolean(), default=True), + sa.Column("status_history", sa.JSON(), nullable=True), + sa.Column("error", sa.JSON(), nullable=True), + sa.Column("usage", sa.JSON(), nullable=True), + sa.Column("created_at", sa.BigInteger(), index=True), + sa.Column("updated_at", sa.BigInteger()), + sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"), + ) + + # Create composite indexes + op.create_index( + "chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"] + ) + op.create_index( + "chat_message_model_created_idx", "chat_message", ["model_id", "created_at"] + ) + op.create_index( + "chat_message_user_created_idx", "chat_message", ["user_id", "created_at"] + ) + + # Step 2: Backfill from existing chats + conn = op.get_bind() + + chat_table = sa.table( + "chat", + sa.column("id", sa.Text()), + sa.column("user_id", sa.Text()), + sa.column("chat", sa.JSON()), + ) + + chat_message_table = sa.table( + "chat_message", + sa.column("id", sa.Text()), + sa.column("chat_id", sa.Text()), + sa.column("user_id", sa.Text()), + sa.column("role", sa.Text()), + sa.column("parent_id", sa.Text()), + sa.column("content", sa.JSON()), + sa.column("output", sa.JSON()), + sa.column("model_id", sa.Text()), + sa.column("files", sa.JSON()), + sa.column("sources", sa.JSON()), + sa.column("embeds", sa.JSON()), + sa.column("done", sa.Boolean()), + sa.column("status_history", sa.JSON()), + sa.column("error", sa.JSON()), + sa.column("usage", sa.JSON()), + sa.column("created_at", sa.BigInteger()), + sa.column("updated_at", sa.BigInteger()), + ) + + # Fetch all chats + chats = conn.execute( + sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat) + ).fetchall() + + now = int(time.time()) + messages_inserted = 0 + messages_failed = 0 + + for chat_row in chats: + chat_id = chat_row[0] + user_id = chat_row[1] + chat_data = chat_row[2] + + if not chat_data: + continue + + # Handle both string and dict chat data + if isinstance(chat_data, str): + try: + chat_data = json.loads(chat_data) + except Exception: + continue + + history = chat_data.get("history", {}) + messages = history.get("messages", {}) + + for message_id, message in messages.items(): + if not isinstance(message, dict): + continue + + role = message.get("role") + if not role: + continue + + timestamp = message.get("timestamp", now) + + # Normalize timestamp: convert ms to seconds, validate range + if timestamp > 10_000_000_000: + timestamp = timestamp // 1000 + # Must be after 2020 and not too far in the future + if timestamp < 1577836800 or timestamp > now + 86400: + timestamp = now + + # Use savepoint to allow individual insert failures without aborting transaction + savepoint = conn.begin_nested() + try: + conn.execute( + sa.insert(chat_message_table).values( + id=f"{chat_id}-{message_id}", + chat_id=chat_id, + user_id=user_id, + role=role, + parent_id=message.get("parentId"), + content=message.get("content"), + output=message.get("output"), + model_id=message.get("model"), + files=message.get("files"), + sources=message.get("sources"), + embeds=message.get("embeds"), + done=message.get("done", True), + status_history=message.get("statusHistory"), + error=message.get("error"), + created_at=timestamp, + updated_at=timestamp, + ) + ) + savepoint.commit() + messages_inserted += 1 + except Exception as e: + savepoint.rollback() + messages_failed += 1 + log.warning(f"Failed to insert message {message_id}: {e}") + continue + + log.info(f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)") + + +def downgrade() -> None: + op.drop_index("chat_message_user_created_idx", table_name="chat_message") + op.drop_index("chat_message_model_created_idx", table_name="chat_message") + op.drop_index("chat_message_chat_parent_idx", table_name="chat_message") + op.drop_table("chat_message") diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py new file mode 100644 index 0000000000..9254baf5d2 --- /dev/null +++ b/backend/open_webui/models/chat_messages.py @@ -0,0 +1,545 @@ +import json +import time +import uuid +from typing import Any, Optional + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + ForeignKey, + Text, + JSON, + Index, +) + +#################### +# Helpers +#################### + + +def _normalize_timestamp(timestamp: int) -> float: + """Normalize and validate timestamp. Returns current time if invalid.""" + now = time.time() + + # Convert milliseconds to seconds if needed + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000 + + # Validate: must be after 2020 and not in the future (with 1 day tolerance) + min_valid = 1577836800 # 2020-01-01 00:00:00 UTC + max_valid = now + 86400 # 1 day in the future (clock skew tolerance) + + if timestamp < min_valid or timestamp > max_valid: + return now + + return timestamp + + +#################### +# ChatMessage DB Schema +#################### + + +class ChatMessage(Base): + __tablename__ = "chat_message" + + # Identity + id = Column(Text, primary_key=True) + chat_id = Column( + Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id = Column(Text, index=True) + + # Structure + role = Column(Text, nullable=False) # user, assistant, system + parent_id = Column(Text, nullable=True) + + # Content + content = Column(JSON, nullable=True) # Can be str or list of blocks + output = Column(JSON, nullable=True) + + # Model (for assistant messages) + model_id = Column(Text, nullable=True, index=True) + + # Attachments + files = Column(JSON, nullable=True) + sources = Column(JSON, nullable=True) + embeds = Column(JSON, nullable=True) + + # Status + done = Column(Boolean, default=True) + status_history = Column(JSON, nullable=True) + error = Column(JSON, nullable=True) + + # Usage (tokens, timing, etc.) + usage = Column(JSON, nullable=True) + + # Timestamps + created_at = Column(BigInteger, index=True) + updated_at = Column(BigInteger) + + __table_args__ = ( + Index("chat_message_chat_parent_idx", "chat_id", "parent_id"), + Index("chat_message_model_created_idx", "model_id", "created_at"), + Index("chat_message_user_created_idx", "user_id", "created_at"), + ) + + +#################### +# Pydantic Models +#################### + + +class ChatMessageModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + role: str + parent_id: Optional[str] = None + content: Optional[Any] = None # str or list of blocks + output: Optional[list] = None + model_id: Optional[str] = None + files: Optional[list] = None + sources: Optional[list] = None + embeds: Optional[list] = None + done: bool = True + status_history: Optional[list] = None + error: Optional[dict] = None + usage: Optional[dict] = None + created_at: int + updated_at: int + + +#################### +# Table Operations +#################### + + +class ChatMessageTable: + def upsert_message( + self, + message_id: str, + chat_id: str, + user_id: str, + data: dict, + db: Optional[Session] = None, + ) -> Optional[ChatMessageModel]: + """Insert or update a chat message.""" + with get_db_context(db) as db: + now = int(time.time()) + timestamp = data.get("timestamp", now) + + # Use composite ID: {chat_id}-{message_id} + composite_id = f"{chat_id}-{message_id}" + + existing = db.get(ChatMessage, composite_id) + if existing: + # Update existing + if "role" in data: + existing.role = data["role"] + if "parent_id" in data: + existing.parent_id = data.get("parent_id") or data.get("parentId") + if "content" in data: + existing.content = data.get("content") + if "output" in data: + existing.output = data.get("output") + if "model_id" in data or "model" in data: + existing.model_id = data.get("model_id") or data.get("model") + if "files" in data: + existing.files = data.get("files") + if "sources" in data: + existing.sources = data.get("sources") + if "embeds" in data: + existing.embeds = data.get("embeds") + if "done" in data: + existing.done = data.get("done", True) + if "status_history" in data or "statusHistory" in data: + existing.status_history = data.get("status_history") or data.get( + "statusHistory" + ) + if "error" in data: + existing.error = data.get("error") + # Extract usage - check direct field first, then info.usage + usage = data.get("usage") + if not usage: + info = data.get("info", {}) + usage = info.get("usage") if info else None + if usage: + existing.usage = usage + existing.updated_at = now + db.commit() + db.refresh(existing) + return ChatMessageModel.model_validate(existing) + else: + # Insert new + # Extract usage - check direct field first, then info.usage + usage = data.get("usage") + if not usage: + info = data.get("info", {}) + usage = info.get("usage") if info else None + message = ChatMessage( + id=composite_id, + chat_id=chat_id, + user_id=user_id, + role=data.get("role", "user"), + parent_id=data.get("parent_id") or data.get("parentId"), + content=data.get("content"), + output=data.get("output"), + model_id=data.get("model_id") or data.get("model"), + files=data.get("files"), + sources=data.get("sources"), + embeds=data.get("embeds"), + done=data.get("done", True), + status_history=data.get("status_history") + or data.get("statusHistory"), + error=data.get("error"), + usage=usage, + created_at=timestamp, + updated_at=now, + ) + db.add(message) + db.commit() + db.refresh(message) + return ChatMessageModel.model_validate(message) + + def get_message_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatMessageModel]: + with get_db_context(db) as db: + message = db.get(ChatMessage, id) + return ChatMessageModel.model_validate(message) if message else None + + def get_messages_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + messages = ( + db.query(ChatMessage) + .filter_by(chat_id=chat_id) + .order_by(ChatMessage.created_at.asc()) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def get_messages_by_user_id( + self, + user_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + messages = ( + db.query(ChatMessage) + .filter_by(user_id=user_id) + .order_by(ChatMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def get_messages_by_model_id( + self, + model_id: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + skip: int = 0, + limit: int = 100, + db: Optional[Session] = None, + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + query = db.query(ChatMessage).filter_by(model_id=model_id) + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + messages = ( + query.order_by(ChatMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def delete_messages_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: + db.query(ChatMessage).filter_by(chat_id=chat_id).delete() + db.commit() + return True + + # Analytics methods + def get_message_count_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.model_id, func.count(ChatMessage.id).label("count") + ).filter(ChatMessage.role == "assistant", ChatMessage.model_id.isnot(None)) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.model_id).all() + return {row.model_id: row.count for row in results} + + def get_token_usage_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict]: + """Aggregate token usage by model using database-level aggregation.""" + with get_db_context(db) as db: + from sqlalchemy import func, cast, Integer + + dialect = db.bind.dialect.name + + if dialect == "sqlite": + input_tokens = cast( + func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer + ) + elif dialect == "postgresql": + # Use json_extract_path_text for PostgreSQL JSON columns + input_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer + ) + else: + raise NotImplementedError(f"Unsupported dialect: {dialect}") + + query = db.query( + ChatMessage.model_id, + func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), + func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), + func.count(ChatMessage.id).label("message_count"), + ).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None), + ChatMessage.usage.isnot(None), + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.model_id).all() + + return { + row.model_id: { + "input_tokens": row.input_tokens, + "output_tokens": row.output_tokens, + "total_tokens": row.input_tokens + row.output_tokens, + "message_count": row.message_count, + } + for row in results + } + + def get_token_usage_by_user( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict]: + """Aggregate token usage by user using database-level aggregation.""" + with get_db_context(db) as db: + from sqlalchemy import func, cast, Integer + + dialect = db.bind.dialect.name + + if dialect == "sqlite": + input_tokens = cast( + func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer + ) + elif dialect == "postgresql": + # Use json_extract_path_text for PostgreSQL JSON columns + input_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer + ) + else: + raise NotImplementedError(f"Unsupported dialect: {dialect}") + + query = db.query( + ChatMessage.user_id, + func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), + func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), + func.count(ChatMessage.id).label("message_count"), + ).filter( + ChatMessage.role == "assistant", + ChatMessage.user_id.isnot(None), + ChatMessage.usage.isnot(None), + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.user_id).all() + + return { + row.user_id: { + "input_tokens": row.input_tokens, + "output_tokens": row.output_tokens, + "total_tokens": row.input_tokens + row.output_tokens, + "message_count": row.message_count, + } + for row in results + } + + def get_message_count_by_user( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.user_id, func.count(ChatMessage.id).label("count") + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.user_id).all() + return {row.user_id: row.count for row in results} + + def get_message_count_by_chat( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.chat_id, func.count(ChatMessage.id).label("count") + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.chat_id).all() + return {row.chat_id: row.count for row in results} + + def get_daily_message_counts_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict[str, int]]: + """Get message counts grouped by day and model.""" + with get_db_context(db) as db: + from datetime import datetime, timedelta + + query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None) + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.all() + + # Group by date -> model -> count + daily_counts: dict[str, dict[str, int]] = {} + for timestamp, model_id in results: + date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d") + if date_str not in daily_counts: + daily_counts[date_str] = {} + daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 + + # Fill in missing days + if start_date and end_date: + current = datetime.fromtimestamp(_normalize_timestamp(start_date)) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) + while current <= end_dt: + date_str = current.strftime("%Y-%m-%d") + if date_str not in daily_counts: + daily_counts[date_str] = {} + current += timedelta(days=1) + + return daily_counts + + def get_hourly_message_counts_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict[str, int]]: + """Get message counts grouped by hour and model.""" + with get_db_context(db) as db: + from datetime import datetime, timedelta + + query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None) + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.all() + + # Group by hour -> model -> count + hourly_counts: dict[str, dict[str, int]] = {} + for timestamp, model_id in results: + hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d %H:00") + if hour_str not in hourly_counts: + hourly_counts[hour_str] = {} + hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 + + # Fill in missing hours + if start_date and end_date: + current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(minute=0, second=0, microsecond=0) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) + while current <= end_dt: + hour_str = current.strftime("%Y-%m-%d %H:00") + if hour_str not in hourly_counts: + hourly_counts[hour_str] = {} + current += timedelta(hours=1) + + return hourly_counts + + +ChatMessages = ChatMessageTable() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index eb0763048b..51a714cea7 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders +from open_webui.models.chat_messages import ChatMessages from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db from pydantic import BaseModel, ConfigDict @@ -314,6 +315,22 @@ class ChatTable: db.add(chat_item) db.commit() db.refresh(chat_item) + + # Dual-write initial messages to chat_message table + try: + history = form_data.chat.get("history", {}) + messages = history.get("messages", {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get("role"): + ChatMessages.upsert_message( + message_id=message_id, + chat_id=id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning(f"Failed to write initial messages to chat_message table: {e}") + return ChatModel.model_validate(chat_item) if chat_item else None def _chat_import_form_to_chat_model( @@ -356,6 +373,23 @@ class ChatTable: db.add_all(chats) db.commit() + + # Dual-write messages to chat_message table + try: + for form_data, chat_obj in zip(chat_import_forms, chats): + history = form_data.chat.get("history", {}) + messages = history.get("messages", {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get("role"): + ChatMessages.upsert_message( + message_id=message_id, + chat_id=chat_obj.id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning(f"Failed to write imported messages to chat_message table: {e}") + return [ChatModel.model_validate(chat) for chat in chats] def update_chat_by_id( @@ -458,6 +492,18 @@ class ChatTable: history["currentId"] = message_id chat["history"] = history + + # Dual-write to chat_message table + try: + ChatMessages.upsert_message( + message_id=message_id, + chat_id=id, + user_id=self.get_chat_by_id(id).user_id, + data=history["messages"][message_id], + ) + except Exception as e: + log.warning(f"Failed to write to chat_message table: {e}") + return self.update_chat_by_id(id, chat) def add_message_status_to_chat_by_id_and_message_id( diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index deeda29a85..43c5a94f81 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -243,6 +243,7 @@ class UsersTable: email: str, profile_image_url: str = "/user.png", role: str = "pending", + username: Optional[str] = None, oauth: Optional[dict] = None, db: Optional[Session] = None, ) -> Optional[UserModel]: @@ -257,6 +258,7 @@ class UsersTable: "last_active_at": int(time.time()), "created_at": int(time.time()), "updated_at": int(time.time()), + "username": username, "oauth": oauth, } ) diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py new file mode 100644 index 0000000000..d8f6928ffc --- /dev/null +++ b/backend/open_webui/routers/analytics.py @@ -0,0 +1,247 @@ +from typing import Optional +import logging +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from open_webui.models.chat_messages import ChatMessages, ChatMessageModel +from open_webui.utils.auth import get_admin_user +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session + +log = logging.getLogger(__name__) + + +router = APIRouter() + + +#################### +# Response Models +#################### + + +class ModelAnalyticsEntry(BaseModel): + model_id: str + count: int + + +class ModelAnalyticsResponse(BaseModel): + models: list[ModelAnalyticsEntry] + + +class UserAnalyticsEntry(BaseModel): + user_id: str + name: Optional[str] = None + email: Optional[str] = None + count: int + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + +class UserAnalyticsResponse(BaseModel): + users: list[UserAnalyticsEntry] + + +#################### +# Endpoints +#################### + + +@router.get("/models", response_model=ModelAnalyticsResponse) +async def get_model_analytics( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts per model.""" + counts = ChatMessages.get_message_count_by_model( + start_date=start_date, end_date=end_date, db=db + ) + models = [ + ModelAnalyticsEntry(model_id=model_id, count=count) + for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) + ] + return ModelAnalyticsResponse(models=models) + + +@router.get("/users", response_model=UserAnalyticsResponse) +async def get_user_analytics( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + limit: int = Query(50, description="Max users to return"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts and token usage per user with user info.""" + from open_webui.models.users import Users + + counts = ChatMessages.get_message_count_by_user( + start_date=start_date, end_date=end_date, db=db + ) + token_usage = ChatMessages.get_token_usage_by_user( + start_date=start_date, end_date=end_date, db=db + ) + + # Get user info for top users + top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]] + user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)} + + users = [] + for user_id in top_user_ids: + u = user_info.get(user_id) + tokens = token_usage.get(user_id, {}) + users.append(UserAnalyticsEntry( + user_id=user_id, + name=u.name if u else None, + email=u.email if u else None, + count=counts[user_id], + input_tokens=tokens.get("input_tokens", 0), + output_tokens=tokens.get("output_tokens", 0), + total_tokens=tokens.get("total_tokens", 0), + )) + + return UserAnalyticsResponse(users=users) + + +@router.get("/messages", response_model=list[ChatMessageModel]) +async def get_messages( + model_id: Optional[str] = Query(None, description="Filter by model ID"), + user_id: Optional[str] = Query(None, description="Filter by user ID"), + chat_id: Optional[str] = Query(None, description="Filter by chat ID"), + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + skip: int = Query(0), + limit: int = Query(50, le=100), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Query messages with filters.""" + if chat_id: + return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db) + elif model_id: + return ChatMessages.get_messages_by_model_id( + model_id=model_id, + start_date=start_date, + end_date=end_date, + skip=skip, + limit=limit, + db=db, + ) + elif user_id: + return ChatMessages.get_messages_by_user_id( + user_id=user_id, skip=skip, limit=limit, db=db + ) + else: + # Return empty if no filter specified + return [] + + +class SummaryResponse(BaseModel): + total_messages: int + total_chats: int + total_models: int + total_users: int + + +@router.get("/summary", response_model=SummaryResponse) +async def get_summary( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get summary statistics for the dashboard.""" + model_counts = ChatMessages.get_message_count_by_model( + start_date=start_date, end_date=end_date, db=db + ) + user_counts = ChatMessages.get_message_count_by_user( + start_date=start_date, end_date=end_date, db=db + ) + chat_counts = ChatMessages.get_message_count_by_chat( + start_date=start_date, end_date=end_date, db=db + ) + + return SummaryResponse( + total_messages=sum(model_counts.values()), + total_chats=len(chat_counts), + total_models=len(model_counts), + total_users=len(user_counts), + ) + + +class DailyStatsEntry(BaseModel): + date: str + models: dict[str, int] + + +class DailyStatsResponse(BaseModel): + data: list[DailyStatsEntry] + + +@router.get("/daily", response_model=DailyStatsResponse) +async def get_daily_stats( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts grouped by model for time-series chart.""" + if granularity == "hourly": + counts = ChatMessages.get_hourly_message_counts_by_model( + start_date=start_date, end_date=end_date, db=db + ) + else: + counts = ChatMessages.get_daily_message_counts_by_model( + start_date=start_date, end_date=end_date, db=db + ) + return DailyStatsResponse( + data=[ + DailyStatsEntry(date=date, models=models) + for date, models in sorted(counts.items()) + ] + ) + + +class TokenUsageEntry(BaseModel): + model_id: str + input_tokens: int + output_tokens: int + total_tokens: int + message_count: int + + +class TokenUsageResponse(BaseModel): + models: list[TokenUsageEntry] + total_input_tokens: int + total_output_tokens: int + total_tokens: int + + +@router.get("/tokens", response_model=TokenUsageResponse) +async def get_token_usage( + start_date: Optional[int] = Query(None), + end_date: Optional[int] = Query(None), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get token usage aggregated by model.""" + usage = ChatMessages.get_token_usage_by_model( + start_date=start_date, end_date=end_date, db=db + ) + + models = [ + TokenUsageEntry(model_id=model_id, **data) + for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"]) + ] + + total_input = sum(m.input_tokens for m in models) + total_output = sum(m.output_tokens for m in models) + + return TokenUsageResponse( + models=models, + total_input_tokens=total_input, + total_output_tokens=total_output, + total_tokens=total_input + total_output, + ) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 44575e57f2..b1d31afb8f 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -794,6 +794,79 @@ def convert_to_azure_payload(url, payload: dict, api_version: str): return url, payload +def convert_to_responses_payload(payload: dict) -> dict: + """ + Convert Chat Completions payload to Responses API format. + + Chat Completions: { messages: [{role, content}], ... } + Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" } + """ + messages = payload.pop("messages", []) + + system_content = "" + input_items = [] + + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + # Check for stored output items (from previous Responses API turn) + stored_output = msg.get("output") + if stored_output and isinstance(stored_output, list): + input_items.extend(stored_output) + continue + + if role == "system": + if isinstance(content, str): + system_content = content + elif isinstance(content, list): + system_content = "\n".join(p.get("text", "") for p in content if p.get("type") == "text") + continue + + # Convert content format + text_type = "output_text" if role == "assistant" else "input_text" + + if isinstance(content, str): + content_parts = [{"type": text_type, "text": content}] + elif isinstance(content, list): + content_parts = [] + for part in content: + if part.get("type") == "text": + content_parts.append({"type": text_type, "text": part.get("text", "")}) + elif part.get("type") == "image_url": + url_data = part.get("image_url", {}) + url = url_data.get("url", "") if isinstance(url_data, dict) else url_data + content_parts.append({"type": "input_image", "image_url": url}) + else: + content_parts = [{"type": text_type, "text": str(content)}] + + input_items.append({ + "type": "message", + "role": role, + "content": content_parts + }) + + responses_payload = {**payload, "input": input_items} + + if system_content: + responses_payload["instructions"] = system_content + + if "max_tokens" in responses_payload: + responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens") + + return responses_payload + + + +def convert_responses_result(response: dict) -> dict: + """ + Convert non-streaming Responses API result. + Just add done flag - pass through raw response, frontend handles output. + """ + response["done"] = True + return response + + @router.post("/chat/completions") async def generate_chat_completion( request: Request, @@ -915,6 +988,8 @@ async def generate_chat_completion( request, url, key, api_config, metadata, user=user ) + is_responses = api_config.get("api_type") == "responses" + if api_config.get("azure", False): api_version = api_config.get("api_version", "2023-03-15-preview") request_url, payload = convert_to_azure_payload(url, payload, api_version) @@ -925,9 +1000,18 @@ async def generate_chat_completion( headers["api-key"] = key headers["api-version"] = api_version - request_url = f"{request_url}/chat/completions?api-version={api_version}" + + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f"{request_url}/responses?api-version={api_version}" + else: + request_url = f"{request_url}/chat/completions?api-version={api_version}" else: - request_url = f"{url}/chat/completions" + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f"{url}/responses" + else: + request_url = f"{url}/chat/completions" payload = json.dumps(payload) @@ -974,6 +1058,10 @@ async def generate_chat_completion( else: return PlainTextResponse(status_code=r.status, content=response) + # Convert Responses API result to simple format + if is_responses and isinstance(response, dict): + response = convert_responses_result(response) + return response except Exception as e: log.exception(e) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 81e07df94e..0f40ce5941 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -107,6 +107,7 @@ from open_webui.utils.filter import ( ) from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.payload import apply_system_prompt_to_body +from open_webui.utils.response import normalize_usage from open_webui.utils.mcp.client import MCPClient @@ -293,6 +294,476 @@ def get_citation_source_from_tool_result( ] +def split_content_and_whitespace(content): + content_stripped = content.rstrip() + original_whitespace = ( + content[len(content_stripped) :] if len(content) > len(content_stripped) else "" + ) + return content_stripped, original_whitespace + + +def is_opening_code_block(content): + backtick_segments = content.split("```") + # Even number of segments means the last backticks are opening a new block + return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 + + +def serialize_output(output: list) -> str: + """ + Convert OR-aligned output items to HTML for display. + For LLM consumption, use convert_output_to_messages() instead. + """ + content = "" + + # First pass: collect function_call_output items by call_id for lookup + tool_outputs = {} + for item in output: + if item.get("type") == "function_call_output": + tool_outputs[item.get("call_id")] = item + + # Second pass: render items in order + for idx, item in enumerate(output): + item_type = item.get("type", "") + + if item_type == "message": + for content_part in item.get("content", []): + if "text" in content_part: + text = content_part.get("text", "").strip() + if text: + content = f"{content}{text}\n" + + elif item_type == "function_call": + # Render tool call inline with its result (if available) + if content and not content.endswith("\n"): + content += "\n" + + call_id = item.get("call_id", "") + name = item.get("name", "") + arguments = item.get("arguments", "") + + result_item = tool_outputs.get(call_id) + if result_item: + result_text = "" + for out in result_item.get("output", []): + if "text" in out: + result_text += out.get("text", "") + files = result_item.get("files") + embeds = result_item.get("embeds", "") + + content += f'
\nTool Executed\n
\n' + else: + content += f'
\nExecuting...\n
\n' + + elif item_type == "function_call_output": + # Already handled inline with function_call above + pass + + elif item_type == "reasoning": + reasoning_content = "" + # Check for 'summary' (new structure) or 'content' (legacy/fallback) + source_list = item.get("summary", []) or item.get("content", []) + for content_part in source_list: + if "text" in content_part: + reasoning_content += content_part.get("text", "") + elif "summary" in content_part: # Handle potential nested logic if any + pass + + reasoning_content = reasoning_content.strip() + + duration = item.get("duration") + status = item.get("status", "in_progress") + + # Infer completion: if this reasoning item is NOT the last item, + # render as done (a subsequent item means reasoning is complete) + is_last_item = idx == len(output) - 1 + + if content and not content.endswith("\n"): + content += "\n" + + display = html.escape( + "\n".join( + (f"> {line}" if not line.startswith(">") else line) + for line in reasoning_content.splitlines() + ) + ) + + if status == "completed" or duration is not None or not is_last_item: + content = f'{content}
\nThought for {duration or 0} seconds\n{display}\n
\n' + else: + content = f'{content}
\nThinking…\n{display}\n
\n' + + elif item_type == "open_webui:code_interpreter": + content_stripped, original_whitespace = split_content_and_whitespace( + content + ) + if is_opening_code_block(content_stripped): + content = content_stripped.rstrip("`").rstrip() + original_whitespace + else: + content = content_stripped + original_whitespace + + if content and not content.endswith("\n"): + content += "\n" + + return content.strip() + + +def deep_merge(target, source): + """ + Merge source into target recursively (returning new structure). + - Dicts: Recursive merge. + - Strings: Concatenation. + - Others: Overwrite. + """ + if isinstance(target, dict) and isinstance(source, dict): + new_target = target.copy() + for k, v in source.items(): + if k in new_target: + new_target[k] = deep_merge(new_target[k], v) + else: + new_target[k] = v + return new_target + elif isinstance(target, str) and isinstance(source, str): + return target + source + else: + return source + + +def handle_responses_streaming_event( + data: dict, + current_output: list, +) -> tuple[list, dict | None]: + """ + Handle Responses API streaming events in a pure functional way. + + Args: + data: The event data + current_output: List of output items (treated as immutable) + + Returns: + tuple[list, dict | None]: (new_output, metadata) + - new_output: The updated output list. + - metadata: Metadata to emit (e.g. usage), {} if update occurred, None if skip. + """ + # Default: no change + # Note: treating current_output as immutable, but avoiding full deepcopy for perf. + # We will shallow copy only if we need to modify the list structure or items. + + event_type = data.get("type", "") + + if event_type == "response.output_item.added": + item = data.get("item", {}) + if item: + new_output = list(current_output) + new_output.append(item) + return new_output, None + return current_output, None + + elif event_type == "response.content_part.added": + part = data.get("part", {}) + output_index = data.get("output_index", len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + # Copy the item to mutate it + item = new_output[output_index].copy() + new_output[output_index] = item + + if "content" not in item: + item["content"] = [] + else: + # Copy content list + item["content"] = list(item["content"]) + + if item.get("type") == "reasoning": + # Reasoning items should not have content parts + pass + else: + item["content"].append(part) + item["content"].append(part) + return new_output, None + return current_output, None + + elif event_type == "response.reasoning_summary_part.added": + part = data.get("part", {}) + output_index = data.get("output_index", len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if "summary" not in item: + item["summary"] = [] + else: + item["summary"] = list(item["summary"]) + + item["summary"].append(part) + return new_output, None + return current_output, None + + elif event_type.startswith("response.") and event_type.endswith(".delta"): + # Generic Delta Handling + parts = event_type.split(".") + if len(parts) >= 3: + delta_type = parts[1] + delta = data.get("delta", "") + + output_index = data.get("output_index", len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + item_type = item.get("type", "") + + # Determine target field and object based on delta_type and item_type + if delta_type == "function_call_arguments": + key = "arguments" + if item_type == "function_call": + # Function call args are usually strings + item[key] = item.get(key, "") + str(delta) + else: + # Generic handling, refined by item type below + pass + + if item_type == "message": + # Message items: "text"/"output_text" -> "text" + # "reasoning_text" -> Skipped (should use reasoning item) + if delta_type in ["text", "output_text"]: + key = "text" + elif delta_type in ["reasoning_text", "reasoning_summary_text"]: + # Skip reasoning updates for message items + return new_output, None + else: + key = delta_type + + content_index = data.get("content_index", 0) + if "content" not in item: + item["content"] = [] + else: + item["content"] = list(item["content"]) + content_list = item["content"] + + while len(content_list) <= content_index: + content_list.append({"type": "text", "text": ""}) + + # Copy the part to mutate it + part = content_list[content_index].copy() + content_list[content_index] = part + + current_val = part.get(key) + if current_val is None: + # Initialize based on delta type + current_val = {} if isinstance(delta, dict) else "" + + part[key] = deep_merge(current_val, delta) + + elif item_type == "reasoning": + # Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text" + # "text"/"output_text" -> Skipped (should use message item) + if delta_type == "reasoning_summary_text": + # Summary updates -> item['summary'] + key = "text" + summary_index = data.get("summary_index", 0) + if "summary" not in item: + item["summary"] = [] + else: + item["summary"] = list(item["summary"]) + summary_list = item["summary"] + + while len(summary_list) <= summary_index: + summary_list.append( + {"type": "summary_text", "text": ""} + ) + + part = summary_list[summary_index].copy() + summary_list[summary_index] = part + + target_val = part.get(key, "") + part[key] = deep_merge(target_val, delta) + + elif delta_type == "reasoning_text": + # Reasoning body updates -> item['content'] + key = "text" + content_index = data.get("content_index", 0) + if "content" not in item: + item["content"] = [] + else: + item["content"] = list(item["content"]) + content_list = item["content"] + + while len(content_list) <= content_index: + # Reasoning content parts default to text + content_list.append({"type": "text", "text": ""}) + + part = content_list[content_index].copy() + content_list[content_index] = part + + target_val = part.get(key, "") + part[key] = deep_merge(target_val, delta) + + elif delta_type in ["text", "output_text"]: + return new_output, None + else: + # Fallback just in case other deltas target reasoning? + pass + + else: + # Fallback for other item types + if delta_type in ["text", "output_text"]: + key = "text" + else: + key = delta_type + + current_val = item.get(key) + if current_val is None: + current_val = {} if isinstance(delta, dict) else "" + item[key] = deep_merge(current_val, delta) + + return new_output, None + + elif event_type.startswith("response.") and event_type.endswith(".done"): + # Delta Events: response.content_part.done, response.text.done, etc. + parts = event_type.split(".") + if len(parts) >= 3: + type_name = parts[1] + + # 1. Handle specific Delta "done" signals + if type_name == "content_part": + # "Signaling that no further changes will occur to a content part" + # If payloads contains the full part, we could update it. + # Usually purely signaling in standard implementation, but we check payload. + part = data.get("part") + output_index = data.get("output_index", len(current_output) - 1) + + if part and current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if "content" in item: + item["content"] = list(item["content"]) + content_index = data.get( + "content_index", len(item["content"]) - 1 + ) + if 0 <= content_index < len(item["content"]): + item["content"][content_index] = part + return new_output, {} + return current_output, None + + elif type_name == "reasoning_summary_part": + part = data.get("part") + output_index = data.get("output_index", len(current_output) - 1) + + if part and current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if "summary" in item: + item["summary"] = list(item["summary"]) + summary_index = data.get( + "summary_index", len(item["summary"]) - 1 + ) + if 0 <= summary_index < len(item["summary"]): + item["summary"][summary_index] = part + return new_output, {} + return current_output, None + + # 2. Skip Output Item done (handled specifically below) + if type_name == "output_item": + pass + + # 3. Generic Field Done (text.done, audio.done) + elif type_name not in ["completed", "failed"]: + output_index = data.get("output_index", len(current_output) - 1) + if current_output and 0 <= output_index < len(current_output): + + key = ( + "text" + if type_name + in [ + "text", + "output_text", + "reasoning_text", + "reasoning_summary_text", + ] + else type_name + ) + if type_name == "function_call_arguments": + key = "arguments" + + if key in data: + final_value = data[key] + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + item_type = item.get("type", "") + + if type_name == "function_call_arguments": + if item_type == "function_call": + item["arguments"] = final_value + elif item_type == "message": + content_index = data.get("content_index", 0) + if "content" in item: + item["content"] = list(item["content"]) + if len(item["content"]) > content_index: + part = item["content"][content_index].copy() + item["content"][content_index] = part + part[key] = final_value + elif item_type == "reasoning": + item["status"] = "completed" + else: + item[key] = final_value + + return new_output, {} + + return current_output, None + + elif event_type == "response.output_item.done": + # Delta Event: Output item complete + item = data.get("item") + output_index = data.get("output_index", len(current_output) - 1) + + new_output = list(current_output) + if item and 0 <= output_index < len(current_output): + new_output[output_index] = item + elif item: + new_output.append(item) + return new_output, {} + + elif event_type == "response.completed": + # State Machine Event: Completed + response_data = data.get("response", {}) + final_output = response_data.get("output") + + new_output = final_output if final_output is not None else current_output + + # Ensure reasoning items are marked as completed in the final output + if new_output: + for item in new_output: + if ( + item.get("type") == "reasoning" + and item.get("status") != "completed" + ): + item["status"] = "completed" + + return new_output, {"usage": response_data.get("usage"), "done": True} + + elif event_type == "response.in_progress": + # State Machine Event: In Progress + # We could extract metadata if needed, but for now just acknowledge iteration + return current_output, None + + elif event_type == "response.failed": + # State Machine Event: Failed + error = data.get("response", {}).get("error", {}) + return current_output, {"error": error} + + else: + return current_output, None + + def apply_source_context_to_messages( request: Request, messages: list, @@ -1570,7 +2041,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): raise e try: - filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", [])) + filter_ids = get_sorted_filter_ids( + request, model, metadata.get("filter_ids", []) + ) filter_functions = Functions.get_functions_by_ids(filter_ids) form_data, flags = await process_filter_functions( @@ -2367,20 +2840,6 @@ async def process_chat_response( task_id = str(uuid4()) # Create a unique task ID. model_id = form_data.get("model", "") - def split_content_and_whitespace(content): - content_stripped = content.rstrip() - original_whitespace = ( - content[len(content_stripped) :] - if len(content) > len(content_stripped) - else "" - ) - return content_stripped, original_whitespace - - def is_opening_code_block(content): - backtick_segments = content.split("```") - # Even number of segments means the last backticks are opening a new block - return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 - # Handle as a background task async def response_handler(response, events): def serialize_content_blocks(content_blocks, raw=False): @@ -2518,105 +2977,6 @@ async def process_chat_response( return content.strip() - def serialize_output(output: list) -> str: - """ - Convert OR-aligned output items to HTML for display. - For LLM consumption, use convert_output_to_messages() instead. - """ - content = "" - - # First pass: collect function_call_output items by call_id for lookup - tool_outputs = {} - for item in output: - if item.get("type") == "function_call_output": - tool_outputs[item.get("call_id")] = item - - # Second pass: render items in order - for item in output: - item_type = item.get("type", "") - - if item_type == "message": - for content_part in item.get("content", []): - if content_part.get("type") == "output_text": - text = content_part.get("text", "").strip() - if text: - content = f"{content}{text}\n" - - elif item_type == "function_call": - # Render tool call inline with its result (if available) - if content and not content.endswith("\n"): - content += "\n" - - call_id = item.get("call_id", "") - name = item.get("name", "") - arguments = item.get("arguments", "") - - result_item = tool_outputs.get(call_id) - if result_item: - result_text = "" - for out in result_item.get("output", []): - if out.get("type") == "input_text": - result_text += out.get("text", "") - files = result_item.get("files") - embeds = result_item.get("embeds", "") - - content += f'
\nTool Executed\n
\n' - else: - content += f'
\nExecuting...\n
\n' - - elif item_type == "function_call_output": - # Already handled inline with function_call above - pass - - elif item_type == "reasoning": - reasoning_content = "" - for content_part in item.get("content", []): - if content_part.get("type") == "output_text": - reasoning_content = content_part.get("text", "").strip() - - duration = item.get("duration") - status = item.get("status", "in_progress") - - if content and not content.endswith("\n"): - content += "\n" - - display = html.escape( - "\n".join( - (f"> {line}" if not line.startswith(">") else line) - for line in reasoning_content.splitlines() - ) - ) - - if status == "completed" or duration is not None: - content = f'{content}
\nThought for {duration or 0} seconds\n{display}\n
\n' - else: - content = f'{content}
\nThinking…\n{display}\n
\n' - - elif item_type == "open_webui:code_interpreter": - code = item.get("code", "") - output_val = item.get("output") - lang = item.get("lang", "") - - content_stripped, original_whitespace = ( - split_content_and_whitespace(content) - ) - if is_opening_code_block(content_stripped): - content = ( - content_stripped.rstrip("`").rstrip() - + original_whitespace - ) - else: - content = content_stripped + original_whitespace - - if content and not content.endswith("\n"): - content += "\n" - - if output_val: - output_escaped = html.escape(json.dumps(output_val)) - content = f'{content}
\nAnalyzed\n```{lang}\n{code}\n```\n
\n' - else: - content = f'{content}
\nAnalyzing...\n```{lang}\n{code}\n```\n
\n' - return content.strip() def convert_content_blocks_to_messages(content_blocks, raw=False): @@ -2982,16 +3342,19 @@ async def process_chat_response( if existing_output: output = existing_output else: - # Always create an initial message item (even if content is empty) - output = [ - { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [{"type": "output_text", "text": content}], - } - ] + # Only create an initial message item if there is content to initialize with + if content: + output = [ + { + "type": "message", + "id": output_id("msg"), + "status": "in_progress", + "role": "assistant", + "content": [{"type": "output_text", "text": content}], + } + ] + else: + output = [] # Keep content_blocks for backward compatibility during transition content_blocks = [ @@ -3000,6 +3363,7 @@ async def process_chat_response( "content": content, } ] + usage = None reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags") DETECT_REASONING_TAGS = reasoning_tags_param is not False @@ -3040,6 +3404,8 @@ async def process_chat_response( async def stream_body_handler(response, form_data): nonlocal content nonlocal content_blocks + nonlocal usage + nonlocal output response_tool_calls = [] @@ -3118,13 +3484,39 @@ async def process_chat_response( "data": data, } ) + # Check for Responses API events (type field starts with "response.") + elif data.get("type", "").startswith("response."): + output, response_metadata = ( + handle_responses_streaming_event(data, output) + ) + + processed_data = { + "output": output, + "content": serialize_output(output), + } + + # print(data) + # print(processed_data) + + # Merge any metadata (usage, done, etc.) + if response_metadata: + processed_data.update(response_metadata) + + await event_emitter( + { + "type": "chat:completion", + "data": processed_data, + } + ) + continue else: choices = data.get("choices", []) - # 17421 - usage = data.get("usage", {}) or {} - usage.update(data.get("timings", {})) # llama.cpp - if usage: + # Normalize usage data to standard format + raw_usage = data.get("usage", {}) or {} + raw_usage.update(data.get("timings", {})) # llama.cpp + if raw_usage: + usage = normalize_usage(raw_usage) await event_emitter( { "type": "chat:completion", @@ -3922,8 +4314,15 @@ async def process_chat_response( { "content": serialize_output(output), "output": output, + **({"usage": usage} if usage else {}), }, ) + elif usage: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + {"usage": usage}, + ) # Send a webhook notification if the user is not active if not Users.is_user_active(user.id): diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 2a54b9af62..52539860aa 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -6,6 +6,47 @@ from open_webui.utils.misc import ( ) +def normalize_usage(usage: dict) -> dict: + """ + Normalize usage statistics to standard format. + Handles OpenAI, Ollama, and llama.cpp formats. + + Adds standardized token fields to the original data: + - input_tokens: Number of tokens in the prompt + - output_tokens: Number of tokens generated + - total_tokens: Sum of input and output tokens + """ + if not usage: + return {} + + # Map various field names to standard names + input_tokens = ( + usage.get("input_tokens") # Already standard + or usage.get("prompt_tokens") # OpenAI + or usage.get("prompt_eval_count") # Ollama + or usage.get("prompt_n") # llama.cpp + or 0 + ) + + output_tokens = ( + usage.get("output_tokens") # Already standard + or usage.get("completion_tokens") # OpenAI + or usage.get("eval_count") # Ollama + or usage.get("predicted_n") # llama.cpp + or 0 + ) + + total_tokens = usage.get("total_tokens") or (input_tokens + output_tokens) + + # Add standardized fields to original data + result = dict(usage) + result["input_tokens"] = int(input_tokens) + result["output_tokens"] = int(output_tokens) + result["total_tokens"] = int(total_tokens) + + return result + + def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: openai_tool_calls = [] for tool_call in tool_calls: @@ -24,7 +65,19 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: def convert_ollama_usage_to_openai(data: dict) -> dict: + input_tokens = int(data.get("prompt_eval_count", 0)) + output_tokens = int(data.get("eval_count", 0)) + total_tokens = input_tokens + output_tokens + return { + # Standardized fields + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + # OpenAI-compatible fields (for backward compatibility) + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + # Ollama-specific metrics "response_token/s": ( round( ( @@ -56,22 +109,13 @@ def convert_ollama_usage_to_openai(data: dict) -> dict: "total_duration": data.get("total_duration", 0), "load_duration": data.get("load_duration", 0), "prompt_eval_count": data.get("prompt_eval_count", 0), - "prompt_tokens": int( - data.get("prompt_eval_count", 0) - ), # This is the OpenAI compatible key "prompt_eval_duration": data.get("prompt_eval_duration", 0), "eval_count": data.get("eval_count", 0), - "completion_tokens": int( - data.get("eval_count", 0) - ), # This is the OpenAI compatible key "eval_duration": data.get("eval_duration", 0), "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( (data.get("total_duration", 0) or 0) // 1_000_000_000 ), - "total_tokens": int( # This is the OpenAI compatible key - data.get("prompt_eval_count", 0) + data.get("eval_count", 0) - ), - "completion_tokens_details": { # This is the OpenAI compatible key + "completion_tokens_details": { "reasoning_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0, diff --git a/pyproject.toml b/pyproject.toml index 1f46eaa068..3f07770850 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "authlib==1.6.6", "requests==2.32.5", - "aiohttp==3.13.2" # do not update to 3.13.3 - broken, + "aiohttp==3.13.2", # do not update to 3.13.3 - broken "async-timeout", "aiocache", "aiofiles", diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts new file mode 100644 index 0000000000..19ef2a3880 --- /dev/null +++ b/src/lib/apis/analytics/index.ts @@ -0,0 +1,231 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const getModelAnalytics = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getUserAnalytics = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + limit: number = 50 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (limit) searchParams.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getMessages = async ( + token: string = '', + modelId: string | null = null, + userId: string | null = null, + chatId: string | null = null, + startDate: number | null = null, + endDate: number | null = null, + skip: number = 0, + limit: number = 50 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (modelId) searchParams.append('model_id', modelId); + if (userId) searchParams.append('user_id', userId); + if (chatId) searchParams.append('chat_id', chatId); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (skip) searchParams.append('skip', skip.toString()); + if (limit) searchParams.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSummary = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getDailyStats = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + granularity: 'hourly' | 'daily' = 'daily' +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + searchParams.append('granularity', granularity); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getTokenUsage = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 557549098c..a455627e11 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -42,6 +42,7 @@ let prefixId = ''; let enable = true; let apiVersion = ''; + let apiType = ''; // '' = chat completions (default), 'responses' = Responses API let headers = ''; @@ -183,7 +184,8 @@ connection_type: connectionType, auth_type, headers: headers ? JSON.parse(headers) : undefined, - ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}) + ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}), + ...(apiType ? { api_type: apiType } : {}) } }; @@ -221,6 +223,7 @@ connectionType = connection.config?.connection_type ?? 'external'; azure = connection.config?.azure ?? false; apiVersion = connection.config?.api_version ?? ''; + apiType = connection.config?.api_type ?? ''; } } }; @@ -506,7 +509,7 @@
{/if} + {#if !ollama && !direct} +
+ + +
+ +
+
+ {/if} +
+ import { onMount, getContext } from 'svelte'; + import { goto } from '$app/navigation'; + import { user } from '$lib/stores'; + + import Dashboard from './Analytics/Dashboard.svelte'; + + const i18n = getContext('i18n'); + + let loaded = false; + + onMount(async () => { + if ($user?.role !== 'admin') { + await goto('/'); + } + loaded = true; + }); + + +{#if loaded} +
+ +
+{/if} diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte new file mode 100644 index 0000000000..5c3ffabdf3 --- /dev/null +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -0,0 +1,129 @@ + + +
+ (hoveredIdx = null)} + > + {#each models as m} + + {/each} + {#if hoveredIdx !== null} + + {#each models as m} + {@const v = hovered?.models?.[m] || 0} + {#if v > 0} + + {/if} + {/each} + {/if} + + + {#if data.length > 1} + {@const labelCount = Math.min(7, data.length)} + {@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1} + {@const isHourly = data[0]?.date?.includes(':')} + {@const dateFormat = isHourly ? 'h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'} +
+ {#each Array(labelCount) as _, i} + {@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)} + {#if data[idx]} + {dayjs(data[idx].date).format(dateFormat)} + {/if} + {/each} +
+ {/if} + {#if hovered} + {@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)} +
+
+
+ {#if hovered.date?.includes(':')} + {dayjs(hovered.date).format('MMM D, h A')} + {:else} + {dayjs(hovered.date).format('MMM D, YYYY')} + {/if} +
+ {#each Object.entries(hovered.models || {}) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) as [n, c]} +
+ {n} + {c.toLocaleString()} + ({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%) +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte new file mode 100644 index 0000000000..82eb5b5819 --- /dev/null +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -0,0 +1,346 @@ + + + +
+
+ {$i18n.t('Analytics')} +
+ +
+ + +{#if !loading} +
+ {summary.total_messages.toLocaleString()} {$i18n.t('messages')} + {formatNumber(totalTokens.total)} {$i18n.t('tokens')} + {summary.total_chats.toLocaleString()} {$i18n.t('chats')} + {summary.total_users} {$i18n.t('users')} +
+ + + {#if dailyStats.length > 1} + {@const allModels = [...new Set(dailyStats.flatMap(d => Object.keys(d.models || {})))]} + {@const topModels = allModels.slice(0, 8)} + {@const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']} + {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', 'all': 'all' }} +
+
+ {$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')} +
+ +
+ {/if} +{/if} + +{#if loading} +
+ +
+{:else} +
+ +
+
+ {$i18n.t('Model Usage')} +
+
+ + + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + + {/each} + {#if sortedModels.length === 0} + + {/if} + +
# toggleModelSort('name')} + > +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('count')} + > +
+ {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('Tokens')}%
{idx + 1} +
+ {model.name} + {model.name} +
+
{model.count.toLocaleString()}{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)} + {totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}% +
{$i18n.t('No data')}
+
+
+ + +
+
+ {$i18n.t('User Activity')} +
+
+ + + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + + {/each} + {#if sortedUsers.length === 0} + + {/if} + +
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('Tokens')}
{idx + 1} +
+ {user.name + {user.name || user.email || user.user_id.substring(0, 8)} +
+
{user.count.toLocaleString()}{formatNumber(user.total_tokens ?? 0)}
{$i18n.t('No data')}
+
+
+
+ +
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+{/if} diff --git a/src/lib/components/admin/Analytics/ModelUsage.svelte b/src/lib/components/admin/Analytics/ModelUsage.svelte new file mode 100644 index 0000000000..d7212f721a --- /dev/null +++ b/src/lib/components/admin/Analytics/ModelUsage.svelte @@ -0,0 +1,141 @@ + + +
+
+ {$i18n.t('Model Usage')} + {totalMessages} {$i18n.t('messages')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !modelStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if modelStats.length} + + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + {/each} + +
# toggleSort('name')} + > +
+ {$i18n.t('Model')} + {#if orderBy === 'name'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + +
+ {model.name} + {model.name} +
+
+ {model.count.toLocaleString()} + + {((model.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+
diff --git a/src/lib/components/admin/Analytics/UserUsage.svelte b/src/lib/components/admin/Analytics/UserUsage.svelte new file mode 100644 index 0000000000..015ba195d1 --- /dev/null +++ b/src/lib/components/admin/Analytics/UserUsage.svelte @@ -0,0 +1,129 @@ + + +
+
+ {$i18n.t('User Activity')} + {userStats.length} {$i18n.t('users')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !userStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if userStats.length} + + + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + + {/each} + +
# toggleSort('user_id')} + > +
+ {$i18n.t('User')} + {#if orderBy === 'user_id'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + + + {user.user_id.substring(0, 8)}... + + + {user.count.toLocaleString()} + + {((user.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')} +
+
diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index d22cd2e7dc..dcb403e279 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -54,7 +54,9 @@ console.log('Exporting table to CSV'); // Extract header row text, decode HTML entities, and escape for CSV. - const header = token.header.map((headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`); + const header = token.header.map( + (headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"` + ); // Create an array for rows that will hold the mapped cell text. const rows = token.rows.map((row) => @@ -316,26 +318,41 @@ {/if} {:else if token.type === 'details'} - -
- -
-
+ {@const textContent = decode(token.text || '') + .replace(/.*?<\/summary>/gi, '') + .trim()} + + {#if textContent.length > 0} + +
+ +
+
+ {:else} + + {/if} {:else if token.type === 'html'} {:else if token.type === 'iframe'} diff --git a/src/lib/components/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index 69262328b8..efaa83f385 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -222,7 +222,7 @@
{ if (!disabled) { open = !open; @@ -270,13 +270,15 @@ {/if}
-
- {#if open} - - {:else} - - {/if} -
+ {#if !disabled} +
+ {#if open} + + {:else} + + {/if} +
+ {/if}
{:else} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 96f290a2b7..3577eb0045 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -28,6 +28,10 @@ import hljs from 'highlight.js'; export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const formatNumber = (num: number): string => { + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(num); +}; + function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/src/routes/(app)/admin/+layout.svelte b/src/routes/(app)/admin/+layout.svelte index ec3d931b41..6577cdabef 100644 --- a/src/routes/(app)/admin/+layout.svelte +++ b/src/routes/(app)/admin/+layout.svelte @@ -66,12 +66,12 @@ href="/admin">{$i18n.t('Users')} - + >