refac: shared chat
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
"""Add shared_chat table and migrate existing shares
|
||||||
|
|
||||||
|
Revision ID: c1d2e3f4a5b6
|
||||||
|
Revises: e1f2a3b4c5d6
|
||||||
|
Create Date: 2026-04-16 23:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'c1d2e3f4a5b6'
|
||||||
|
down_revision = 'e1f2a3b4c5d6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Lightweight table references for data migration (no ORM models needed)
|
||||||
|
chat_t = sa.table(
|
||||||
|
'chat',
|
||||||
|
sa.column('id', sa.Text),
|
||||||
|
sa.column('user_id', sa.Text),
|
||||||
|
sa.column('title', sa.Text),
|
||||||
|
sa.column('chat', sa.JSON),
|
||||||
|
sa.column('share_id', sa.Text),
|
||||||
|
sa.column('created_at', sa.BigInteger),
|
||||||
|
sa.column('updated_at', sa.BigInteger),
|
||||||
|
sa.column('archived', sa.Boolean),
|
||||||
|
sa.column('meta', sa.JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_chat_t = sa.table(
|
||||||
|
'shared_chat',
|
||||||
|
sa.column('id', sa.Text),
|
||||||
|
sa.column('chat_id', sa.Text),
|
||||||
|
sa.column('user_id', sa.Text),
|
||||||
|
sa.column('title', sa.Text),
|
||||||
|
sa.column('chat', sa.JSON),
|
||||||
|
sa.column('created_at', sa.BigInteger),
|
||||||
|
sa.column('updated_at', sa.BigInteger),
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_message_t = sa.table(
|
||||||
|
'chat_message',
|
||||||
|
sa.column('chat_id', sa.Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
access_grant_t = sa.table(
|
||||||
|
'access_grant',
|
||||||
|
sa.column('id', sa.Text),
|
||||||
|
sa.column('resource_type', sa.Text),
|
||||||
|
sa.column('resource_id', sa.Text),
|
||||||
|
sa.column('principal_type', sa.Text),
|
||||||
|
sa.column('principal_id', sa.Text),
|
||||||
|
sa.column('permission', sa.Text),
|
||||||
|
sa.column('created_at', sa.BigInteger),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Create shared_chat table
|
||||||
|
op.create_table(
|
||||||
|
'shared_chat',
|
||||||
|
sa.Column('id', sa.Text(), primary_key=True),
|
||||||
|
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('title', sa.Text(), nullable=True),
|
||||||
|
sa.Column('chat', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Migrate existing shared-* rows
|
||||||
|
shared_rows = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
chat_t.c.id,
|
||||||
|
chat_t.c.user_id,
|
||||||
|
chat_t.c.title,
|
||||||
|
chat_t.c.chat,
|
||||||
|
chat_t.c.created_at,
|
||||||
|
chat_t.c.updated_at,
|
||||||
|
).where(chat_t.c.user_id.like('shared-%'))
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in shared_rows:
|
||||||
|
share_token = row.id
|
||||||
|
original_chat_id = row.user_id.replace('shared-', '', 1)
|
||||||
|
|
||||||
|
# Verify original chat still exists
|
||||||
|
original = conn.execute(
|
||||||
|
sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not original:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert snapshot into shared_chat
|
||||||
|
conn.execute(shared_chat_t.insert().values(
|
||||||
|
id=share_token,
|
||||||
|
chat_id=original_chat_id,
|
||||||
|
user_id=original.user_id,
|
||||||
|
title=row.title,
|
||||||
|
chat=row.chat,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create user:*:read grant for backward compat
|
||||||
|
conn.execute(access_grant_t.insert().values(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
resource_type='shared_chat',
|
||||||
|
resource_id=original_chat_id,
|
||||||
|
principal_type='user',
|
||||||
|
principal_id='*',
|
||||||
|
permission='read',
|
||||||
|
created_at=row.created_at or int(time.time()),
|
||||||
|
))
|
||||||
|
|
||||||
|
# 3. Clean up old phantom rows
|
||||||
|
conn.execute(
|
||||||
|
chat_message_t.delete().where(
|
||||||
|
chat_message_t.c.chat_id.in_(
|
||||||
|
sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
shared_rows = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
shared_chat_t.c.id,
|
||||||
|
shared_chat_t.c.chat_id,
|
||||||
|
shared_chat_t.c.user_id,
|
||||||
|
shared_chat_t.c.title,
|
||||||
|
shared_chat_t.c.chat,
|
||||||
|
shared_chat_t.c.created_at,
|
||||||
|
shared_chat_t.c.updated_at,
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in shared_rows:
|
||||||
|
conn.execute(chat_t.insert().values(
|
||||||
|
id=row.id,
|
||||||
|
user_id=f'shared-{row.chat_id}',
|
||||||
|
title=row.title,
|
||||||
|
chat=row.chat,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
archived=False,
|
||||||
|
meta={},
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat')
|
||||||
|
)
|
||||||
|
op.drop_table('shared_chat')
|
||||||
@@ -555,77 +555,51 @@ class ChatTable:
|
|||||||
async def insert_shared_chat_by_chat_id(
|
async def insert_shared_chat_by_chat_id(
|
||||||
self, chat_id: str, db: Optional[AsyncSession] = None
|
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||||
) -> Optional[ChatModel]:
|
) -> Optional[ChatModel]:
|
||||||
|
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
|
||||||
|
from open_webui.models.shared_chats import SharedChats
|
||||||
|
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
# Get the existing chat to share
|
|
||||||
chat = await db.get(Chat, chat_id)
|
chat = await db.get(Chat, chat_id)
|
||||||
# Check if chat exists
|
|
||||||
if not chat:
|
if not chat:
|
||||||
return None
|
return None
|
||||||
# Check if the chat is already shared
|
|
||||||
if chat.share_id:
|
|
||||||
return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db)
|
|
||||||
# Create a new chat with the same data, but with a new ID
|
|
||||||
shared_chat = ChatModel(
|
|
||||||
**{
|
|
||||||
'id': str(uuid.uuid4()),
|
|
||||||
'user_id': f'shared-{chat_id}',
|
|
||||||
'title': chat.title,
|
|
||||||
'chat': chat.chat,
|
|
||||||
'meta': chat.meta,
|
|
||||||
'pinned': chat.pinned,
|
|
||||||
'folder_id': chat.folder_id,
|
|
||||||
'created_at': chat.created_at,
|
|
||||||
'updated_at': int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
shared_result = Chat(**shared_chat.model_dump())
|
|
||||||
db.add(shared_result)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(shared_result)
|
|
||||||
|
|
||||||
# Update the original chat with the share_id
|
# If already shared, just update the existing snapshot
|
||||||
await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id))
|
if chat.share_id:
|
||||||
|
return await self.update_shared_chat_by_chat_id(chat_id, db=db)
|
||||||
|
|
||||||
|
shared = await SharedChats.create(chat_id, chat.user_id, db=db)
|
||||||
|
if not shared:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Set share_id on the original chat
|
||||||
|
chat.share_id = shared.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return shared_chat if shared_result else None
|
await db.refresh(chat)
|
||||||
|
return ChatModel.model_validate(chat)
|
||||||
|
|
||||||
async def update_shared_chat_by_chat_id(
|
async def update_shared_chat_by_chat_id(
|
||||||
self, chat_id: str, db: Optional[AsyncSession] = None
|
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||||
) -> Optional[ChatModel]:
|
) -> Optional[ChatModel]:
|
||||||
|
"""Re-snapshot the shared chat with current chat data."""
|
||||||
|
from open_webui.models.shared_chats import SharedChats
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
chat = await db.get(Chat, chat_id)
|
chat = await db.get(Chat, chat_id)
|
||||||
result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}'))
|
if not chat or not chat.share_id:
|
||||||
shared_chat = result.scalars().first()
|
|
||||||
|
|
||||||
if shared_chat is None:
|
|
||||||
return await self.insert_shared_chat_by_chat_id(chat_id, db=db)
|
return await self.insert_shared_chat_by_chat_id(chat_id, db=db)
|
||||||
|
|
||||||
shared_chat.title = chat.title
|
await SharedChats.update(chat.share_id, db=db)
|
||||||
shared_chat.chat = chat.chat
|
return ChatModel.model_validate(chat)
|
||||||
shared_chat.meta = chat.meta
|
|
||||||
shared_chat.pinned = chat.pinned
|
|
||||||
shared_chat.folder_id = chat.folder_id
|
|
||||||
shared_chat.updated_at = int(time.time())
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(shared_chat)
|
|
||||||
|
|
||||||
return ChatModel.model_validate(shared_chat)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
|
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Delete shared snapshot for a chat."""
|
||||||
|
from open_webui.models.shared_chats import SharedChats
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with get_async_db_context(db) as db:
|
return await SharedChats.delete_by_chat_id(chat_id, db=db)
|
||||||
# Get shared chat IDs
|
|
||||||
result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}'))
|
|
||||||
shared_ids = [row[0] for row in result.all()]
|
|
||||||
|
|
||||||
if shared_ids:
|
|
||||||
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
|
|
||||||
await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}'))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -746,53 +720,12 @@ class ChatTable:
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Optional[AsyncSession] = None,
|
db: Optional[AsyncSession] = None,
|
||||||
) -> list[SharedChatResponse]:
|
) -> list[SharedChatResponse]:
|
||||||
async with get_async_db_context(db) as db:
|
"""Delegate to SharedChats for listing shared chats by user."""
|
||||||
stmt = (
|
from open_webui.models.shared_chats import SharedChats
|
||||||
select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at)
|
|
||||||
.filter_by(user_id=user_id)
|
|
||||||
.filter(Chat.share_id.isnot(None))
|
|
||||||
)
|
|
||||||
|
|
||||||
if filter:
|
return await SharedChats.get_by_user_id(
|
||||||
query_key = filter.get('query')
|
user_id, filter=filter, skip=skip, limit=limit, db=db
|
||||||
if query_key:
|
)
|
||||||
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
|
|
||||||
|
|
||||||
order_by = filter.get('order_by')
|
|
||||||
direction = filter.get('direction')
|
|
||||||
|
|
||||||
if order_by and direction:
|
|
||||||
if not getattr(Chat, order_by, None):
|
|
||||||
raise ValueError('Invalid order_by field')
|
|
||||||
|
|
||||||
if direction.lower() == 'asc':
|
|
||||||
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
|
||||||
elif direction.lower() == 'desc':
|
|
||||||
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
|
||||||
else:
|
|
||||||
raise ValueError('Invalid direction for ordering')
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
|
||||||
|
|
||||||
if skip:
|
|
||||||
stmt = stmt.offset(skip)
|
|
||||||
if limit:
|
|
||||||
stmt = stmt.limit(limit)
|
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
all_chats = result.all()
|
|
||||||
return [
|
|
||||||
SharedChatResponse.model_validate(
|
|
||||||
{
|
|
||||||
'id': chat[0],
|
|
||||||
'title': chat[1],
|
|
||||||
'share_id': chat[2],
|
|
||||||
'updated_at': chat[3],
|
|
||||||
'created_at': chat[4],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for chat in all_chats
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_chat_list_by_user_id(
|
async def get_chat_list_by_user_id(
|
||||||
self,
|
self,
|
||||||
@@ -925,15 +858,23 @@ class ChatTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]:
|
async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]:
|
||||||
try:
|
"""Look up a shared chat snapshot by its share token."""
|
||||||
async with get_async_db_context(db) as db:
|
from open_webui.models.shared_chats import SharedChats
|
||||||
result = await db.execute(select(Chat).filter_by(share_id=id))
|
|
||||||
chat = result.scalars().first()
|
|
||||||
|
|
||||||
if chat:
|
try:
|
||||||
return await self.get_chat_by_id(id, db=db)
|
shared = await SharedChats.get_by_id(id, db=db)
|
||||||
else:
|
if shared:
|
||||||
return None
|
# Return a ChatModel-compatible view of the snapshot
|
||||||
|
return ChatModel(
|
||||||
|
id=shared.id,
|
||||||
|
user_id=shared.user_id,
|
||||||
|
title=shared.title,
|
||||||
|
chat=shared.chat,
|
||||||
|
created_at=shared.created_at,
|
||||||
|
updated_at=shared.updated_at,
|
||||||
|
share_id=shared.id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1568,20 +1509,17 @@ class ChatTable:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
|
async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Delete all shared chat snapshots created by a user."""
|
||||||
|
from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
result = await db.execute(select(Chat.id).filter_by(user_id=user_id))
|
# Delete shared_chat rows for this user's chats
|
||||||
id_rows = result.all()
|
await db.execute(delete(SharedChatTable).filter_by(user_id=user_id))
|
||||||
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
|
|
||||||
|
|
||||||
if shared_chat_ids:
|
# Clear share_id on all of this user's chats
|
||||||
# Get shared chat IDs to delete associated messages
|
await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
|
||||||
shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)))
|
await db.commit()
|
||||||
shared_ids = [row[0] for row in shared_result.all()]
|
|
||||||
if shared_ids:
|
|
||||||
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
|
|
||||||
await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids)))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1651,16 +1589,15 @@ class ChatTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]:
|
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||||
|
"""Return IDs of chats that contain this file and have an active share link."""
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Chat)
|
select(Chat.id)
|
||||||
.join(ChatFile, Chat.id == ChatFile.chat_id)
|
.join(ChatFile, Chat.id == ChatFile.chat_id)
|
||||||
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
|
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
|
||||||
)
|
)
|
||||||
all_chats = result.scalars().all()
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
|
||||||
|
|
||||||
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
|
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
|
||||||
"""Update the tasks list on a chat."""
|
"""Update the tasks list on a chat."""
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# SharedChat DB Schema
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class SharedChat(Base):
|
||||||
|
__tablename__ = 'shared_chat'
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL
|
||||||
|
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
user_id = Column(Text, nullable=False) # Who created this share
|
||||||
|
|
||||||
|
title = Column(Text)
|
||||||
|
chat = Column(JSON) # Snapshot of chat JSON at share time
|
||||||
|
|
||||||
|
created_at = Column(BigInteger)
|
||||||
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class SharedChatModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
chat_id: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
title: str
|
||||||
|
chat: dict
|
||||||
|
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SharedChatResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
chat_id: str
|
||||||
|
title: str
|
||||||
|
share_id: Optional[str] = None # Alias for id, for backward compat
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Table Operations
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class SharedChatsTable:
|
||||||
|
async def create(
|
||||||
|
self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> Optional[SharedChatModel]:
|
||||||
|
"""
|
||||||
|
Create a snapshot of the chat for link sharing.
|
||||||
|
Returns the SharedChatModel with the share token as its id.
|
||||||
|
"""
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
from open_webui.models.chats import Chat
|
||||||
|
|
||||||
|
chat = await db.get(Chat, chat_id)
|
||||||
|
if not chat:
|
||||||
|
return None
|
||||||
|
|
||||||
|
share_id = str(uuid.uuid4())
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
shared_chat = SharedChat(
|
||||||
|
id=share_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=chat.title,
|
||||||
|
chat=chat.chat,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(shared_chat)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(shared_chat)
|
||||||
|
|
||||||
|
return SharedChatModel.model_validate(shared_chat)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, share_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> Optional[SharedChatModel]:
|
||||||
|
"""
|
||||||
|
Re-snapshot: update the shared chat with the current state of the original chat.
|
||||||
|
"""
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
from open_webui.models.chats import Chat
|
||||||
|
|
||||||
|
shared_chat = await db.get(SharedChat, share_id)
|
||||||
|
if not shared_chat:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chat = await db.get(Chat, shared_chat.chat_id)
|
||||||
|
if not chat:
|
||||||
|
return None
|
||||||
|
|
||||||
|
shared_chat.title = chat.title
|
||||||
|
shared_chat.chat = chat.chat
|
||||||
|
shared_chat.updated_at = int(time.time())
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(shared_chat)
|
||||||
|
return SharedChatModel.model_validate(shared_chat)
|
||||||
|
|
||||||
|
async def get_by_id(
|
||||||
|
self, share_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> Optional[SharedChatModel]:
|
||||||
|
"""Get a shared chat by its share token."""
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
shared_chat = await db.get(SharedChat, share_id)
|
||||||
|
if shared_chat:
|
||||||
|
return SharedChatModel.model_validate(shared_chat)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_by_chat_id(
|
||||||
|
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> Optional[SharedChatModel]:
|
||||||
|
"""Get the shared chat for a given original chat. Returns the most recent one."""
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SharedChat)
|
||||||
|
.filter_by(chat_id=chat_id)
|
||||||
|
.order_by(SharedChat.updated_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
shared_chat = result.scalars().first()
|
||||||
|
if shared_chat:
|
||||||
|
return SharedChatModel.model_validate(shared_chat)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_by_user_id(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: Optional[dict] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> list[SharedChatResponse]:
|
||||||
|
"""List all shared chats created by a user."""
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
stmt = select(SharedChat).filter_by(user_id=user_id)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get('query')
|
||||||
|
if query_key:
|
||||||
|
stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%'))
|
||||||
|
|
||||||
|
order_by = filter.get('order_by')
|
||||||
|
direction = filter.get('direction')
|
||||||
|
|
||||||
|
if order_by and direction:
|
||||||
|
col = getattr(SharedChat, order_by, None)
|
||||||
|
if not col:
|
||||||
|
raise ValueError('Invalid order_by field')
|
||||||
|
if direction.lower() == 'asc':
|
||||||
|
stmt = stmt.order_by(col.asc())
|
||||||
|
elif direction.lower() == 'desc':
|
||||||
|
stmt = stmt.order_by(col.desc())
|
||||||
|
else:
|
||||||
|
raise ValueError('Invalid direction for ordering')
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(SharedChat.updated_at.desc())
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
stmt = stmt.offset(skip)
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [
|
||||||
|
SharedChatResponse(
|
||||||
|
id=sc.chat_id,
|
||||||
|
chat_id=sc.chat_id,
|
||||||
|
title=sc.title,
|
||||||
|
share_id=sc.id,
|
||||||
|
updated_at=sc.updated_at,
|
||||||
|
created_at=sc.created_at,
|
||||||
|
)
|
||||||
|
for sc in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def delete_by_id(
|
||||||
|
self, share_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a shared chat by its share token."""
|
||||||
|
try:
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
await db.execute(delete(SharedChat).filter_by(id=share_id))
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_by_chat_id(
|
||||||
|
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete all shared chats for a given original chat."""
|
||||||
|
try:
|
||||||
|
async with get_async_db_context(db) as db:
|
||||||
|
await db.execute(delete(SharedChat).filter_by(chat_id=chat_id))
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
SharedChats = SharedChatsTable()
|
||||||
@@ -17,13 +17,14 @@ from open_webui.models.chats import (
|
|||||||
ChatResponse,
|
ChatResponse,
|
||||||
Chats,
|
Chats,
|
||||||
ChatTitleIdResponse,
|
ChatTitleIdResponse,
|
||||||
SharedChatResponse,
|
|
||||||
ChatStatsExport,
|
ChatStatsExport,
|
||||||
AggregateChatStats,
|
AggregateChatStats,
|
||||||
ChatBody,
|
ChatBody,
|
||||||
ChatHistoryStats,
|
ChatHistoryStats,
|
||||||
MessageStats,
|
MessageStats,
|
||||||
)
|
)
|
||||||
|
from open_webui.models.shared_chats import SharedChats, SharedChatResponse
|
||||||
|
from open_webui.models.access_grants import AccessGrants
|
||||||
from open_webui.models.tags import TagModel, Tags
|
from open_webui.models.tags import TagModel, Tags
|
||||||
from open_webui.models.folders import Folders
|
from open_webui.models.folders import Folders
|
||||||
from open_webui.internal.db import get_async_session
|
from open_webui.internal.db import get_async_session
|
||||||
@@ -35,7 +36,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.access_control import has_permission
|
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -807,7 +808,7 @@ async def get_shared_session_user_chat_list(
|
|||||||
if direction:
|
if direction:
|
||||||
filter['direction'] = direction
|
filter['direction'] = direction
|
||||||
|
|
||||||
return await Chats.get_shared_chat_list_by_user_id(
|
return await SharedChats.get_by_user_id(
|
||||||
user.id,
|
user.id,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
@@ -828,17 +829,32 @@ async def get_shared_chat_by_id(
|
|||||||
if user.role == 'pending':
|
if user.role == 'pending':
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||||
|
|
||||||
if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS):
|
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
|
||||||
elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
|
||||||
chat = await Chats.get_chat_by_id(share_id, db=db)
|
chat = await Chats.get_chat_by_id(share_id, db=db)
|
||||||
|
|
||||||
if chat:
|
|
||||||
return ChatResponse(**chat.model_dump())
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||||
|
|
||||||
|
if not chat:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||||
|
|
||||||
|
# Look up the original chat_id to check access grants
|
||||||
|
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||||
|
if shared:
|
||||||
|
has_grant = await AccessGrants.has_access(
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type='shared_chat',
|
||||||
|
resource_id=shared.chat_id,
|
||||||
|
permission='read',
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
if not has_grant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChatResponse(**chat.model_dump())
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# GetChatsByTags
|
# GetChatsByTags
|
||||||
@@ -878,11 +894,25 @@ async def get_user_chat_list_by_tag_name(
|
|||||||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||||
|
|
||||||
|
if not chat:
|
||||||
|
# Check if user has access via access grants (shared_chat grants)
|
||||||
|
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||||
|
chat = await Chats.get_chat_by_id(id, db=db)
|
||||||
|
else:
|
||||||
|
has_grant = await AccessGrants.has_access(
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type='shared_chat',
|
||||||
|
resource_id=id,
|
||||||
|
permission='read',
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
if has_grant:
|
||||||
|
chat = await Chats.get_chat_by_id(id, db=db)
|
||||||
|
|
||||||
if chat:
|
if chat:
|
||||||
return ChatResponse(**chat.model_dump())
|
return ChatResponse(**chat.model_dump())
|
||||||
|
|
||||||
else:
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
@@ -1158,39 +1188,58 @@ async def clone_shared_chat_by_id(
|
|||||||
else:
|
else:
|
||||||
chat = await Chats.get_chat_by_share_id(id, db=db)
|
chat = await Chats.get_chat_by_share_id(id, db=db)
|
||||||
|
|
||||||
if chat:
|
if not chat:
|
||||||
updated_chat = {
|
raise HTTPException(
|
||||||
**chat.chat,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
'originalChatId': chat.id,
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
'branchPointMessageId': chat.chat['history']['currentId'],
|
|
||||||
'title': f'Clone of {chat.title}',
|
|
||||||
}
|
|
||||||
|
|
||||||
chats = await Chats.import_chats(
|
|
||||||
user.id,
|
|
||||||
[
|
|
||||||
ChatImportForm(
|
|
||||||
**{
|
|
||||||
'chat': updated_chat,
|
|
||||||
'meta': chat.meta,
|
|
||||||
'pinned': chat.pinned,
|
|
||||||
'folder_id': chat.folder_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
],
|
|
||||||
db=db,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if chats:
|
# Enforce access grants
|
||||||
chat = chats[0]
|
shared = await SharedChats.get_by_id(id, db=db)
|
||||||
return ChatResponse(**chat.model_dump())
|
if shared and user.role != 'admin':
|
||||||
else:
|
has_grant = await AccessGrants.has_access(
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type='shared_chat',
|
||||||
|
resource_id=shared.chat_id,
|
||||||
|
permission='read',
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
if not has_grant:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=ERROR_MESSAGES.DEFAULT(),
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
updated_chat = {
|
||||||
|
**chat.chat,
|
||||||
|
'originalChatId': chat.id,
|
||||||
|
'branchPointMessageId': chat.chat['history']['currentId'],
|
||||||
|
'title': f'Clone of {chat.title}',
|
||||||
|
}
|
||||||
|
|
||||||
|
chats = await Chats.import_chats(
|
||||||
|
user.id,
|
||||||
|
[
|
||||||
|
ChatImportForm(
|
||||||
|
**{
|
||||||
|
'chat': updated_chat,
|
||||||
|
'meta': chat.meta,
|
||||||
|
'pinned': chat.pinned,
|
||||||
|
'folder_id': chat.folder_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if chats:
|
||||||
|
chat = chats[0]
|
||||||
|
return ChatResponse(**chat.model_dump())
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=ERROR_MESSAGES.DEFAULT(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
@@ -1241,16 +1290,28 @@ async def share_chat_by_id(
|
|||||||
|
|
||||||
if chat:
|
if chat:
|
||||||
if chat.share_id:
|
if chat.share_id:
|
||||||
shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db)
|
# Re-snapshot existing share
|
||||||
return ChatResponse(**shared_chat.model_dump())
|
shared = await SharedChats.update(chat.share_id, db=db)
|
||||||
|
if shared:
|
||||||
|
# Re-fetch the original chat to return
|
||||||
|
chat = await Chats.get_chat_by_id(id, db=db)
|
||||||
|
return ChatResponse(**chat.model_dump())
|
||||||
|
|
||||||
shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db)
|
# Create new share
|
||||||
if not shared_chat:
|
shared = await SharedChats.create(id, user.id, db=db)
|
||||||
|
if not shared:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=ERROR_MESSAGES.DEFAULT(),
|
detail=ERROR_MESSAGES.DEFAULT(),
|
||||||
)
|
)
|
||||||
return ChatResponse(**shared_chat.model_dump())
|
# Set share_id on the original chat
|
||||||
|
chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=ERROR_MESSAGES.DEFAULT(),
|
||||||
|
)
|
||||||
|
return ChatResponse(**chat.model_dump())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1260,7 +1321,7 @@ async def share_chat_by_id(
|
|||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# DeletedSharedChatById
|
# DeleteSharedChatById
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
@@ -1273,10 +1334,13 @@ async def delete_shared_chat_by_id(
|
|||||||
if not chat.share_id:
|
if not chat.share_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
result = await Chats.delete_shared_chat_by_chat_id(id, db=db)
|
await SharedChats.delete_by_chat_id(id, db=db)
|
||||||
update_result = await Chats.update_chat_share_id_by_id(id, None, db=db)
|
await Chats.update_chat_share_id_by_id(id, None, db=db)
|
||||||
|
|
||||||
return result and update_result != None
|
# Revoke all access grants for this shared chat
|
||||||
|
await AccessGrants.set_access_grants('shared_chat', id, [], db=db)
|
||||||
|
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -1284,6 +1348,85 @@ async def delete_shared_chat_by_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# UpdateSharedChatAccessById
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAccessGrantsForm(BaseModel):
|
||||||
|
access_grants: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse])
|
||||||
|
async def update_shared_chat_access_by_id(
|
||||||
|
request: Request,
|
||||||
|
id: str,
|
||||||
|
form_data: ChatAccessGrantsForm,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
db: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if chat.user_id != user.id and user.role != 'admin':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
form_data.access_grants = await filter_allowed_access_grants(
|
||||||
|
request.app.state.config.USER_PERMISSIONS,
|
||||||
|
user.id,
|
||||||
|
user.role,
|
||||||
|
form_data.access_grants,
|
||||||
|
'sharing.public_chats',
|
||||||
|
)
|
||||||
|
|
||||||
|
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
|
||||||
|
|
||||||
|
return ChatResponse(**chat.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# GetSharedChatAccessById
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/shared/{id}/access', response_model=list)
|
||||||
|
async def get_shared_chat_access_by_id(
|
||||||
|
id: str,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
db: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if chat.user_id != user.id and user.role != 'admin':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': g.id,
|
||||||
|
'principal_type': g.principal_type,
|
||||||
|
'principal_id': g.principal_id,
|
||||||
|
'permission': g.permission,
|
||||||
|
}
|
||||||
|
for g in grants
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# UpdateChatFolderIdById
|
# UpdateChatFolderIdById
|
||||||
############################
|
############################
|
||||||
|
|||||||
@@ -66,10 +66,18 @@ async def has_access_to_file(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if the file is associated with any chats the user has access to
|
# Check if the file is associated with any chats the user has access to
|
||||||
# TODO: Granular access control for chats
|
shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db)
|
||||||
chats = await Chats.get_shared_chats_by_file_id(file_id, db=db)
|
if shared_chat_ids:
|
||||||
if chats:
|
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||||
return True
|
user_id=user.id,
|
||||||
|
resource_type='shared_chat',
|
||||||
|
resource_ids=shared_chat_ids,
|
||||||
|
permission='read',
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
if accessible_ids:
|
||||||
|
return True
|
||||||
|
|
||||||
# Check if the file is directly attached to a shared workspace model
|
# Check if the file is directly attached to a shared workspace model
|
||||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||||
|
|||||||
@@ -953,6 +953,77 @@ export const deleteSharedChatById = async (token: string, id: string) => {
|
|||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateChatAccessGrants = async (
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
accessGrants: object[]
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { authorization: `Bearer ${token}` })
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
access_grants: accessGrants
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err;
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChatAccessGrants = async (token: string, id: string) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { authorization: `Bearer ${token}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err;
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateChatById = async (token: string, id: string, chat: object) => {
|
export const updateChatById = async (token: string, id: string, chat: object) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,32 @@
|
|||||||
import { models, config } from '$lib/stores';
|
import { models, config } from '$lib/stores';
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';
|
import {
|
||||||
|
deleteSharedChatById,
|
||||||
|
getChatById,
|
||||||
|
shareChatById,
|
||||||
|
getChatAccessGrants,
|
||||||
|
updateChatAccessGrants
|
||||||
|
} from '$lib/apis/chats';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
import Modal from '../common/Modal.svelte';
|
import Modal from '../common/Modal.svelte';
|
||||||
import Link from '../icons/Link.svelte';
|
import Link from '../icons/Link.svelte';
|
||||||
import XMark from '$lib/components/icons/XMark.svelte';
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
||||||
|
|
||||||
export let chatId;
|
export let chatId;
|
||||||
|
|
||||||
let chat = null;
|
let chat = null;
|
||||||
let shareUrl = null;
|
let shareUrl = null;
|
||||||
|
let accessGrants: any[] = [];
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
const shareLocalChat = async () => {
|
const shareLocalChat = async () => {
|
||||||
const _chat = chat;
|
const _chat = chat;
|
||||||
|
|
||||||
const sharedChat = await shareChatById(localStorage.token, chatId);
|
const sharedChat = await shareChatById(localStorage.token, chatId);
|
||||||
shareUrl = `${window.location.origin}/s/${sharedChat.id}`;
|
shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`;
|
||||||
console.log(shareUrl);
|
console.log(shareUrl);
|
||||||
chat = await getChatById(localStorage.token, chatId);
|
chat = await getChatById(localStorage.token, chatId);
|
||||||
|
|
||||||
@@ -54,6 +62,25 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAccessGrants = async () => {
|
||||||
|
if (!chatId) return;
|
||||||
|
try {
|
||||||
|
accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load access grants', e);
|
||||||
|
accessGrants = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAccessGrants = async () => {
|
||||||
|
try {
|
||||||
|
await updateChatAccessGrants(localStorage.token, chatId, accessGrants);
|
||||||
|
toast.success($i18n.t('Access updated'));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
||||||
const isDifferentChat = (_chat) => {
|
const isDifferentChat = (_chat) => {
|
||||||
@@ -73,8 +100,10 @@
|
|||||||
if (isDifferentChat(_chat)) {
|
if (isDifferentChat(_chat)) {
|
||||||
chat = _chat;
|
chat = _chat;
|
||||||
}
|
}
|
||||||
|
await loadAccessGrants();
|
||||||
} else {
|
} else {
|
||||||
chat = null;
|
chat = null;
|
||||||
|
accessGrants = [];
|
||||||
console.log(chat);
|
console.log(chat);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -97,8 +126,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chat}
|
{#if chat}
|
||||||
<div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center">
|
<div class="px-5 pt-4 pb-5 w-full flex flex-col">
|
||||||
<div class=" text-sm dark:text-gray-300 mb-1">
|
<div class="text-sm dark:text-gray-300">
|
||||||
{#if chat.share_id}
|
{#if chat.share_id}
|
||||||
<a href="/s/{chat.share_id}" target="_blank"
|
<a href="/s/{chat.share_id}" target="_blank"
|
||||||
>{$i18n.t('You have shared this chat')}
|
>{$i18n.t('You have shared this chat')}
|
||||||
@@ -124,70 +153,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
{#if chat.share_id}
|
||||||
<div class="flex flex-col items-end space-x-1 mt-3">
|
<div class="mt-3">
|
||||||
<div class="flex gap-1">
|
<AccessControl
|
||||||
{#if $config?.features.enable_community_sharing}
|
bind:accessGrants
|
||||||
<button
|
accessRoles={['read']}
|
||||||
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
|
onChange={saveAccessGrants}
|
||||||
type="button"
|
/>
|
||||||
on:click={() => {
|
|
||||||
shareChat();
|
|
||||||
show = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{$i18n.t('Share to Open WebUI Community')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
|
||||||
type="button"
|
|
||||||
id="copy-and-share-chat-button"
|
|
||||||
on:click={async () => {
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
if (isSafari) {
|
|
||||||
// Oh, Safari, you're so special, let's give you some extra love and attention
|
|
||||||
console.log('isSafari');
|
|
||||||
|
|
||||||
const getUrlPromise = async () => {
|
|
||||||
const url = await shareLocalChat();
|
|
||||||
return new Blob([url], { type: 'text/plain' });
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.clipboard
|
|
||||||
.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'text/plain': getUrlPromise()
|
|
||||||
})
|
|
||||||
])
|
|
||||||
.then(() => {
|
|
||||||
console.log('Async: Copying to clipboard was successful!');
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Async: Could not copy text: ', error);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(await shareLocalChat());
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
|
|
||||||
show = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link />
|
|
||||||
|
|
||||||
{#if chat.share_id}
|
|
||||||
{$i18n.t('Update and Copy Link')}
|
|
||||||
{:else}
|
|
||||||
{$i18n.t('Copy Link')}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-1 mt-3">
|
||||||
|
{#if $config?.features.enable_community_sharing}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
|
||||||
|
type="button"
|
||||||
|
on:click={() => {
|
||||||
|
shareChat();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$i18n.t('Share to Open WebUI Community')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
|
type="button"
|
||||||
|
id="copy-and-share-chat-button"
|
||||||
|
on:click={async () => {
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
if (isSafari) {
|
||||||
|
console.log('isSafari');
|
||||||
|
|
||||||
|
const getUrlPromise = async () => {
|
||||||
|
const url = await shareLocalChat();
|
||||||
|
return new Blob([url], { type: 'text/plain' });
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.clipboard
|
||||||
|
.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/plain': getUrlPromise()
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
console.log('Async: Copying to clipboard was successful!');
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Async: Could not copy text: ', error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
copyToClipboard(await shareLocalChat());
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
|
||||||
|
{#if chat.share_id}
|
||||||
|
{$i18n.t('Update and Copy Link')}
|
||||||
|
{:else}
|
||||||
|
{$i18n.t('Copy Link')}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user