This commit is contained in:
Timothy Jaeryang Baek 2026-10-08 14:30:07 +04:00
parent 8145774e32
commit de73bb830a
23 changed files with 798 additions and 392 deletions

View file

@ -1121,6 +1121,28 @@ class NotificationEventSink:
class SocketSessionEventSink:
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
from open_webui.socket.main import refresh_chat_access
if event.event in {
EVENTS.FOLDER_ACCESS_UPDATED.name,
EVENTS.FOLDER_UPDATED.name,
EVENTS.FOLDER_PARENT_UPDATED.name,
EVENTS.FOLDER_DELETED.name,
EVENTS.GROUP_MEMBER_REMOVED.name,
EVENTS.GROUP_MEMBER_ADDED.name,
EVENTS.GROUP_UPDATED.name,
EVENTS.GROUP_DELETED.name,
EVENTS.CHAT_DELETED_ALL.name,
}:
await refresh_chat_access()
elif event.event in {
EVENTS.CHAT_SHARED.name,
EVENTS.CHAT_UNSHARED.name,
EVENTS.CHAT_FOLDER_UPDATED.name,
EVENTS.CHAT_DELETED.name,
}:
await refresh_chat_access((event.subject or {}).get('id'))
if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}:
return

View file

@ -1263,8 +1263,10 @@ async def chat_completion(
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
chat_id = form_data.pop('chat_id', None) or ''
chat_variables = form_data.pop('chat_variables', None)
if chat_variables is None:
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
if existing_chat and existing_chat.user_id != user.id:
chat_variables = {}
elif chat_variables is None:
chat_variables = existing_chat.variables if existing_chat else {}
chat_variables = normalize_chat_variables(chat_variables)
@ -1401,6 +1403,7 @@ async def chat_completion(
if user_message_id and user_message:
user_message['childrenIds'] = all_assistant_ids
user_message['user_id'] = user.id
history_messages[user_message_id] = user_message
for entry in message_ids:
@ -1455,7 +1458,7 @@ async def chat_completion(
subject_id=chat_id,
data={'title': 'New Chat'},
)
await emit_chat_list_event(metadata, chat_id)
await emit_chat_list_event({**metadata, 'message_id': user_message_id}, chat_id)
if user_message_id:
await publish_event(
request,
@ -1524,56 +1527,41 @@ async def chat_completion(
asyncio.create_task(run_initial_title_generation())
else:
# Existing chat — verify ownership
if not await Chats.is_chat_owner(chat_id, user.id) and not (
user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
chat = await Chats.get_accessible_chat_by_id(chat_id, user, permission='write')
if not chat:
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
user_message = metadata.get('user_message') or {}
selected_chat_models = user_message.get('models') if isinstance(user_message, dict) else None
if not isinstance(selected_chat_models, list) or not selected_chat_models:
selected_chat_models = [entry.get('model_id') for entry in message_ids if entry.get('model_id')]
# Persist chat-level fields the frontend used to save on every message.
# The old frontend saveChatHandler did this on every message;
# now the backend owns persistence.
chat_files = metadata.get('files')
chat_fields = {}
if chat_files is not None:
chat_fields['files'] = chat_files
if selected_chat_models:
chat_fields['models'] = selected_chat_models
if chat_fields:
await Chats.update_chat_by_id(chat_id, chat_fields, touch=False)
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
# Save user message to DB
if user_message and user_message.get('id'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
user_message['id'],
user_message,
assistant_message_id = metadata.get('assistant_message_id')
if assistant_message_id:
message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
if not message or (message.get('user_id') or chat.user_id) != user.id:
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
if any(entry.get('message_id') != assistant_message_id for entry in message_ids):
raise HTTPException(status_code=400, detail='Invalid response ID.')
metadata['user_message_id'] = message.get('parentId')
else:
turn = await Chats.insert_chat_turn(chat_id, user, user_message, message_ids)
event_emitter = await get_event_emitter(
{**metadata, 'message_id': turn['currentId']}, update_db=False
)
turn['messages'] = {
mid: {key: value for key, value in message.items() if key != 'meta'}
for mid, message in turn['messages'].items()
}
await event_emitter({'type': 'chat:messages', 'data': turn})
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
await publish_event(
request,
EVENTS.MESSAGE_CREATED,
actor=user,
subject_id=user_message['id'],
data={
'chat_id': chat_id,
'role': user_message.get('role', 'user'),
'content_preview': user_message.get('content', '')[:300],
},
)
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
'internal'
):
for message_id, message in turn['messages'].items():
if message_id != user_message['id'] and message.get('parentId') != user_message['id']:
continue
await publish_event(
request,
EVENTS.MESSAGE_CREATED,
actor=user,
subject_id=message_id,
data={'chat_id': chat_id, 'role': message['role'], 'model': message.get('model')},
)
if not getattr(request.state, 'internal', False):
try:
from open_webui.utils.timers import cancel_timers_for_chat
@ -1581,91 +1569,30 @@ async def chat_completion(
except Exception:
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
# Link grandparent → user message (childrenIds)
grandparent_id = user_message.get('parentId')
if grandparent_id:
grandparent = await Chats.get_message_by_id_and_message_id(chat_id, grandparent_id)
if grandparent:
child_ids = grandparent.get('childrenIds', [])
if user_message['id'] not in child_ids:
child_ids.append(user_message['id'])
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id, grandparent_id, {'childrenIds': child_ids}
)
if chat.user_id == user.id:
selected_chat_models = user_message.get('models') or [
entry['model_id'] for entry in message_ids
]
chat_fields = {'models': selected_chat_models}
if metadata.get('files') is not None:
chat_fields['files'] = metadata['files']
await Chats.update_chat_by_id(chat_id, chat_fields, touch=False)
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
else:
tasks = {
key: value
for key, value in (tasks or {}).items()
if key not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
} or None
# Insert chat files from user message if any
user_message_files = user_message.get('files', [])
if user_message_files:
try:
await Chats.insert_chat_files(
chat_id,
user_message.get('id'),
[
file_item.get('id')
for file_item in user_message_files
if file_item.get('type') == 'file'
],
user.id,
)
except Exception as e:
log.debug('Error inserting chat files: %s', e)
pass
# Save ALL assistant placeholders
user_message_id = metadata.get('user_message_id')
all_assistant_ids = [entry['message_id'] for entry in message_ids if entry.get('message_id')]
# Link user message → all assistant messages (childrenIds)
if user_message_id and all_assistant_ids:
existing_user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id)
if existing_user_message:
child_ids = existing_user_message.get('childrenIds', [])
for assistant_id in all_assistant_ids:
if assistant_id not in child_ids:
child_ids.append(assistant_id)
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
user_message_id,
{'childrenIds': child_ids},
)
# Save each assistant placeholder
for entry in message_ids:
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id and assistant_message_id == metadata.get('assistant_message_id'):
continue
if assistant_message_id:
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
}
# Preserve the side-by-side column index so duplicate
# models don't collapse into one another on reload.
if entry.get('modelIdx') is not None:
assistant_message['modelIdx'] = entry['modelIdx']
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
assistant_message,
)
await publish_event(
request,
EVENTS.MESSAGE_CREATED,
actor=user,
subject_id=assistant_message_id,
data={
'chat_id': chat_id,
'role': 'assistant',
'model': target_model_id,
},
)
await Chats.insert_chat_files(
chat_id,
user_message.get('id'),
[file.get('id') for file in user_message_files if file.get('type') == 'file'],
user.id,
)
request.state.metadata = metadata
form_data['metadata'] = metadata
@ -1685,12 +1612,15 @@ async def chat_completion(
try:
if not metadata.get('direct'):
target = (
model_info if form_data['model'] == model_id
model_info
if form_data['model'] == model_id
else await Models.get_model_by_id(form_data['model'])
)
controls = target.params.model_dump().get('model_controls', {}) if target else {}
form_data['params'] = apply_model_controls(
copy.deepcopy(form_data.get('params') or {}), controls, model_controls.get(form_data['model'], {})
copy.deepcopy(form_data.get('params') or {}),
controls,
model_controls.get(form_data['model'], {}),
)
ctx = None
# Saved chats load the message after approved tool calls run, so their results are kept
@ -1864,6 +1794,11 @@ async def chat_completion(
'task_id': str(uuid4()),
}
if is_saved_chat_id(chat_id):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id, assistant_message_id, {'meta': {'task_id': per_model_metadata['task_id']}}, touch=False
)
# Per-model form_data: own model
model_form_data = {
**form_data,
@ -2200,12 +2135,15 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
if owner_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS):
return {'task_ids': []}
else:
chat = await Chats.get_chat_by_id(chat_id)
if chat is None or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)):
chat = await Chats.get_accessible_chat_by_id(chat_id, user, permission='write')
if chat is None:
return {'task_ids': []}
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
if not socket_id:
task_ids = await Chats.filter_task_ids_by_user_id(chat, user.id, task_ids)
log.debug('Task IDs for chat %s: %s', chat_id, task_ids)
return {'task_ids': task_ids}
@ -2219,14 +2157,24 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
if owner_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
else:
chat = await Chats.get_chat_by_id(chat_id)
if chat is None or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)):
chat = await Chats.get_accessible_chat_by_id(chat_id, user, permission='write')
if chat is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
result = await stop_item_tasks(request.app.state.redis, chat_id)
if socket_id:
result = await stop_item_tasks(request.app.state.redis, chat_id)
else:
task_ids = await Chats.filter_task_ids_by_user_id(
chat, user.id, await list_task_ids_by_item_id(request.app.state.redis, chat_id)
)
result = {'status': True, 'message': 'No tasks found.'}
for task_id in task_ids:
result = await stop_task(request.app.state.redis, task_id)
if not socket_id and str(result.get('message', '')).startswith('No tasks found'):
messages_map = await Chats.get_messages_map_by_chat_id(chat_id) or {}
for message_id, message in messages_map.items():
if (message.get('user_id') or chat.user_id) != user.id:
continue
if message.get('role') != 'assistant' or message.get('done') is not False:
continue
@ -2254,7 +2202,7 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
event_emitter = await get_event_emitter(
{
'user_id': chat.user_id,
'user_id': user.id,
'chat_id': chat_id,
'message_id': message_id,
},

View file

@ -1,3 +1,4 @@
from contextlib import nullcontext
import time
import uuid
from collections import Counter
@ -278,47 +279,43 @@ class ChatMessageTable:
db: Optional[AsyncSession] = None,
) -> Optional[ChatMessageModel]:
"""Insert or update a chat message."""
async with get_async_db_context(db) as db:
async with nullcontext(db) if db is not None else get_async_db_context() as session:
now = int(time.time())
# Use composite ID: {chat_id}-{message_id}
composite_id = f'{chat_id}-{message_id}'
message = await db.get(ChatMessage, composite_id)
message = await session.get(ChatMessage, composite_id)
if message:
self._apply_message_data(message, data, now)
else:
message = self._build_message(composite_id, chat_id, user_id, data, now)
db.add(message)
session.add(message)
await db.commit()
if db is None:
await session.commit()
else:
await session.flush()
return ChatMessageModel.model_validate(message)
async def upsert_messages(
self,
chat_id: str,
user_id: str,
messages: dict[str, dict],
db: AsyncSession | None = None,
self, chat_id: str, user_id: str, messages: dict[str, dict], db: AsyncSession | None = None
) -> None:
"""Insert or update the given messages of one chat."""
"""Backfill missing rows without overwriting newer message data."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
if not messages:
return
async with get_async_db_context(db) as db:
now = int(time.time())
result = await db.execute(
select(ChatMessage).filter(ChatMessage.id.in_([f'{chat_id}-{message_id}' for message_id in messages]))
insert = sqlite_insert if db.bind.dialect.name == 'sqlite' else pg_insert
rows = [
self._build_message(f'{chat_id}-{mid}', chat_id, data.get('user_id') or user_id, data, int(time.time()))
for mid, data in messages.items()
]
await db.execute(
insert(ChatMessage).on_conflict_do_nothing(index_elements=['id']),
[{column.name: getattr(row, column.name) for column in ChatMessage.__table__.columns} for row in rows],
)
existing_by_id = {row.id: row for row in result.scalars().all()}
for message_id, data in messages.items():
composite_id = f'{chat_id}-{message_id}'
message = existing_by_id.get(composite_id)
if message:
self._apply_message_data(message, data, now)
else:
db.add(self._build_message(composite_id, chat_id, user_id, data, now))
await db.commit()
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
@ -358,7 +355,7 @@ class ChatMessageTable:
'created_at': 'timestamp',
}
# DB-internal columns excluded from the reconstructed message dict.
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'})
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'updated_at'})
async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
"""Build a {message_id: message_dict} map from chat_message rows.
@ -509,13 +506,16 @@ class ChatMessageTable:
"""Delete specific ``chat_message`` rows by their original message IDs."""
if not message_ids:
return True
async with get_async_db_context(db) as db:
await db.execute(
async with nullcontext(db) if db is not None else get_async_db_context() as session:
await session.execute(
delete(ChatMessage)
.where(ChatMessage.chat_id == chat_id)
.where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids}))
)
await db.commit()
if db is None:
await session.commit()
else:
await session.flush()
return True
# Analytics methods

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
import logging
import re
import time
@ -10,7 +11,7 @@ from typing import Any, Literal
# local imports
from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.internal.db import Base, JSONField, get_async_db, get_async_db_context
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import AutomationRun
from open_webui.models.chat_messages import ChatMessage, ChatMessages
@ -394,6 +395,16 @@ class ChatStatsExport(BaseModel):
class ChatTable:
@asynccontextmanager
async def _chat_transaction(self, id: str):
# Own the transaction; callers may already have a read session.
async with get_async_db() as session:
if session.bind.dialect.name == 'sqlite':
await session.execute(text('BEGIN IMMEDIATE'))
chat = await session.get(Chat, id, with_for_update=True)
yield session, chat
await session.commit()
def _clean_null_bytes(self, obj):
"""Recursively remove null bytes from strings in dict/list structures."""
return sanitize_data_for_db(obj)
@ -663,6 +674,10 @@ class ChatTable:
'last_read_at': int(time.time()),
}
)
messages = list((chat.chat.get('history', {}).get('messages') or {}).values())
messages.extend(chat.chat.get('messages') or [])
for message in messages:
message['user_id'] = user_id
return chat
async def import_chats(
@ -734,13 +749,7 @@ class ChatTable:
) -> ChatModel | None:
"""Patch top-level chat keys; history is merged so stale writers don't drop messages."""
try:
async with get_async_db_context(db) as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
@ -749,6 +758,15 @@ class ChatTable:
if 'history' in chat:
# The caller built its history from an earlier read; merge so messages saved since then survive.
updated['history'] = self.merge_history(stored.get('history'), chat['history'])
for mid, message in (stored.get('history', {}).get('messages') or {}).items():
if (message.get('user_id') or chat_item.user_id) != chat_item.user_id or (
message.get('role') == 'assistant'
and (
message.get('done') is False or updated['history']['messages'][mid].get('done') is False
)
):
updated['history']['messages'][mid] = message
updated['history'] = self.merge_history(updated['history'], {})
updated = self._clean_null_bytes(updated)
chat_item.chat = updated
@ -759,8 +777,16 @@ class ChatTable:
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
for mid in chat.get('history', {}).get('messages') or {}:
message = updated['history']['messages'].get(mid)
if (
message
and message.get('role')
and message != (stored.get('history', {}).get('messages') or {}).get(mid)
):
await ChatMessages.upsert_message(
mid, id, message.get('user_id') or chat_item.user_id, message, db=session
)
return ChatModel.model_validate(chat_item)
except Exception:
return
@ -860,19 +886,13 @@ class ChatTable:
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
try:
async with get_async_db_context() as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
clean_title = self._clean_null_bytes(title)
chat_item.title = clean_title
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return None
@ -999,10 +1019,16 @@ class ChatTable:
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
incoming_meta = message.get('meta')
if isinstance(incoming_meta, dict):
if set(incoming_meta) == {'voice'}:
if set(incoming_meta) <= {'voice', 'task_id'}:
message = {**message, 'meta': {**existing_meta, **incoming_meta}}
elif 'voice' in existing_meta and 'voice' not in incoming_meta:
message = {**message, 'meta': {**incoming_meta, 'voice': existing_meta['voice']}}
else:
message = {
**message,
'meta': {
**{key: existing_meta[key] for key in ('voice', 'task_id') if key in existing_meta},
**incoming_meta,
},
}
messages[message_id] = {
**messages[message_id],
**message,
@ -1059,17 +1085,6 @@ class ChatTable:
except Exception as e:
log.warning('Backfill failed for chat %s: %s', chat_id, e)
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Sync ``chat_message`` rows with the committed JSON blob.
Upserts current messages via ``backfill_messages_by_chat_id``.
Best-effort: errors are logged but never raised.
"""
try:
await self.backfill_messages_by_chat_id(chat_id, user_id, messages)
except Exception as e:
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
async def get_messages_map_by_chat_id(self, id: str) -> dict | None:
"""Message map for walking history (see ``get_message_list``).
@ -1163,6 +1178,73 @@ class ChatTable:
message = chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
return message.get(metadata_key)
async def insert_chat_turn(self, chat_id, user, user_message, message_ids):
from fastapi import HTTPException
if not await self.get_accessible_chat_by_id(chat_id, user, permission='write'):
raise HTTPException(403, 'Chat continuation is not allowed.')
async with self._chat_transaction(chat_id) as (db, chat):
if not chat:
raise HTTPException(404, 'Chat not found.')
history = (chat.chat or {}).get('history') or {'messages': {}, 'currentId': None}
messages = history.get('messages') or {}
ids = [user_message.get('id'), *[entry.get('message_id') for entry in message_ids]]
if (
not all(isinstance(mid, str) and mid for mid in ids)
or len(set(ids)) != len(ids)
or any(mid in messages for mid in ids[1:])
):
raise HTTPException(409, 'Message already exists or has an invalid ID.')
existing = messages.get(ids[0])
if existing and (existing.get('role') != 'user' or (existing.get('user_id') or chat.user_id) != user.id):
raise HTTPException(403, 'You can only regenerate your own messages.')
parent_id = existing.get('parentId') if existing else user_message.get('parentId')
if parent_id is not None and parent_id not in messages:
raise HTTPException(409, 'Parent message no longer exists.')
message = existing or (
dict(user_message)
if chat.user_id == user.id
else {key: user_message[key] for key in ('content', 'files', 'models') if key in user_message}
)
if not existing:
message.update(
id=ids[0],
parentId=parent_id,
role='user',
childrenIds=[],
timestamp=int(time.time()),
user_id=user.id,
user={'id': user.id, 'name': user.name},
)
turn = {ids[0]: self.upsert_message_to_history(history, ids[0], self._clean_null_bytes(message))}
for entry in message_ids:
mid = entry['message_id']
turn[mid] = self.upsert_message_to_history(
history,
mid,
{
'id': mid,
'parentId': ids[0],
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': entry['model_id'],
'modelIdx': entry.get('modelIdx'),
'timestamp': int(time.time()),
'user_id': user.id,
},
)
chat.chat = {**(chat.chat or {}), 'history': history}
flag_modified(chat, 'chat')
chat.current_message_id = history['currentId']
chat.updated_at = int(time.time())
for mid, message in turn.items():
await ChatMessages.upsert_message(mid, chat_id, user.id, message, db=db)
if parent_id:
turn[parent_id] = history['messages'][parent_id]
return {'messages': turn, 'currentId': history['currentId']}
async def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict, *, touch: bool = True
) -> ChatModel | None:
@ -1175,13 +1257,7 @@ class ChatTable:
message_id = self._clean_null_bytes(message_id)
try:
async with get_async_db_context() as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
@ -1190,6 +1266,9 @@ class ChatTable:
history = chat.get('history', {})
saved_message = self.upsert_message_to_history(history, message_id, message)
await ChatMessages.upsert_message(
message_id, id, saved_message.get('user_id') or chat_item.user_id, saved_message, db=session
)
chat['history'] = history
chat_item.chat = chat # chat is a fresh dict when the column was empty
chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat'))
@ -1199,20 +1278,7 @@ class ChatTable:
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
updated_chat = ChatModel.model_validate(chat_item)
user_id = chat_item.user_id
# Dual-write to chat_message table
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=saved_message,
)
except Exception as e:
log.warning(f'Failed to write to chat_message table: {e}')
return updated_chat
except Exception:
@ -1220,13 +1286,7 @@ class ChatTable:
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
try:
async with get_async_db_context() as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
@ -1235,27 +1295,23 @@ class ChatTable:
history = chat.get('history', {})
deleted_ids = self.delete_message_from_history(history, message_id)
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids, db=session)
if not deleted_ids:
chat_item.chat = chat
chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat'))
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
await session.commit()
return ChatModel.model_validate(chat_item)
messages = history.get('messages') or {}
chat['history'] = history
chat_item.chat = chat
chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat'))
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
chat_item.updated_at = int(time.time())
await session.commit()
updated_chat = ChatModel.model_validate(chat_item)
user_id = chat_item.user_id
await self.backfill_messages_by_chat_id(id, user_id, messages)
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
updated_chat = ChatModel.model_validate(chat_item)
return updated_chat
except Exception:
@ -1266,13 +1322,7 @@ class ChatTable:
) -> ChatModel | None:
try:
status = self._clean_null_bytes(status)
async with get_async_db_context() as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
@ -1284,13 +1334,16 @@ class ChatTable:
status_history = history['messages'][message_id].get('statusHistory', [])
status_history.append(status)
history['messages'][message_id]['statusHistory'] = status_history
message = history['messages'][message_id]
await ChatMessages.upsert_message(
message_id, id, message.get('user_id') or chat_item.user_id, message, db=session
)
chat['history'] = history
chat_item.chat = chat
chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat'))
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
@ -1299,13 +1352,7 @@ class ChatTable:
async def add_message_files_by_id_and_message_id(
self, id: str, message_id: str, files: list[dict]
) -> list[dict] | None:
async with get_async_db_context() as session:
chat_item = await session.get(
Chat,
id,
populate_existing=True,
with_for_update=session.bind.dialect.name == 'postgresql',
)
async with self._chat_transaction(id) as (session, chat_item):
if chat_item is None:
return None
@ -1318,15 +1365,21 @@ class ChatTable:
message_files = history['messages'][message_id].get('files', [])
message_files = message_files + files
history['messages'][message_id]['files'] = message_files
message = history['messages'][message_id]
await ChatMessages.upsert_message(
message_id,
id,
message.get('user_id') or chat_item.user_id,
self._clean_null_bytes(message),
db=session,
)
# Written here rather than through update_chat_by_id: with session sharing off that opens a second
# connection, which then blocks on the lock this one holds.
chat['history'] = history
chat_item.chat = self._clean_null_bytes(chat)
# History was mutated in place, so the new blob compares equal to the loaded one.
flag_modified(chat_item, 'chat')
chat_item.updated_at = int(time.time())
await session.commit()
return message_files
async def insert_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> ChatModel | None:
@ -1722,14 +1775,11 @@ class ChatTable:
if chat_item is None:
return None
repaired_history = self._repair_chat_current_id(chat_item.chat or {})
if repaired_history:
chat_item.current_message_id = self.get_current_message_id(chat_item.chat)
flag_modified(chat_item, 'chat')
if self._sanitize_chat_row(chat_item) or repaired_history:
await session.commit()
model = ChatModel.model_validate(chat_item)
model.chat = self._clean_null_bytes(model.chat)
self._repair_chat_current_id(model.chat)
return model
return ChatModel.model_validate(chat_item)
except Exception:
return None
@ -1764,52 +1814,66 @@ class ChatTable:
if not chat:
return None
repaired_history = self._repair_chat_current_id(chat.chat or {})
if repaired_history:
chat.current_message_id = self.get_current_message_id(chat.chat)
flag_modified(chat, 'chat')
if self._sanitize_chat_row(chat) or repaired_history:
await session.commit()
model = ChatModel.model_validate(chat)
model.chat = self._clean_null_bytes(model.chat)
self._repair_chat_current_id(model.chat)
return model
return ChatModel.model_validate(chat)
except Exception:
return None
async def get_chat_by_id_for_user(
async def get_accessible_chat_by_id(
self,
id: str,
user,
db: AsyncSession | None = None,
*,
permission: Literal['read', 'write'] = 'read',
chat: Chat | ChatModel | None = None,
) -> ChatModel | None:
chat = await self.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
return chat
chat = await self.get_chat_by_id(id, db=db)
if user.role not in {'user', 'admin'}:
return None
chat = chat if chat is not None else await self.get_chat_by_id(id, db=db)
if not chat:
return None
if user.role == 'admin' and (ENABLE_ADMIN_CHAT_ACCESS or is_internal_chat(chat.meta)):
return chat
if await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
if (
chat.user_id == user.id
or user.role == 'admin'
and (ENABLE_ADMIN_CHAT_ACCESS or permission == 'read' and is_internal_chat(chat.meta))
):
return chat
return ChatModel.model_validate(chat)
from open_webui.models.shared_chats import SharedChats
shared = await SharedChats.get_by_chat_id(id, db=db)
if (
shared
and shared.chat.get('share_mode') == 'continue'
and await AccessGrants.has_access(
user_id=user.id, resource_type='shared_chat', resource_id=id, permission='read', db=db
)
):
return ChatModel.model_validate(chat)
if chat.folder_id:
from open_webui.utils.access_control.folders import has_folder_access
folder = await Folders.get_folder_by_id(chat.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
return chat
if (
folder
and (permission == 'read' or (folder.data or {}).get('share_mode') == 'continue')
and await has_folder_access(user.id, folder, 'read', db)
):
return ChatModel.model_validate(chat)
return None
async def filter_task_ids_by_user_id(self, chat, user_id: str, task_ids: list[str]) -> list[str]:
if not task_ids:
return []
actors = {
(m.get('meta') or {}).get('task_id'): m.get('user_id') or chat.user_id
for m in (chat.chat.get('history', {}).get('messages') or {}).values()
}
return [task_id for task_id in task_ids if actors.get(task_id, chat.user_id) == user_id]
async def is_chat_owner(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
"""
Lightweight ownership check — uses EXISTS subquery instead of loading
@ -2682,12 +2746,12 @@ class ChatTable:
return False
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: AsyncSession | None = None) -> list[str]:
"""Return IDs of chats that contain this file and have an active share link."""
"""Return file-associated chats with a share link or folder audience."""
async with get_async_db_context(db) as session:
result = await session.execute(
select(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, or_(Chat.share_id.isnot(None), Chat.folder_id.isnot(None)))
)
return [row[0] for row in result.all()]

View file

@ -1,7 +1,7 @@
import logging
import time
import uuid
from typing import Optional
from typing import Literal, Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
ChatShareMode = Literal['continue'] | None
class ShareChatForm(BaseModel):
share_mode: ChatShareMode = None
####################
# SharedChat DB Schema
####################
@ -58,7 +65,9 @@ class SharedChatResponse(BaseModel):
class SharedChatsTable:
async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
async def create(
self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None, *, share_mode: ChatShareMode = None
) -> Optional[SharedChatModel]:
"""
Create a snapshot of the chat for link sharing.
Returns the SharedChatModel with the share token as its id.
@ -78,7 +87,7 @@ class SharedChatsTable:
chat_id=chat_id,
user_id=user_id,
title=chat.title,
chat=chat.chat,
chat={**chat.chat, 'share_mode': share_mode},
created_at=now,
updated_at=now,
)
@ -88,7 +97,9 @@ class SharedChatsTable:
return SharedChatModel.model_validate(shared_chat)
async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
async def update(
self, share_id: str, form_data: ShareChatForm | None = None, db: Optional[AsyncSession] = None
) -> Optional[SharedChatModel]:
"""
Re-snapshot: update the shared chat with the current state of the original chat.
"""
@ -104,13 +115,31 @@ class SharedChatsTable:
return None
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.chat = {
**chat.chat,
'share_mode': shared_chat.chat.get('share_mode'),
**(form_data.model_dump(exclude_unset=True) if form_data else {}),
}
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def set_share_mode(self, share_id: str, share_mode: ChatShareMode, db: Optional[AsyncSession] = None):
async with get_async_db_context(db) as db:
shared = await db.get(SharedChat, share_id)
if shared:
if share_mode is None and shared.chat.get('share_mode') == 'continue':
from open_webui.models.chats import Chat
chat = await db.get(Chat, shared.chat_id)
if chat:
shared.chat = chat.chat
shared.title = chat.title
shared.chat = {**shared.chat, 'share_mode': share_mode}
await db.commit()
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:

View file

@ -30,7 +30,7 @@ from open_webui.models.chats import (
)
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.shared_chats import SharedChatResponse, SharedChats
from open_webui.models.shared_chats import ChatShareMode, ShareChatForm, SharedChatResponse, SharedChats
from open_webui.models.tags import TagModel, Tags
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
@ -127,6 +127,20 @@ async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
)
def shared_chat_response(chat, user=None):
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
if user is None or chat.user_id != user.id:
data['variables'] = {}
for key in ('params', 'tool_servers', 'tool_ids', 'filter_ids', 'variables'):
data['chat'].pop(key, None)
messages = list((data['chat'].get('history', {}).get('messages') or {}).values())
messages.extend(data['chat'].get('messages') or [])
for message in messages:
if user is None or (message.get('user_id') or chat.user_id) != user.id:
message.pop('meta', None)
return data
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
@ -1223,9 +1237,20 @@ async def get_shared_chat_by_id(
if await is_open_shared_chat(shared, db=db) or (
user is not None and await can_read_shared_chat(user, shared, db=db)
):
chat = await Chats.get_chat_by_share_id(share_id, db=db)
live = (
shared.chat.get('share_mode') == 'continue'
and user is not None
and await can_read_shared_chat(user, shared, db=db)
)
chat = (
await Chats.get_chat_by_id(shared.chat_id, db=db)
if live
else await Chats.get_chat_by_share_id(share_id, db=db)
)
if chat:
return ChatResponse.model_validate(chat, from_attributes=True)
data = shared_chat_response(chat, user)
data['chat']['share_mode'] = 'continue' if live else None
return data
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -1338,14 +1363,17 @@ async def get_chat_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_for_user(
chat = await Chats.get_accessible_chat_by_id(
id,
user,
db=db,
)
if chat:
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
data = shared_chat_response(chat, user)
data['chat']['share_mode'] = (
'continue' if await Chats.get_accessible_chat_by_id(id, user, db=db, permission='write', chat=chat) else None
)
data = overlay_response_streams(
data,
await get_response_streams_by_chat_id(request.app.state.redis, id),
@ -1384,12 +1412,6 @@ async def update_chat_by_id(
or chat
)
# Reconcile chat_message rows without inferring deletes from missing IDs.
# Message deletion has its own endpoint below.
messages = ((chat.chat or {}).get('history') or {}).get('messages') or {}
if messages:
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
await publish_event(
request,
EVENTS.CHAT_UPDATED,
@ -1874,6 +1896,8 @@ async def clone_shared_chat_by_id(
)
chat = await Chats.get_chat_by_share_id(id, db=db) if shared else None
if shared and shared.chat.get('share_mode') == 'continue' and await can_read_shared_chat(user, shared, db=db):
chat = await Chats.get_chat_by_id(shared.chat_id, db=db)
# Fallback: admins can also access any chat directly by chat ID
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
@ -1886,7 +1910,7 @@ async def clone_shared_chat_by_id(
)
updated_chat = {
**chat.chat,
**shared_chat_response(chat, user)['chat'],
'originalChatId': chat.id,
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
@ -1899,9 +1923,9 @@ async def clone_shared_chat_by_id(
**{
'chat': updated_chat,
'meta': chat.meta,
'variables': chat.variables or {},
'variables': {},
'pinned': chat.pinned,
'folder_id': chat.folder_id,
'folder_id': None,
}
)
],
@ -1963,6 +1987,7 @@ async def archive_chat_by_id(
async def share_chat_by_id(
request: Request,
id: str,
form_data: ShareChatForm | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -1975,7 +2000,7 @@ async def share_chat_by_id(
# If a share already exists, re-snapshot it
if chat.share_id:
shared = await SharedChats.update(chat.share_id, db=db)
shared = await SharedChats.update(chat.share_id, form_data, db=db)
if shared:
chat = await Chats.get_chat_by_id(id, db=db)
await publish_event(
@ -1988,7 +2013,7 @@ async def share_chat_by_id(
return ChatResponse.model_validate(chat, from_attributes=True)
# Create a new share
shared = await SharedChats.create(id, user.id, db=db)
shared = await SharedChats.create(id, user.id, db=db, share_mode=form_data.share_mode if form_data else None)
if not shared:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
@ -2041,6 +2066,7 @@ async def delete_shared_chat_by_id(
class ChatAccessGrantsForm(BaseModel):
access_grants: list[dict]
share_mode: ChatShareMode = None
@router.post('/shared/{id}/access/update', response_model=ChatResponse | None)
@ -2072,6 +2098,11 @@ async def update_shared_chat_access_by_id(
)
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
if 'share_mode' in form_data.model_fields_set and chat.share_id:
await SharedChats.set_share_mode(chat.share_id, form_data.share_mode, db=db)
from open_webui.socket.main import refresh_chat_access
await refresh_chat_access(id)
return ChatResponse.model_validate(chat, from_attributes=True)
@ -2177,7 +2208,7 @@ async def update_chat_folder_id_by_id(
@router.get('/{id}/tags', response_model=list[TagModel])
async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
chat = await Chats.get_chat_by_id_for_user(
chat = await Chats.get_accessible_chat_by_id(
id,
user,
db=db,

View file

@ -12,6 +12,7 @@ from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.shared_chats import ChatShareMode
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.config import Config
from open_webui.models.chats import Chats
@ -526,6 +527,7 @@ async def update_folder_is_expanded_by_id(
class FolderAccessGrantsForm(BaseModel):
access_grants: list[dict]
share_mode: ChatShareMode = None
@router.post('/{id}/access/update')
@ -561,6 +563,10 @@ async def update_folder_access_by_id(
)
await AccessGrants.set_access_grants('folder', id, form_data.access_grants, db=db)
if 'share_mode' in form_data.model_fields_set:
folder = await Folders.update_folder_by_id_and_user_id(
id, folder.user_id, FolderUpdateForm(data={'share_mode': form_data.share_mode}), db=db
)
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
await publish_event(

View file

@ -529,7 +529,7 @@ async def user_join(sid, data):
'last_seen_at': int(time.time()),
}
SESSION_POOL[sid] = socket_user
SESSION_POOL[sid] = {**socket_user, 'chat_ids': (SESSION_POOL.get(sid) or {}).get('chat_ids', [])}
await sio.save_session(sid, {'user': socket_user, 'token': auth['token']})
LOCAL_AUTHENTICATED_SIDS.add(sid)
await sio.enter_room(sid, f'user:{user.id}')
@ -544,11 +544,40 @@ async def user_join(sid, data):
return {'id': user.id, 'name': user.name}
async def refresh_chat_access(chat_id=None):
# The pool includes sessions on other workers; Socket.IO routes room changes via Redis.
access = {}
for batch in get_session_pool_batches():
for sid, session in batch:
if not session:
continue
chat_ids = set(session.get('chat_ids') or [])
for cid in list(chat_ids):
if chat_id and cid != chat_id:
continue
key = (cid, session['id'])
if key not in access:
user = await Users.get_user_by_id(session['id'])
access[key] = bool(user and await Chats.get_accessible_chat_by_id(cid, user))
if not access[key]:
await sio.leave_room(sid, f'chat:{cid}')
chat_ids.discard(cid)
await sio.emit(
'events', {'chat_id': cid, 'shared': True, 'data': {'type': 'chat:access', 'data': {}}}, to=sid
)
if chat_ids != set(session.get('chat_ids') or []):
SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)}
@sio.on('heartbeat')
async def heartbeat(sid, data):
user = await get_socket_session_user(sid)
if user:
SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
SESSION_POOL[sid] = {
**user,
'chat_ids': (SESSION_POOL.get(sid) or {}).get('chat_ids', []),
'last_seen_at': int(time.time()),
}
await Users.update_last_active_by_id(user['id'])
@ -671,6 +700,24 @@ async def chat_events(sid, data):
event_data = data.get('data', {})
event_type = event_data.get('type')
if event_type in {'join', 'leave'}:
chat_id = data.get('chat_id')
if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id):
return False
session = SESSION_POOL.get(sid) or user
chat_ids = set(session.get('chat_ids') or [])
if event_type == 'leave':
await sio.leave_room(sid, f'chat:{chat_id}')
chat_ids.discard(chat_id)
else:
reader = await Users.get_user_by_id(user['id'])
if not reader or not await Chats.get_accessible_chat_by_id(chat_id, reader):
return False
await sio.enter_room(sid, f'chat:{chat_id}')
chat_ids.add(chat_id)
SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)}
return True
if event_type == 'last_read_at':
read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
if not read_update:
@ -1208,7 +1255,11 @@ async def get_event_emitter(request_info, update_db=True):
if (request_info.get('chat_id') or '').startswith('channel:'):
return await _make_channel_emitter(request_info)
last_shared_emit = 0.0
output = None
async def __event_emitter__(event_data):
nonlocal last_shared_emit, output
user_id = request_info['user_id']
chat_id = request_info['chat_id']
message_id = request_info['message_id']
@ -1219,8 +1270,7 @@ async def get_event_emitter(request_info, update_db=True):
return
room = f'user:{user_id}'
# Local rooms are authoritative; Redis may have listeners on another instance.
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
if event_data.get('type') != 'chat:messages':
await sio.emit(
'events',
{
@ -1232,6 +1282,55 @@ async def get_event_emitter(request_info, update_db=True):
room=room,
)
if not internal and is_saved_chat_id(chat_id):
event_type = event_data.get('type')
shared_event = None
if event_type in {
'chat:messages',
'chat:active',
'status',
'source',
'citation',
'files',
'embeds',
'chat:message:error',
'chat:tasks:cancel',
'chat:message:follow_ups',
}:
shared_event = event_data
elif event_type in {'chat:completion', 'response:completion'}:
data = event_data.get('data') or {}
if isinstance(data.get('output'), list):
output = copy.deepcopy(data['output'])
elif event_type == 'response:completion':
from open_webui.utils.middleware import handle_responses_streaming_event
output, _ = handle_responses_streaming_event(data, output or [])
now = time.monotonic()
if not data.get('type', '').endswith('.delta') or now - last_shared_emit >= 0.15:
last_shared_emit = now
payload = {
key: value
for key, value in data.items()
if key in {'done', 'error', 'usage', 'finish_reason', 'content', 'selected_model_id', 'sources'}
}
if output is not None:
payload['output'] = output
shared_event = {'type': 'chat:completion', 'data': payload}
if shared_event:
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
'user_id': user_id,
'shared': True,
'data': shared_event,
},
room=f'chat:{chat_id}',
skip_sid=request_info.get('session_id'),
)
if save_to_chat:
event_type = event_data.get('type')

View file

@ -85,6 +85,9 @@ async def has_access_to_file(
)
if accessible_ids:
return True
for chat_id in shared_chat_ids:
if await Chats.get_accessible_chat_by_id(chat_id, user, db=db):
return True
# Note attachment JSON is user-controlled, so only the file owner's notes can grant access.
if access_type == 'read':

View file

@ -692,7 +692,21 @@ def handle_responses_streaming_event(
delta_type = parts[1]
delta = data.get('delta', '')
output_index = data.get('output_index', len(current_output) - 1)
output_index = data.get('output_index', max(len(current_output) - 1, 0))
# Chat Completions can start an item with a delta, without an added event.
if output_index >= len(current_output):
current_output = list(current_output)
while len(current_output) <= output_index:
current_output.append(
{'type': 'message', 'status': 'in_progress', 'role': 'assistant', 'content': []}
)
current_output[output_index].update(
id=data.get('item_id'),
type='reasoning'
if delta_type.startswith('reasoning')
else ('function_call' if delta_type == 'function_call_arguments' else 'message'),
)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
@ -1832,10 +1846,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
if not is_saved_chat_id(chat_id):
message_list = form_data.get('messages', [])
else:
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id)
chat = await Chats.get_accessible_chat_by_id(chat_id, user, permission='write')
messages_map = chat.chat.get('history', {}).get('messages', {})
message_id = chat.chat.get('history', {}).get('currentId')
message_id = metadata.get('user_message_id') or chat.chat.get('history', {}).get('currentId')
message_list = get_message_list(messages_map, message_id)
user_message = get_last_user_message(message_list)

View file

@ -5,7 +5,6 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.users import Users
@ -27,8 +26,8 @@ async def resolve_tool_call_output(
user,
db: AsyncSession | None = None,
) -> dict:
chat = await Chats.get_chat_by_id(chat_id, db=db)
if not chat or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)):
chat = await Chats.get_accessible_chat_by_id(chat_id, user, db=db, permission='write')
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@ -38,6 +37,9 @@ async def resolve_tool_call_output(
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if (message.get('user_id') or chat.user_id) != user.id:
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
output = message.get('output') or []
if not isinstance(output, list):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Message has no resolvable output.')
@ -109,7 +111,7 @@ async def resolve_tool_call_output(
event_emitter = await get_event_emitter(
{
'user_id': chat.user_id,
'user_id': user.id,
'chat_id': chat_id,
'message_id': message_id,
},
@ -148,6 +150,8 @@ async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat
chat_data = chat.chat or {}
message_meta = assistant_message.get('meta') if isinstance(assistant_message.get('meta'), dict) else {}
chat_params = chat_data.get('params') if isinstance(chat_data.get('params'), dict) else {}
if (assistant_message.get('user_id') or chat.user_id) != chat.user_id:
chat_params = {}
params = {
**chat_params,
**(message_meta.get('params') if isinstance(message_meta.get('params'), dict) else {}),
@ -166,7 +170,7 @@ async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat
system_prompt = params.get('system')
if not system_prompt:
# Mirror the chat UI's system prompt fallback
user = await Users.get_user_by_id(chat.user_id)
user = await Users.get_user_by_id(assistant_message.get('user_id') or chat.user_id)
ui_settings = (user.settings.ui if user and user.settings else None) or {}
system_prompt = ui_settings.get('system')
if system_prompt is None:
@ -180,7 +184,7 @@ async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat
'model': model_id,
'messages': messages,
'params': params,
'files': message_meta.get('files') or chat_data.get('files') or None,
'files': message_meta.get('files') if 'files' in message_meta else chat_data.get('files'),
'filter_ids': message_meta.get('filter_ids') or None,
'tool_ids': message_meta.get('tool_ids') or None,
'skill_ids': message_meta.get('skill_ids') or None,
@ -188,7 +192,9 @@ async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat
'tool_servers': message_meta.get('tool_servers') or None,
'features': message_meta.get('features') or {},
'variables': message_meta.get('variables') or {},
'chat_variables': chat.variables,
'chat_variables': chat.variables
if (assistant_message.get('user_id') or chat.user_id) == chat.user_id
else {},
'session_id': message_meta.get('session_id'),
'chat_id': chat_id,
'id': message_id,

View file

@ -1069,7 +1069,7 @@ export const cloneSharedChatById = async (token: string, id: string) => {
return res;
};
export const shareChatById = async (token: string, id: string) => {
export const shareChatById = async (token: string, id: string, shareMode?: 'continue' | null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/share`, {
@ -1078,7 +1078,8 @@ export const shareChatById = async (token: string, id: string) => {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
},
body: JSON.stringify({ share_mode: shareMode })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -1200,7 +1201,12 @@ export const deleteSharedChatById = async (token: string, id: string) => {
return res;
};
export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => {
export const updateChatAccessGrants = async (
token: string,
id: string,
accessGrants: object[],
shareMode?: 'continue' | null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, {
@ -1211,7 +1217,8 @@ export const updateChatAccessGrants = async (token: string, id: string, accessGr
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
access_grants: accessGrants
access_grants: accessGrants,
share_mode: shareMode
})
})
.then(async (res) => {

View file

@ -263,7 +263,12 @@ export const markFolderChatsReadById = async (token: string, id: string) => {
return res;
};
export const updateFolderAccessById = async (token: string, id: string, accessGrants: any[]) => {
export const updateFolderAccessById = async (
token: string,
id: string,
accessGrants: any[],
shareMode?: 'continue' | null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/access/update`, {
@ -273,7 +278,7 @@ export const updateFolderAccessById = async (token: string, id: string, accessGr
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
body: JSON.stringify({ access_grants: accessGrants, share_mode: shareMode })
})
.then(async (res) => {
if (!res.ok) throw await res.json();

View file

@ -377,7 +377,7 @@
let generationController = null;
let contextCompactionToastId = null;
let chat = null;
let chat: any = null;
let tags = [];
// Read-only when viewing someone else's chat (e.g. via shared folder access)
@ -474,7 +474,7 @@
}
);
if ($chatId && !$temporaryChatEnabled && !isTemporaryChatId($chatId)) {
if (!readOnly && $chatId && !$temporaryChatEnabled && !isTemporaryChatId($chatId)) {
const res = await updateChatById(localStorage.token, $chatId, { params }).catch((err) => {
console.error('[tool permissions chat]', err);
return null;
@ -485,6 +485,7 @@
if (tool_approval_mode === 'full') {
const messages = [...Object.values(history?.messages ?? {})].reverse() as any[];
for (const message of messages) {
if ((message.user_id ?? chat?.user_id ?? $user?.id) !== $user?.id) continue;
const output = (Array.isArray(message?.output) ? message.output : []) as any[];
const resultCallIds = new Set(
output
@ -566,6 +567,7 @@
? createMessagesList(chatHistory, chatHistory.currentId)
: Object.values(chatHistory.messages);
for (const message of [...messages].reverse()) {
if ((message.user_id ?? chat?.user_id ?? $user?.id) !== $user?.id) continue;
const pending = getPendingAskUserFromMessage(message);
if (pending) return pending;
}
@ -733,6 +735,7 @@
const saveChatVariables = async (values) => {
chatVariables = { ...chatVariables, ...values };
if (readOnly) return;
if ($chatId && !$temporaryChatEnabled && !isTemporaryChatId($chatId)) {
const res = await updateChatById(localStorage.token, $chatId, {}, chatVariables).catch(
@ -1259,8 +1262,90 @@
}
};
let joinedChatId = '';
let chatRefresh: { id: string; promise: Promise<void> } | null = null;
const mergeChatMessages = (
incoming: typeof history,
baseline: Record<string, any> | null = null
) => {
const currentId = history.currentId;
const next = incoming?.messages ?? {};
const follow =
!generating &&
!(
taskIds?.length &&
Object.values(history.messages).some(
(message: any) =>
message.role === 'assistant' &&
message.done === false &&
(message.user_id ?? chat?.user_id) === $user?.id
)
) &&
Object.keys(next).some((id) => !history.messages[id]);
for (const [id, message] of Object.entries(next) as [string, any][]) {
const local = history.messages[id];
const ownActive =
local?.done === false &&
(local.user_id ?? chat?.user_id) === $user?.id &&
(taskIds?.length || generating);
if (!local || (!ownActive && (!baseline || baseline[id] === local))) {
history.messages[id] = { ...local, ...message };
}
if (local) {
history.messages[id].childrenIds = [
...new Set([...(local.childrenIds ?? []), ...(message.childrenIds ?? [])])
];
}
}
if ((!currentId || follow) && incoming.currentId && next[incoming.currentId]) {
history.currentId = incoming.currentId;
autoScrollToBottom();
}
history = history;
};
const refreshChat = () => {
const id = $chatId;
if (!id || $temporaryChatEnabled) return Promise.resolve();
if (chatRefresh?.id === id) return chatRefresh.promise;
const baseline = { ...history.messages };
const promise = (async () => {
try {
const fresh = await getChatById(localStorage.token, id);
if ($chatId !== id || joinedChatId !== id) return;
chat = fresh;
mergeChatMessages(fresh.chat.history, baseline);
} catch (error) {
if ($chatId === id && joinedChatId === id) {
chat = null;
history = { messages: {}, currentId: null };
if (!embedded) await goto('/');
}
} finally {
if (chatRefresh?.id === id) chatRefresh = null;
}
})();
chatRefresh = { id, promise };
return promise;
};
const syncChatRoom = (socket: typeof $socket, id: string, temporary: boolean) => {
const next = socket && id && !temporary && !isTemporaryChatId(id) ? id : '';
if (next === joinedChatId) return;
if (joinedChatId)
socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = next;
if (next)
socket?.emit('events:chat', { chat_id: next, data: { type: 'join' } }, (joined: boolean) => {
if (joined && $chatId === next) void refreshChat();
});
};
$: syncChatRoom($socket, $chatId, $temporaryChatEnabled);
const chatEventHandler = async (event, cb) => {
console.log(event);
if (event.shared && event.user_id === $user?.id && event.data?.type !== 'chat:messages') return;
// A new chat's title can arrive before its id; the response message already exists.
if (
@ -1269,6 +1354,18 @@
) {
await tick();
const type = event?.data?.type ?? null;
if (event.shared) {
if (type === 'chat:messages') {
mergeChatMessages(event.data.data);
return;
}
if (type === 'chat:access' || type === 'chat:active') {
if (type === 'chat:access' || event.data.data?.active === false) await refreshChat();
return;
}
if (!history.messages[event.message_id]) await refreshChat();
if ($chatId !== event.chat_id) return;
}
if (type === 'chat:reload') {
await loadChat();
return;
@ -1277,6 +1374,7 @@
return;
}
let message = history.messages[event.message_id];
if (message) message = { ...message };
if (message) {
const data = event?.data?.data ?? null;
@ -1308,17 +1406,17 @@
if (type === 'response:completion') {
responseCompletionEventHandler(data, message);
} else {
await chatCompletionEventHandler(data, message, event.chat_id);
await chatCompletionEventHandler(data, message, event.chat_id, event.shared);
}
autoScrollToBottom();
} else if (type === 'chat:tasks:cancel') {
message.bridgeCancelled = true;
bridgeCancellations.get(event.message_id)?.();
dismissContextCompactionToast();
if (!event.shared) dismissContextCompactionToast();
if (data?.output) {
message.output = data.output;
}
if (event.message_id === history.currentId) {
if (!event.shared && event.message_id === history.currentId) {
taskIds = null;
// Set all response messages to done
for (const messageId of history.messages[message.parentId].childrenIds) {
@ -1351,8 +1449,11 @@
}, 100);
} else if (type === 'chat:message:error') {
const responseCompleted = message.done;
handleOpenAIError(data.error, message);
if (!responseCompleted) {
if (event.shared) {
message.error = data.error;
message.done = true;
} else handleOpenAIError(data.error, message);
if (!event.shared && !responseCompleted) {
dismissContextCompactionToast();
if (event.message_id === history.currentId) {
await processNextInQueue(event.chat_id);
@ -1583,22 +1684,12 @@
);
const handleSocketConnect = async () => {
// Gate on $chatId, not chatIdProp: chats started from the home page keep an empty chatIdProp
if (!$chatId || $temporaryChatEnabled) {
return;
}
if (!hasPendingAssistantLeaf()) {
return;
}
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
.then((res) => res?.task_ids ?? [])
.catch(() => null);
if (pendingTaskIds?.length === 0) {
await loadChat();
}
if (!$chatId || $temporaryChatEnabled) return;
joinedChatId = '';
syncChatRoom($socket, $chatId, $temporaryChatEnabled);
const id = $chatId;
const tasks = await getTaskIdsByChatId(localStorage.token, id).catch(() => null);
if ($chatId === id && tasks) taskIds = tasks.task_ids?.length ? tasks.task_ids : null;
};
onMount(() => {
@ -1701,6 +1792,9 @@
// Clear the selected chat when leaving the chat surface (e.g. navigating
// to the admin panel), otherwise the previously-viewed chat stays selected
// in the sidebar and deleting/archiving it wrongly navigates away.
if (joinedChatId)
$socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = '';
chatId.set('');
chatTitle.set('');
@ -2634,6 +2728,7 @@
for (const message of Object.values(history.messages)) {
if (
message?.role === 'assistant' &&
(message.user_id ?? chat?.user_id) === $user?.id &&
!message.done &&
!messageHasPendingAskUser(message)
) {
@ -2911,6 +3006,7 @@
parentId: userMessageId,
childrenIds: [],
role: 'assistant',
user_id: $user?.id,
content: `[RESPONSE] ${responseMessageId}`,
done: true,
@ -3027,10 +3123,13 @@
history = history;
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const chatCompletionEventHandler = async (data, message, chatId, shared = false) => {
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
if (error) handleOpenAIError(error, message);
if (error) {
if (shared) message.error = error;
else handleOpenAIError(error, message);
}
// Store raw OR-aligned output items from backend
if (output) {
@ -3038,12 +3137,13 @@
message.content = getOutputText(output);
if (
data.type === 'response.output_text.delta' &&
!shared &&
navigator.vibrate &&
$settings?.hapticFeedback
) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
if (!shared) dispatchCallOverlayAudio(message);
}
if (sources && !message?.sources) {
@ -3054,7 +3154,7 @@
if (choices[0]?.message?.content) {
// Non-stream response
message.content += choices[0]?.message?.content;
dispatchCallOverlayAudio(message);
if (!shared) dispatchCallOverlayAudio(message);
} else {
// Stream response
let value = choices[0]?.delta?.content ?? '';
@ -3063,10 +3163,10 @@
} else {
message.content += value;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
if (!shared && navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
if (!shared) dispatchCallOverlayAudio(message);
}
}
}
@ -3075,10 +3175,10 @@
// REALTIME_CHAT_SAVE is disabled
message.content = content;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
if (!shared && navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
if (!shared) dispatchCallOverlayAudio(message);
}
if (selected_model_id) {
@ -3095,6 +3195,7 @@
if (done) {
message.done = true;
if (shared) return;
if (message.error) {
dismissContextCompactionToast();
bridge?.update();
@ -3178,6 +3279,7 @@
parentId: history.currentId ?? null,
childrenIds: [],
role: 'user',
user_id: $user?.id,
content: inputContent,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
@ -3538,6 +3640,7 @@
id: responseMessageId,
childrenIds: [],
role: 'assistant',
user_id: $user?.id,
content: '',
done: false,
model: model.id,
@ -4024,6 +4127,8 @@
const stopResponse = async (processQueue = true, messageId = history.currentId) => {
const responseMessage = messageId ? history.messages[messageId] : null;
if (responseMessage && (responseMessage.user_id ?? chat?.user_id ?? $user?.id) !== $user?.id)
return;
if (bridge?.connected && responseMessage) responseMessage.bridgeStopping = true;
const hasTaskIds = (taskIds?.length ?? 0) > 0;
const hasPendingAssistantResponse =
@ -4268,6 +4373,7 @@
};
const saveChatHandler = async (_chatId, history) => {
if (readOnly) return;
if ($chatId == _chatId) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
@ -4282,6 +4388,7 @@
};
const saveControls = async () => {
if (readOnly) return;
if (!$chatId || $temporaryChatEnabled) return;
const loaded = chat?.chat ?? {};
if (equal(params, loaded.params ?? {}) && equal(chatFiles, loaded.files ?? [])) return;
@ -4670,6 +4777,7 @@
chatId={$chatId}
user={chatOwner ?? $user}
{readOnly}
shareMode={chat?.chat?.share_mode ?? null}
bind:history
bind:autoScroll
bind:prompt
@ -4698,7 +4806,7 @@
</div>
</div>
{#if readOnly}
{#if readOnly && chat?.chat?.share_mode !== 'continue'}
{#if canClone}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-5 pt-10"

View file

@ -45,6 +45,7 @@
export let forkHandler: Function | null = null;
export let readOnly = false;
export let shareMode: 'continue' | null = null;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -530,6 +531,7 @@
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/each}
</ul>

View file

@ -43,6 +43,7 @@
export let forkHandler: Function | null = null;
export let triggerScroll;
export let readOnly = false;
export let shareMode: 'continue' | null = null;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -67,7 +68,7 @@
{#if history.messages[messageId]}
{#if history.messages[messageId].role === 'user'}
<UserMessage
{user}
user={history.messages[messageId].user ?? user}
{chatId}
{history}
{messageId}
@ -118,6 +119,7 @@
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{:else}
{#key messageId}
@ -148,6 +150,7 @@
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/key}
{/if}

View file

@ -33,6 +33,7 @@
export let isLastMessage;
export let readOnly = false;
export let shareMode: 'continue' | null = null;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -312,6 +313,7 @@
{compactPreview}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/if}
{/key}
@ -377,6 +379,7 @@
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/if}
{/key}

View file

@ -69,6 +69,7 @@
import { getOutputText, replaceOutputMessageText, type OutputItem } from './structuredOutput';
interface MessageType {
user_id?: string;
id: string;
model: string;
content: string;
@ -172,6 +173,7 @@
export let isLastMessage = true;
export let readOnly = false;
export let shareMode: 'continue' | null = null;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -849,10 +851,11 @@
floatingButtons={message?.done &&
!readOnly &&
($settings?.showFloatingActionButtons ?? true)}
save={!readOnly}
save={(!readOnly && (!message.user_id || message.user_id === $user?.id)) ||
(shareMode === 'continue' && message.user_id === $user?.id)}
preview={!readOnly}
{compactPreview}
{editCodeBlock}
editCodeBlock={!readOnly && editCodeBlock}
{topPadding}
done={message?.done ?? false}
allowEmbeds={!readOnly}
@ -1253,8 +1256,8 @@
</Tooltip>
{/if}
{#if !readOnly}
{#if !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true) && ($user?.role === 'admin' || ($user?.permissions?.chat?.rate_response ?? true))}
{#if (!readOnly && (!message.user_id || message.user_id === $user?.id)) || (shareMode === 'continue' && message.user_id === $user?.id)}
{#if !readOnly && !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true) && ($user?.role === 'admin' || ($user?.permissions?.chat?.rate_response ?? true))}
<Tooltip content={$i18n.t('Good Response')} placement="bottom">
<button
aria-label={$i18n.t('Good Response')}
@ -1481,7 +1484,7 @@
{/if}
{/if}
{#each model?.actions ?? [] as action}
{#each (!readOnly && model?.actions) || [] as action}
<Tooltip
content={resolveLocalizedFunction(
action,

View file

@ -1,4 +1,6 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { getContext, onMount } from 'svelte';
import { models, config, user } from '$lib/stores';
@ -6,6 +8,7 @@
import {
deleteSharedChatById,
getChatById,
getChatByShareId,
shareChatById,
getChatAccessGrants,
updateChatAccessGrants
@ -19,15 +22,16 @@
export let chatId;
let chat = null;
let chat: any = null;
let shareUrl = null;
let shareMode: 'continue' | null = null;
let accessGrants: any[] = [];
const i18n = getContext('i18n');
const i18n = getContext<Writable<i18nType>>('i18n');
const shareLocalChat = async () => {
const _chat = chat;
const sharedChat = await shareChatById(localStorage.token, chatId);
const sharedChat = await shareChatById(localStorage.token, chatId, shareMode);
shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`;
console.log(shareUrl);
chat = await getChatById(localStorage.token, chatId);
@ -69,6 +73,9 @@
if (!chatId) return;
try {
accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? [];
shareMode = chat?.share_id
? ((await getChatByShareId(localStorage.token, chat.share_id))?.chat?.share_mode ?? null)
: null;
} catch (e) {
console.error('Failed to load access grants', e);
accessGrants = [];
@ -77,10 +84,14 @@
const saveAccessGrants = async () => {
try {
await updateChatAccessGrants(localStorage.token, chatId, accessGrants);
if (!chat.share_id) {
await shareLocalChat();
}
await updateChatAccessGrants(localStorage.token, chatId, accessGrants, shareMode);
toast.success($i18n.t('Access updated'));
} catch (e) {
toast.error(`${e}`);
await loadAccessGrants();
}
};
@ -150,27 +161,37 @@
</button>
{$i18n.t('and create a new shared link.')}
{:else}
{$i18n.t(
"Messages you send after creating your link won't be shared. Your link is private until you choose who can view it."
)}
{$i18n.t('Your link is private until you choose who can view it.')}
{/if}
</div>
{#if chat.share_id}
<div class="mt-3">
<AccessControl
bind:accessGrants
accessRoles={['read']}
sharePublic={$user?.permissions?.sharing?.public_chats || $user?.role === 'admin'}
shareOpen={$user?.permissions?.sharing?.open_chats || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
allowGroups={($user?.permissions?.access_grants?.allow_groups ?? true) ||
$user?.role === 'admin'}
onChange={saveAccessGrants}
/>
</div>
{/if}
<div class="mt-3">
<AccessControl
bind:accessGrants
accessRoles={['read']}
sharePublic={$user?.permissions?.sharing?.public_chats || $user?.role === 'admin'}
shareOpen={$user?.permissions?.sharing?.open_chats || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
allowGroups={($user?.permissions?.access_grants?.allow_groups ?? true) ||
$user?.role === 'admin'}
onChange={saveAccessGrants}
>
{#if accessGrants.length > 0}
<label class="flex w-full items-center justify-between mt-3 mb-1 text-xs">
<span>{$i18n.t('Sharing mode')}</span>
<select
class="bg-transparent text-xs outline-none"
bind:value={shareMode}
on:change={saveAccessGrants}
>
<option value={null}>{$i18n.t('Clone only')}</option>
<option value="continue">{$i18n.t('Allow replies')}</option>
</select>
</label>
{/if}
</AccessControl>
</div>
<div class="flex justify-end gap-1 mt-3">
{#if $config?.features.enable_community_sharing}

View file

@ -1,6 +1,8 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<Writable<i18nType>>('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
@ -20,6 +22,7 @@
let accessGrants: AccessGrant[] = [];
let loading = false;
let shareMode: 'continue' | null = null;
// Fetch fresh folder data (with access_grants) when modal opens
$: if (show && folder?.id) {
@ -32,6 +35,7 @@
const freshFolder = await getFolderById(localStorage.token, folder.id);
if (freshFolder) {
accessGrants = freshFolder.access_grants ?? [];
shareMode = freshFolder.data?.share_mode ?? null;
}
} catch (e) {
console.error('Failed to load folder access grants', e);
@ -44,12 +48,18 @@
const handleAccessChange = async () => {
if (!folder) return;
try {
const res = await updateFolderAccessById(localStorage.token, folder.id, accessGrants);
const res = await updateFolderAccessById(
localStorage.token,
folder.id,
accessGrants,
shareMode
);
if (res) {
accessGrants = res.access_grants ?? accessGrants;
}
} catch (e) {
console.error('Failed to update folder access', e);
await loadAccessGrants();
}
};
</script>
@ -81,7 +91,21 @@
shareUsers={$user?.role === 'admin' || $user?.permissions?.access_grants?.allow_users}
allowGroups={$user?.role === 'admin' ||
($user?.permissions?.access_grants?.allow_groups ?? true)}
/>
>
{#if accessGrants.length > 0}
<label class="flex w-full items-center justify-between mt-3 mb-1 text-xs">
<span>{$i18n.t('Sharing mode')}</span>
<select
class="bg-transparent text-xs outline-none"
bind:value={shareMode}
on:change={handleAccessChange}
>
<option value={null}>{$i18n.t('Clone only')}</option>
<option value="continue">{$i18n.t('Allow replies')}</option>
</select>
</label>
{/if}
</AccessControl>
</div>
</div>
</Modal>

View file

@ -565,6 +565,8 @@
{/if}
</div>
<slot />
{#if share}
<div class="flex items-center justify-between text-xs font-normal text-gray-500 my-0.5">
<div>

View file

@ -579,6 +579,7 @@
};
const chatEventHandler = async (event, cb) => {
if (event.shared) return;
// Answer this session's availability check even when another chat is active.
if (
event?.data?.type === 'request:terminal:state' &&

View file

@ -106,6 +106,11 @@
await chatId.set(shareId);
chat = await getChatByShareId(token, shareId).catch(() => null);
if (chat?.chat?.share_mode === 'continue' && chat.id !== shareId) {
await goto(`/c/${chat.id}`, { replaceState: true });
return;
}
if (chat) {
user = token
? await getUserInfoById(token, chat.user_id).catch((error) => {