diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 636b66243..32d68caa6 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -116,7 +116,7 @@ from open_webui.internal.db import ScopedSession, engine, get_async_session from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel, Users -from open_webui.models.chats import Chats +from open_webui.models.chats import Chats, ChatForm from open_webui.config import ( # Ollama @@ -1693,13 +1693,30 @@ async def chat_completion( if model_info_params.get('reasoning_tags') is not None: reasoning_tags = model_info_params.get('reasoning_tags') + # parent_id signals intent: + # null → new chat (root message, no parent) + # value → follow-up (user message's parentId = prev assistant) + # absent → legacy caller, no chat management + is_new_chat = 'parent_id' in form_data and form_data['parent_id'] is None and not form_data.get('chat_id') + parent_id = form_data.pop('parent_id', None) + form_data.pop('new_chat', None) # Legacy field + + # Multi-model: {model_id: assistant_message_id} + # Single-model fallback: built from 'model' + 'id' + message_ids = form_data.pop('message_ids', None) + if not message_ids: + message_ids = {model_id: form_data.pop('id', None)} + else: + form_data.pop('id', None) + + user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) metadata = { 'user_id': user.id, 'chat_id': form_data.pop('chat_id', None), - 'message_id': form_data.pop('id', None), - 'parent_message': form_data.pop('parent_message', None), - 'parent_message_id': form_data.pop('parent_id', None), + 'user_message': user_message, + 'user_message_id': user_message.get('id') if user_message else None, 'session_id': form_data.pop('session_id', None), + 'folder_id': form_data.pop('folder_id', None), 'filter_ids': form_data.pop('filter_ids', []), 'tool_ids': form_data.get('tool_ids', None), 'tool_servers': form_data.pop('tool_servers', None), @@ -1722,36 +1739,160 @@ async def chat_completion( }, } + if is_new_chat: + metadata['chat_id'] = str(uuid4()) + if metadata.get('chat_id') and user: - if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored - # Verify chat ownership — lightweight EXISTS check avoids - # deserializing the full chat JSON blob just to confirm the row exists - if ( - not await Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin' - ): # admins can access any chat - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.DEFAULT(), + chat_id = metadata['chat_id'] + if not chat_id.startswith('local:'): # temporary chats are not stored + if is_new_chat: + # Build the full history upfront with ALL assistant placeholders + user_message = metadata.get('user_message') or {} + user_message_id = user_message.get('id') if user_message else None + + history_messages = {} + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id] + + if user_message_id and user_message: + user_message['childrenIds'] = all_assistant_ids + history_messages[user_message_id] = user_message + + for target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + history_messages[assistant_message_id] = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + } + + await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'New Chat', + 'models': list(message_ids.keys()), + 'history': { + 'currentId': all_assistant_ids[0] if all_assistant_ids else user_message_id, + 'messages': history_messages, + }, + 'messages': [ + {'role': 'user', 'content': user_message.get('content', '')}, + ] if user_message_id else [], + 'tags': [], + 'timestamp': int(time.time() * 1000), + }, + folder_id=metadata.get('folder_id'), + ), ) - # Insert chat files from parent message if any - parent_message = metadata.get('parent_message') or {} - parent_message_files = parent_message.get('files', []) - if parent_message_files: - try: - await Chats.insert_chat_files( - metadata['chat_id'], - parent_message.get('id'), - [ - file_item.get('id') - for file_item in parent_message_files - if file_item.get('type') == 'file' - ], - user.id, + # 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_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(f'Error inserting chat files: {e}') + pass + else: + # Existing chat — verify ownership + if ( + not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.DEFAULT(), ) - except Exception as e: - log.debug(f'Error inserting chat files: {e}') - pass + + # Save user message to DB + user_message = metadata.get('user_message') or {} + 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, + ) + + # 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} + ) + + # 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(f'Error inserting chat files: {e}') + pass + + # Save ALL assistant placeholders + user_message_id = metadata.get('user_message_id') + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_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 target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + assistant_message_id, + { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + }, + ) request.state.metadata = metadata form_data['metadata'] = metadata @@ -1783,19 +1924,6 @@ async def chat_completion( except Exception: detail = f'Provider returned HTTP {response.status_code}' raise Exception(detail) - if metadata.get('chat_id') and metadata.get('message_id'): - try: - if not metadata['chat_id'].startswith('local:'): - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'parentId': metadata.get('parent_message_id', None), - 'model': model_id, - }, - ) - except Exception: - pass ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events) @@ -1824,7 +1952,7 @@ async def chat_completion( metadata['chat_id'], metadata['message_id'], { - 'parentId': metadata.get('parent_message_id', None), + 'parentId': metadata.get('user_message_id', None), 'error': {'content': str(e)}, }, ) @@ -1875,19 +2003,55 @@ async def chat_completion( except Exception as e: log.debug(f'Error emitting chat:active: {e}') - if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): - # Asynchronous Chat Processing - task_id, _ = await create_task( - request.app.state.redis, - process_chat(request, form_data, user, metadata, model), - id=metadata['chat_id'], - ) - # Emit chat:active=true when task starts - event_emitter = await get_event_emitter(metadata, update_db=False) - if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': True}}) - return {'status': True, 'task_id': task_id} + # Fan out: one task per model + if metadata.get('session_id') and metadata.get('chat_id'): + task_ids = [] + chat_id = metadata['chat_id'] + + for target_model_id, assistant_message_id in message_ids.items(): + if not assistant_message_id: + continue + + # Per-model metadata: own message_id + model + per_model_metadata = { + **metadata, + 'message_id': assistant_message_id, + } + + # Per-model form_data: own model + model_form_data = { + **form_data, + 'model': target_model_id, + 'metadata': per_model_metadata, + } + + # Resolve the model object for this specific model + resolved_model = request.app.state.MODELS.get(target_model_id, model) + + task_id, _ = await create_task( + request.app.state.redis, + process_chat(request, model_form_data, user, per_model_metadata, resolved_model), + id=chat_id, + ) + task_ids.append(task_id) + + # Emit chat:active=true + if task_ids: + event_emitter = await get_event_emitter( + {**metadata, 'message_id': list(message_ids.values())[0]}, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:active', 'data': {'active': True}}) + + return { + 'status': True, + 'task_ids': task_ids, + 'chat_id': chat_id, + } else: + # Legacy/direct: single model, synchronous + metadata['message_id'] = list(message_ids.values())[0] return await process_chat(request, form_data, user, metadata, model) @@ -1962,6 +2126,8 @@ async def generate_messages( @app.post('/api/chat/completed') async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)): + """Deprecated: outlet filters now run inline during chat completion. + Kept for backward compatibility with external integrations.""" try: model_item = form_data.pop('model_item', {}) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index b13edbf0b..3bcfdce03 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -293,10 +293,9 @@ class ChatTable: return changed async def insert_new_chat( - self, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None + self, id: str, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: async with get_async_db_context(db) as db: - id = str(uuid.uuid4()) chat = ChatModel( **{ 'id': id, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 979b9388c..1980d2236 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,6 +1,7 @@ import json import logging from typing import Optional +from uuid import uuid4 from sqlalchemy.ext.asyncio import AsyncSession import asyncio from fastapi.responses import StreamingResponse @@ -557,7 +558,7 @@ async def create_new_chat( db: AsyncSession = Depends(get_async_session), ): try: - chat = await Chats.insert_new_chat(user.id, form_data, db=db) + chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) return ChatResponse(**chat.model_dump()) except Exception as e: log.exception(e) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index a32b04bbf..3866eb865 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -307,8 +307,9 @@ async def execute_automation(app, automation: AutomationModel) -> None: user_msg_id = str(uuid4()) assistant_msg_id = str(uuid4()) - # Create the chat with user message (same structure as frontend) + chat_id = str(uuid4()) chat = await Chats.insert_new_chat( + chat_id, automation.user_id, ChatForm( chat={ @@ -378,7 +379,13 @@ async def execute_automation(app, automation: AutomationModel) -> None: 'stream': True, 'chat_id': chat.id, 'id': assistant_msg_id, - 'parent_id': user_msg_id, + 'parent_id': None, # Root message (chat already created above) + 'user_message': { + 'id': user_msg_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + }, 'session_id': f'automation:{automation.id}', 'background_tasks': {}, } diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 7613bd33d..878d5b3f2 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2153,10 +2153,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Load messages from DB when available — DB preserves structured 'output' items # which the frontend strips, causing tool calls to be merged into content. chat_id = metadata.get('chat_id') - parent_message_id = metadata.get('parent_message_id') + user_message_id = metadata.get('user_message_id') - if chat_id and parent_message_id and not chat_id.startswith('local:'): - db_messages = await load_messages_from_db(chat_id, parent_message_id) + if chat_id and user_message_id and not chat_id.startswith('local:'): + db_messages = await load_messages_from_db(chat_id, user_message_id) if db_messages: system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = [system_message, *db_messages] if system_message else db_messages @@ -3061,6 +3061,110 @@ async def background_tasks_handler(ctx): pass +async def outlet_filter_handler(ctx): + """Run outlet filters inline after chat completion. + + Replaces the separate POST /api/chat/completed round-trip. + Persists outlet-modified content to DB and emits a chat:outlet event + so the frontend can sync its in-memory state. + """ + request = ctx['request'] + user = ctx['user'] + model = ctx['model'] + metadata = ctx['metadata'] + event_emitter = ctx.get('event_emitter') + event_caller = ctx.get('event_caller') + + chat_id = metadata.get('chat_id', '') + message_id = metadata.get('message_id') + + if not chat_id or chat_id.startswith('local:') or not message_id: + return + + try: + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return + + message_list = get_message_list(messages_map, message_id) + if not message_list: + return + + model_id = model.get('id') if isinstance(model, dict) else model + + outlet_data = { + 'model': model_id, + 'messages': [ + { + 'id': m.get('id'), + 'role': m.get('role'), + 'content': m.get('content', ''), + 'info': m.get('info'), + 'timestamp': m.get('timestamp'), + **(({'usage': m['usage']} if m.get('usage') else {})), + **(({'sources': m['sources']} if m.get('sources') else {})), + } + for m in message_list + ], + 'filter_ids': metadata.get('filter_ids', []), + 'chat_id': chat_id, + 'session_id': metadata.get('session_id'), + 'id': message_id, + } + + # Pipeline outlet filters + models = request.app.state.MODELS + try: + outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models) + except Exception as e: + log.debug(f'Pipeline outlet filter error: {e}') + + # Function outlet filters + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__request__': request, + '__model__': model, + } + + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) + + outlet_result, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='outlet', + form_data=outlet_data, + extra_params=extra_params, + ) + + # Persist outlet-modified content and notify frontend + if outlet_result and outlet_result.get('messages'): + for msg in outlet_result['messages']: + msg_id = msg.get('id') + if msg_id and msg_id in messages_map: + original = messages_map[msg_id] + if original.get('content') != msg.get('content'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + msg_id, + { + 'content': msg['content'], + 'originalContent': original.get('content'), + }, + ) + + if event_emitter: + await event_emitter({ + 'type': 'chat:outlet', + 'data': {'messages': outlet_result['messages']}, + }) + except Exception as e: + log.debug(f'Error running outlet filters: {e}') + + async def non_streaming_chat_response_handler(response, ctx): request = ctx['request'] @@ -3182,6 +3286,7 @@ async def non_streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + await outlet_filter_handler(ctx) response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: @@ -4693,6 +4798,7 @@ async def streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + await outlet_filter_handler(ctx) except asyncio.CancelledError: log.warning('Task was cancelled!') try: diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 1421b4618..9618619f7 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -81,7 +81,6 @@ import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval'; import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users'; import { - chatCompleted, generateQueries, chatAction, generateMoACompletion, @@ -491,6 +490,22 @@ if (autoScroll) { scrollToBottom('smooth'); } + } else if (type === 'chat:outlet') { + // Outlet filter ran on backend — sync in-memory state + const outletMessages = data.messages ?? []; + for (const msg of outletMessages) { + if (msg?.id && history.messages[msg.id]) { + const existing = history.messages[msg.id]; + if (existing.content !== msg.content) { + history.messages[msg.id] = { + ...existing, + originalContent: existing.content, + ...msg + }; + } + } + } + history = history; } else if (type === 'chat:message:favorite') { // Update message favorite status message.favorite = data.favorite; @@ -1361,6 +1376,17 @@ taskIds = taskRes.task_ids; } + // If no active tasks and current message is incomplete, generation was interrupted + const currentMessage = history.currentId ? history.messages[history.currentId] : null; + if ( + currentMessage && + currentMessage.role === 'assistant' && + !currentMessage.done && + (!taskIds || taskIds.length === 0) + ) { + currentMessage.done = true; + } + await tick(); return true; @@ -1416,71 +1442,12 @@ }; const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => { - if (!responseMessageId) { - console.error('chatCompleted: missing message id', { - chatId: _chatId, - modelId, - messageCount: messages?.length ?? 0 - }); - return; + // Backend handles outlet filters and persistence inline. + // Just refresh the sidebar chat list. + if ($chatId == _chatId && !$temporaryChatEnabled) { + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); } - - const res = await chatCompleted(localStorage.token, { - model: modelId, - messages: messages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - info: m.info ? m.info : undefined, - timestamp: m.timestamp, - ...(m.usage ? { usage: m.usage } : {}), - ...(m.sources ? { sources: m.sources } : {}) - })), - filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, - model_item: $models.find((m) => m.id === modelId), - chat_id: _chatId, - session_id: $socket?.id, - id: responseMessageId - }).catch((error) => { - toast.error(`${error}`); - messages.at(-1).error = { content: error }; - - return null; - }); - - if (res !== null && res.messages) { - // Update chat history with the new messages - for (const message of res.messages) { - if (message?.id) { - // Add null check for message and message.id - history.messages[message.id] = { - ...history.messages[message.id], - ...(history.messages[message.id].content !== message.content - ? { originalContent: history.messages[message.id].content } - : {}), - ...message - }; - } - } - } - - await tick(); - - if ($chatId == _chatId) { - if (!$temporaryChatEnabled) { - chat = await updateChatById(localStorage.token, _chatId, { - models: selectedModels, - messages: messages, - history: history, - params: params, - files: chatFiles - }); - - currentChatPage.set(1); - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - } - } - taskIds = null; }; @@ -1894,7 +1861,7 @@ saveSessionSelectedModels(); - await sendMessage(history, userMessageId, { newChat: true }); + await sendMessage(history, userMessageId); }; const submitHandler = async (userPrompt, { _raw = false } = {}) => { @@ -1994,13 +1961,11 @@ { messages = null, modelId = null, - modelIdx = null, - newChat = false + modelIdx = null }: { messages?: any[] | null; modelId?: string | null; modelIdx?: number | null; - newChat?: boolean; } = {} ) => { if (autoScroll) { @@ -2019,6 +1984,8 @@ : selectedModels; // Create response messages for each selected model + // Build message_ids map: {model_id: assistant_message_id} + const messageIdsMap: Record = {}; for (const [_modelIdx, modelId] of selectedModelIds.entries()) { const model = $models.filter((m) => m.id === modelId).at(0); @@ -2043,7 +2010,6 @@ // Append messageId to childrenIds of parent message if (parentId !== null && history.messages[parentId]) { - // Add null check before accessing childrenIds history.messages[parentId].childrenIds = [ ...history.messages[parentId].childrenIds, responseMessageId @@ -2051,68 +2017,71 @@ } responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId; + messageIdsMap[modelId] = responseMessageId; } } history = history; - // Create new chat if newChat is true and first user message - if (newChat && _history.messages[_history.currentId].parentId === null) { - _chatId = await initChatHandler(_history); + // New chat — backend generates the chat_id on first request + if (!_chatId) { + if ($temporaryChatEnabled) { + _chatId = `local:${$socket?.id}`; + await chatId.set(_chatId); + } + await tick(); } await tick(); + // Re-clone history so sendMessageSocket gets the response messages we just added _history = structuredClone(history); - // Save chat after all messages have been created - await saveChatHandler(_chatId, _history); - await Promise.all( - selectedModelIds.map(async (modelId, _modelIdx) => { - console.log('modelId', modelId); - const model = $models.filter((m) => m.id === modelId).at(0); + // Vision capability check + for (const mid of selectedModelIds) { + const model = $models.filter((m) => m.id === mid).at(0); + if (model) { + const hasImages = createMessagesList(_history, parentId).some((message) => + message.files?.some( + (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + ) + ); - if (model) { - // If there are image files, check if model is vision capable - // Skip this check if image generation is enabled, as images may be for editing or are generated outputs in the history - const hasImages = createMessagesList(_history, parentId).some((message) => - message.files?.some( - (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') - ) + if ( + hasImages && + !(model.info?.meta?.capabilities?.vision ?? true) && + !imageGenerationEnabled + ) { + toast.error( + $i18n.t('Model {{modelName}} is not vision capable', { + modelName: model.name ?? model.id + }) ); - - if ( - hasImages && - !(model.info?.meta?.capabilities?.vision ?? true) && - !imageGenerationEnabled - ) { - toast.error( - $i18n.t('Model {{modelName}} is not vision capable', { - modelName: model.name ?? model.id - }) - ); - } - - let responseMessageId = - responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`]; - const chatEventEmitter = await getChatEventEmitter(model.id, _chatId); - - scrollToBottom(); - await sendMessageSocket( - model, - messages && messages.length > 0 - ? messages - : createMessagesList(_history, responseMessageId), - _history, - responseMessageId, - _chatId - ); - - if (chatEventEmitter) clearInterval(chatEventEmitter); - } else { - toast.error($i18n.t(`Model {{modelId}} not found`, { modelId })); } - }) - ); + } + } + + // Single request — backend fans out to all models + const primaryModelId = selectedModelIds[0]; + const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0); + const primaryResponseMessageId = messageIdsMap[primaryModelId]; + + if (primaryModel && primaryResponseMessageId) { + const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId); + + scrollToBottom(); + await sendMessageSocket( + primaryModel, + messages && messages.length > 0 + ? messages + : createMessagesList(_history, primaryResponseMessageId), + _history, + primaryResponseMessageId, + _chatId, + selectedModelIds.length > 1 ? messageIdsMap : undefined + ); + + if (chatEventEmitter) clearInterval(chatEventEmitter); + } }; const getFeatures = () => { @@ -2167,7 +2136,7 @@ .map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`))); }; - const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => { + const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId, messageIdsMap?: Record) => { const responseMessage = _history.messages[responseMessageId]; const userMessage = _history.messages[responseMessage.parentId]; @@ -2357,12 +2326,13 @@ model_item: $models.find((m) => m.id === model.id), session_id: $socket?.id, - chat_id: $chatId, + chat_id: _chatId || undefined, folder_id: $selectedFolder?.id ?? undefined, id: responseMessageId, - parent_id: userMessage?.id ?? null, - parent_message: userMessage, + ...(messageIdsMap ? { message_ids: messageIdsMap } : {}), + parent_id: userMessage?.parentId ?? null, + user_message: userMessage, background_tasks: { ...(!$temporaryChatEnabled && @@ -2419,10 +2389,22 @@ if (res.error) { await handleOpenAIError(res.error, responseMessage); } else { + // Backend returns task_ids (multi-model) or task_id (single model) + const newTaskIds = res.task_ids ?? (res.task_id ? [res.task_id] : []); if (taskIds) { - taskIds.push(res.task_id); + taskIds.push(...newTaskIds); } else { - taskIds = [res.task_id]; + taskIds = newTaskIds; + } + + // Backend returns chat_id for new chats — set store + URL + if (res.chat_id && $chatId !== res.chat_id) { + await chatId.set(res.chat_id); + if (!$temporaryChatEnabled) { + window.history.replaceState(history.state, '', `/c/${res.chat_id}`); + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + } } } } diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 465d0d1e0..55a3200ca 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -88,10 +88,21 @@ let mouseOver = false; + // Local state: tracks the last updatedAt seen while the user was viewing + // this chat. Survives prop refreshes from sidebar data re-fetches that + // would overwrite the `lastReadAt` prop with a stale server value. + let viewedAt: number | null = null; + + $: if (id === $chatId) { + viewedAt = updatedAt ?? Date.now() / 1000; + } + + $: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null; + $: unread = id !== $chatId && !$activeChatIds.has(id) && - (lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt)); + (effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt)); const loadChat = async () => { if (!chat) {