diff --git a/libs/mobile/chat/features/chat/src/lib/components/messages-list/component.tsx b/libs/mobile/chat/features/chat/src/lib/components/messages-list/component.tsx index da3b8a6..08cecee 100644 --- a/libs/mobile/chat/features/chat/src/lib/components/messages-list/component.tsx +++ b/libs/mobile/chat/features/chat/src/lib/components/messages-list/component.tsx @@ -166,12 +166,20 @@ export default function ChatMessagesList({ } as Chat, }); + // NOTE: The backend links the (existing) assistant message's parentId from `user_message`. + // Passing the parent user turn + `assistantMessageId` keeps that link intact — otherwise the + // backend nulls parentId and the user message drops out of the rendered branch. + const assistantMessage = history?.messages[messageId]; + const userMessage = assistantMessage?.parentId ? history?.messages[assistantMessage.parentId] : undefined; + const completePayload = prepareCompleteChatPayload({ chatId, messages, - messageId: messageId, + messageId, sessionId: socketService.socketSessionId, model: modelId, + userMessage, + assistantMessageId: messageId, }); completeChat(completePayload); }; diff --git a/libs/shared/data-access/api/src/lib/chats/api.ts b/libs/shared/data-access/api/src/lib/chats/api.ts index a484891..f75d47d 100644 --- a/libs/shared/data-access/api/src/lib/chats/api.ts +++ b/libs/shared/data-access/api/src/lib/chats/api.ts @@ -16,7 +16,11 @@ import { ApiErrorData } from '@open-webui-react-native/shared/data-access/api-cl import { getNextPageParam, Role } from '@open-webui-react-native/shared/data-access/common'; import { refetchOnMountWithStaleCheck } from '@open-webui-react-native/shared/data-access/persist-query-helpers'; import { queryClient } from '@open-webui-react-native/shared/data-access/query-client'; -import { useSubscribeToEvent, WebSocketEventName } from '@open-webui-react-native/shared/data-access/websocket'; +import { + getOutputText, + useSubscribeToEvent, + WebSocketEventName, +} from '@open-webui-react-native/shared/data-access/websocket'; import { foldersApiConfig } from '../folders'; import { archivedChatListQueryKey } from './archived-chat-list-query-keys'; import { chatQueriesKeys } from './chat-queries-keys'; @@ -103,6 +107,12 @@ export const getChatQueryOptions = ( for (const message of Object.values(messages)) { if (message.role === Role.ASSISTANT) { message.done = true; + + // NOTE: Backends on Open WebUI 0.11.0+ may return assistant text only in the `output` + // array. Derive `content` from it when absent so both old and new formats render. + if (!message.content) { + message.content = getOutputText(message.output); + } } } diff --git a/libs/shared/data-access/api/src/lib/chats/models/complete-chat-request.ts b/libs/shared/data-access/api/src/lib/chats/models/complete-chat-request.ts index 154856b..1b1e486 100644 --- a/libs/shared/data-access/api/src/lib/chats/models/complete-chat-request.ts +++ b/libs/shared/data-access/api/src/lib/chats/models/complete-chat-request.ts @@ -3,6 +3,7 @@ import { AttachedFile } from '@open-webui-react-native/shared/data-access/common import { BackgroundTasks } from './background-tasks'; import { ChatMessage } from './chat-message'; import { Features } from './features'; +import { Message } from './message'; export class CompleteChatRequest { @Expose() @@ -39,6 +40,22 @@ export class CompleteChatRequest { @Expose({ name: 'session_id' }) public sessionId: string; + // NOTE: Since the backend now owns message persistence, it derives the assistant message's + // parentId from `user_message`. Without it the backend links the assistant to `null`, + // orphaning the parent user message. Sent for both new turns and "Continue Response". + @Expose({ name: 'user_message' }) + @Type(() => Message) + public userMessage?: Message; + + // parentId of the user message (grandparent link on the backend) + @Expose({ name: 'parent_id' }) + public parentId?: string | null; + + // Set only for "Continue Response" so the backend keeps the existing assistant message + // (instead of nulling its parentId) and feeds its prior text back to the model. + @Expose({ name: 'assistant_message_id' }) + public assistantMessageId?: string; + constructor(request: Partial = {}) { Object.assign(this, request); } diff --git a/libs/shared/data-access/api/src/lib/chats/models/message.ts b/libs/shared/data-access/api/src/lib/chats/models/message.ts index 6cab95c..7b6b460 100644 --- a/libs/shared/data-access/api/src/lib/chats/models/message.ts +++ b/libs/shared/data-access/api/src/lib/chats/models/message.ts @@ -7,7 +7,7 @@ import { MessageSource, Role, } from '@open-webui-react-native/shared/data-access/common'; -import { ChatStatusData } from '@open-webui-react-native/shared/data-access/websocket'; +import { ChatCompletionOutputItem, ChatStatusData } from '@open-webui-react-native/shared/data-access/websocket'; export class Message extends BaseEntity { @Expose({ name: 'user_id' }) @@ -22,6 +22,13 @@ export class Message extends BaseEntity { @Expose() public content: string; + // NOTE: Open WebUI 0.11.0+ persists assistant text as an `output` array (Responses API + // format) in addition to `content`. Kept so the client can derive text itself and stay + // compatible with both the old (flat `content`) and new (`output`) backend formats. + @Expose() + @Type(() => ChatCompletionOutputItem) + public output?: Array; + @Expose() public childrenIds?: Array; diff --git a/libs/shared/data-access/api/src/lib/chats/utils/handle-completed-chat.ts b/libs/shared/data-access/api/src/lib/chats/utils/handle-completed-chat.ts index 47794ee..33cecb3 100644 --- a/libs/shared/data-access/api/src/lib/chats/utils/handle-completed-chat.ts +++ b/libs/shared/data-access/api/src/lib/chats/utils/handle-completed-chat.ts @@ -11,6 +11,7 @@ export const handleCompletedChat = async ( chatId: string, sessionId: string, sources?: Array, + output?: Message['output'], ): Promise => { const chatData = queryClient.getQueryData(chatQueriesKeys.get(chatId).queryKey); @@ -20,8 +21,11 @@ export const handleCompletedChat = async ( const { chat } = chatData; + // NOTE: Persist `output` alongside `content`. The backend replaces the whole message object on + // save (merge_history), so dropping `output` here wipes it server-side — which breaks + // "Continue Response" (the backend seeds continuation from the stored `output`). const updatedMessages = chat.messages.map((msg, i, arr) => - i === arr.length - 1 ? { ...msg, content: message, done: true, sources } : msg, + i === arr.length - 1 ? { ...msg, content: message, output: output ?? msg.output, done: true, sources } : msg, ); const updatedMessageMap: Record = Object.fromEntries(updatedMessages.map((msg) => [msg.id, msg])); diff --git a/libs/shared/data-access/api/src/lib/chats/utils/patch-chat-message-with-completion.ts b/libs/shared/data-access/api/src/lib/chats/utils/patch-chat-message-with-completion.ts index fbaf798..9b1d74e 100644 --- a/libs/shared/data-access/api/src/lib/chats/utils/patch-chat-message-with-completion.ts +++ b/libs/shared/data-access/api/src/lib/chats/utils/patch-chat-message-with-completion.ts @@ -5,6 +5,7 @@ export function patchChatMessagesWithCompletion( oldData: ChatResponse | undefined, newContent: string, sources?: Array, + output?: Message['output'], ): ChatResponse | undefined { if ( !oldData || @@ -23,6 +24,7 @@ export function patchChatMessagesWithCompletion( const updatedLastMessage: Message = { ...lastMessage, content: newContent, + output: output ?? lastMessage.output, sources: sources, socketStatusData: history.messages[lastMessage.id]?.socketStatusData, }; diff --git a/libs/shared/data-access/api/src/lib/chats/utils/prepare-complete-chat-payload.ts b/libs/shared/data-access/api/src/lib/chats/utils/prepare-complete-chat-payload.ts index b78e2c9..0f4348d 100644 --- a/libs/shared/data-access/api/src/lib/chats/utils/prepare-complete-chat-payload.ts +++ b/libs/shared/data-access/api/src/lib/chats/utils/prepare-complete-chat-payload.ts @@ -11,6 +11,11 @@ export interface PrepareCompleteChatPayloadArgs { sessionId: string; model: string; generationOptions?: Array; + // The parent user message of the assistant turn being generated. The backend uses it to + // link the assistant's parentId; omitting it orphans the user message (drops from the branch). + userMessage?: Message; + // Set only for "Continue Response": id of the existing assistant message to keep and extend. + assistantMessageId?: string; } export function prepareCompleteChatPayload({ @@ -20,6 +25,8 @@ export function prepareCompleteChatPayload({ sessionId, model, generationOptions, + userMessage, + assistantMessageId, }: PrepareCompleteChatPayloadArgs): CompleteChatRequest { const prepareChatMessages = (): Array => { return messages.map((message) => { @@ -74,6 +81,9 @@ export function prepareCompleteChatPayload({ id: messageId, sessionId, files, + userMessage, + parentId: userMessage?.parentId ?? null, + assistantMessageId, }); return request; diff --git a/libs/shared/data-access/api/src/lib/chats/utils/socket-events/handle-chat-completion-event.ts b/libs/shared/data-access/api/src/lib/chats/utils/socket-events/handle-chat-completion-event.ts index ac69ef3..f92468f 100644 --- a/libs/shared/data-access/api/src/lib/chats/utils/socket-events/handle-chat-completion-event.ts +++ b/libs/shared/data-access/api/src/lib/chats/utils/socket-events/handle-chat-completion-event.ts @@ -3,6 +3,7 @@ import { queryClient } from '@open-webui-react-native/shared/data-access/query-c import { ChatEventBase, ChatCompletionChunk, + getOutputText, socketService, } from '@open-webui-react-native/shared/data-access/websocket'; import { chatQueriesKeys } from '../../chat-queries-keys'; @@ -15,6 +16,9 @@ import { patchCompletedMessage } from '../patch-completed-message'; const sourcesStore: Record = {}; const flushScheduled: Record = {}; const contentBuffer: Record = {}; +// NOTE: Buffer the raw `output` array so it can be persisted on the message. The backend seeds +// "Continue Response" from the stored `output`; if we drop it, continue starts from scratch. +const outputBuffer: Record = {}; export const handleChatCompletionEvent = async (socketResponse: ChatEventBase): Promise => { const sessionId = socketService.socketSessionId; @@ -26,31 +30,43 @@ export const handleChatCompletionEvent = async (socketResponse: ChatEventBase): sourcesStore[chatId] = chatCompletionData.sources; } - if (!chatCompletionData?.content) return; - contentBuffer[chatId] = chatCompletionData.content; + // NOTE: Since Open WebUI 0.11.0 the completion stream delivers assistant text inside an + // `output` array (Responses API format) instead of a flat `content` string. Fall back to it + // so streamed content renders and — importantly — the terminal `done` event below still runs. + const content = chatCompletionData.content || getOutputText(chatCompletionData.output); + + if (chatCompletionData.output) { + outputBuffer[chatId] = chatCompletionData.output; + } const queryKey = chatQueriesKeys.get(chatId).queryKey; - const chatData = queryClient.getQueryData(queryKey); const storedSources = sourcesStore[chatId]; - // NOTE: Limit updates to once per frame (~16ms) because frequent streaming updates - // can cause UI unresponsiveness on low-end Android devices. - if (!flushScheduled[chatId] && chatData) { - flushScheduled[chatId] = true; + if (content) { + contentBuffer[chatId] = content; + + const chatData = queryClient.getQueryData(queryKey); + + // NOTE: Limit updates to once per frame (~16ms) because frequent streaming updates + // can cause UI unresponsiveness on low-end Android devices. + if (!flushScheduled[chatId] && chatData) { + flushScheduled[chatId] = true; - requestAnimationFrame(() => { - flushScheduled[chatId] = false; + requestAnimationFrame(() => { + flushScheduled[chatId] = false; - queryClient.setQueryData(queryKey, (oldData: ChatResponse) => - patchChatMessagesWithCompletion(oldData, contentBuffer[chatId], storedSources), - ); - }); + queryClient.setQueryData(queryKey, (oldData: ChatResponse) => + patchChatMessagesWithCompletion(oldData, contentBuffer[chatId], storedSources, outputBuffer[chatId]), + ); + }); + } } if (chatCompletionData.done) { delete sourcesStore[chatId]; queryClient.setQueryData(queryKey, (oldData: ChatResponse) => patchCompletedMessage(oldData)); - handleCompletedChat(chatCompletionData.content, socketResponse.chatId, sessionId, storedSources); + handleCompletedChat(contentBuffer[chatId] ?? content, chatId, sessionId, storedSources, outputBuffer[chatId]); + delete outputBuffer[chatId]; } }; diff --git a/libs/shared/data-access/websocket/src/models/chat-event-data/chat-completion-chunk.ts b/libs/shared/data-access/websocket/src/models/chat-event-data/chat-completion-chunk.ts index 5394985..5ebd7b3 100644 --- a/libs/shared/data-access/websocket/src/models/chat-event-data/chat-completion-chunk.ts +++ b/libs/shared/data-access/websocket/src/models/chat-event-data/chat-completion-chunk.ts @@ -1,6 +1,38 @@ import { Expose, Type } from 'class-transformer'; import { MessageSource } from '@open-webui-react-native/shared/data-access/common'; +// NOTE: Since Open WebUI 0.11.0 the completion stream delivers assistant text inside an +// `output` array (Responses API format) instead of a flat `content` string. Each `message` +// item carries `content` parts of type `output_text`. +export class ChatCompletionOutputContentPart { + @Expose() + public type?: string; + + @Expose() + public text?: string; +} + +export class ChatCompletionOutputItem { + @Expose() + public type?: string; + + // NOTE: These are echoed back to the backend when persisting the assistant message so that + // "Continue Response" can seed generation from the prior `output` (see handle-completed-chat). + // Kept faithful to the Responses-API item shape. + @Expose() + public id?: string; + + @Expose() + public role?: string; + + @Expose() + public status?: string; + + @Expose() + @Type(() => ChatCompletionOutputContentPart) + public content?: Array; +} + export class ChatCompletionChunk { @Expose() public id: string; @@ -8,6 +40,10 @@ export class ChatCompletionChunk { @Expose() public content: string; + @Expose() + @Type(() => ChatCompletionOutputItem) + public output?: Array; + @Expose() public created?: number; diff --git a/libs/shared/data-access/websocket/src/models/chat-event-data/get-output-text.ts b/libs/shared/data-access/websocket/src/models/chat-event-data/get-output-text.ts new file mode 100644 index 0000000..e411b16 --- /dev/null +++ b/libs/shared/data-access/websocket/src/models/chat-event-data/get-output-text.ts @@ -0,0 +1,26 @@ +import { ChatCompletionOutputItem } from './chat-completion-chunk'; + +// NOTE: Mirrors the backend `get_output_text` (open_webui/utils/misc.py). Concatenates the +// text of every `message` output item, joining separate messages with a newline. Non-message +// items (reasoning, tool calls, etc.) are ignored, matching the backend behavior. +export const getOutputText = (output?: Array): string => { + if (!Array.isArray(output)) { + return ''; + } + + const texts: Array = []; + + for (const item of output) { + if (item?.type !== 'message' || !Array.isArray(item.content)) { + continue; + } + + const text = item.content.map((part) => (part?.text != null ? String(part.text) : '')).join(''); + + if (text.trim()) { + texts.push(text); + } + } + + return texts.join('\n'); +}; diff --git a/libs/shared/data-access/websocket/src/models/chat-event-data/index.ts b/libs/shared/data-access/websocket/src/models/chat-event-data/index.ts index 6f57da1..aa1ba2f 100644 --- a/libs/shared/data-access/websocket/src/models/chat-event-data/index.ts +++ b/libs/shared/data-access/websocket/src/models/chat-event-data/index.ts @@ -1,5 +1,6 @@ export * from './chat-event-data'; export * from './chat-completion-chunk'; +export * from './get-output-text'; export * from './chat-title-data'; export * from './chat-status-data'; export * from './chat-files-data';