Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
12 changes: 11 additions & 1 deletion libs/shared/data-access/api/src/lib/chats/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<CompleteChatRequest> = {}) {
Object.assign(this, request);
}
Expand Down
9 changes: 8 additions & 1 deletion libs/shared/data-access/api/src/lib/chats/models/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
@Expose({ name: 'user_id' })
Expand All @@ -22,6 +22,13 @@ export class Message extends BaseEntity<string> {
@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<ChatCompletionOutputItem>;

@Expose()
public childrenIds?: Array<string>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const handleCompletedChat = async (
chatId: string,
sessionId: string,
sources?: Array<MessageSource>,
output?: Message['output'],
): Promise<void> => {
const chatData = queryClient.getQueryData<ChatResponse>(chatQueriesKeys.get(chatId).queryKey);

Expand All @@ -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<string, Message> = Object.fromEntries(updatedMessages.map((msg) => [msg.id, msg]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export function patchChatMessagesWithCompletion(
oldData: ChatResponse | undefined,
newContent: string,
sources?: Array<MessageSource>,
output?: Message['output'],
): ChatResponse | undefined {
if (
!oldData ||
Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface PrepareCompleteChatPayloadArgs {
sessionId: string;
model: string;
generationOptions?: Array<ChatGenerationOption>;
// 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({
Expand All @@ -20,6 +25,8 @@ export function prepareCompleteChatPayload({
sessionId,
model,
generationOptions,
userMessage,
assistantMessageId,
}: PrepareCompleteChatPayloadArgs): CompleteChatRequest {
const prepareChatMessages = (): Array<ChatMessage> => {
return messages.map((message) => {
Expand Down Expand Up @@ -74,6 +81,9 @@ export function prepareCompleteChatPayload({
id: messageId,
sessionId,
files,
userMessage,
parentId: userMessage?.parentId ?? null,
assistantMessageId,
});

return request;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,9 @@ import { patchCompletedMessage } from '../patch-completed-message';
const sourcesStore: Record<string, ChatCompletionChunk['sources']> = {};
const flushScheduled: Record<string, boolean> = {};
const contentBuffer: Record<string, string> = {};
// 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<string, ChatCompletionChunk['output']> = {};

export const handleChatCompletionEvent = async (socketResponse: ChatEventBase): Promise<void> => {
const sessionId = socketService.socketSessionId;
Expand All @@ -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<ChatResponse>(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<ChatResponse>(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];
}
};
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
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<ChatCompletionOutputContentPart>;
}

export class ChatCompletionChunk {
@Expose()
public id: string;

@Expose()
public content: string;

@Expose()
@Type(() => ChatCompletionOutputItem)
public output?: Array<ChatCompletionOutputItem>;

@Expose()
public created?: number;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatCompletionOutputItem>): string => {
if (!Array.isArray(output)) {
return '';
}

const texts: Array<string> = [];

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');
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading