diff --git a/apps/mobile/app/(main)/chat/[id].tsx b/apps/mobile/app/(main)/chat/[id].tsx index 8c85bcb2..165b8a75 100644 --- a/apps/mobile/app/(main)/chat/[id].tsx +++ b/apps/mobile/app/(main)/chat/[id].tsx @@ -15,13 +15,19 @@ import { Icon, FullScreenSearchModal, IconButton, + View, } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; import { ChatScreenParams, navigationConfig, useInitialNavigation, } from '@open-webui-react-native/mobile/shared/utils/navigation'; -import { chatApi, getSelectableModels, modelsApi } from '@open-webui-react-native/shared/data-access/api'; +import { + chatApi, + getSelectableModels, + isTemporaryChatId, + modelsApi, +} from '@open-webui-react-native/shared/data-access/api'; import { appState$ } from '@open-webui-react-native/shared/data-access/app-state'; import { useNavigateOnce } from '@open-webui-react-native/shared/utils/navigation'; import { useTranslation } from '@ronas-it/react-native-common-modules/i18n'; @@ -39,9 +45,11 @@ export default function ChatScreen(): ReactElement { const navigateToClonedChat = (id: string): void => navigateOnce(navigationConfig.main.chat.view({ id })); const isOfflineMode = useSelector(appState$.isOfflineMode); + const isTemporaryChat = isTemporaryChatId(id); const { data: models, isLoading: isModelsLoading } = modelsApi.useGetModels(); - const { data: chat, isLoading: isChatLoading } = chatApi.useGet(id); + // NOTE: Temporary chats are never persisted, so there's nothing to fetch — read the client-seeded cache only. + const { data: chat, isLoading: isChatLoading } = chatApi.useGet(id, { enabled: !isTemporaryChat }); const { modelId, modelName, onSelectModel } = useSetSelectedModel(id); const selectableModels = useMemo(() => getSelectableModels(models), [models]); @@ -75,13 +83,23 @@ export default function ChatScreen(): ReactElement { isLoading && !modelId ? ( translate('TEXT_LOADING') ) : ( - + + {isTemporaryChat && ( + + + + {translate('TEXT_TEMPORARY_CHAT')} + + + )} + + ) } onGoBack={handleGoBackPress} @@ -89,8 +107,11 @@ export default function ChatScreen(): ReactElement { { - if (!chat) return; + if (!chat || isTemporaryChat) return; chatActionsSheetRef.current?.present(chat); }} /> diff --git a/i18n/mobile/app/de.json b/i18n/mobile/app/de.json index 418dbd3c..6c4deba3 100644 --- a/i18n/mobile/app/de.json +++ b/i18n/mobile/app/de.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Archivierte Chats", "TEXT_EXPORT_ALL_CHATS": "Alle Chats exportieren", "TEXT_ARCHIVE_ALL": "Alle archivieren", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Alle Chats archivieren", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "Möchten Sie wirklich alle Chats archivieren? Diese Aktion kann nicht rückgängig gemacht werden.", "TEXT_DELETE_ALL": "Alle löschen", + "TEXT_DELETE_ALL_CHATS_TITLE": "Alle Chats löschen", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "Möchten Sie wirklich alle Chats löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Websuche immer aktiv", "TEXT_HAPTIC_FEEDBACK": "Haptisches Feedback", "TEXT_ENABLE_MESSAGE_QUEUE": "Nachrichtenwarteschlange aktivieren", diff --git a/i18n/mobile/app/en.json b/i18n/mobile/app/en.json index 6d4e8d15..26f15887 100644 --- a/i18n/mobile/app/en.json +++ b/i18n/mobile/app/en.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Archived chats", "TEXT_EXPORT_ALL_CHATS": "Export all chats", "TEXT_ARCHIVE_ALL": "Archive all", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Archive All Chats", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "Are you sure you want to archive all chats? This action cannot be undone.", "TEXT_DELETE_ALL": "Delete all", + "TEXT_DELETE_ALL_CHATS_TITLE": "Delete All Chats", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "Are you sure you want to delete all chats? This action cannot be undone.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Always-on web search", "TEXT_HAPTIC_FEEDBACK": "Haptic feedback", "TEXT_ENABLE_MESSAGE_QUEUE": "Enable message queue", diff --git a/i18n/mobile/app/es.json b/i18n/mobile/app/es.json index 6b1ec2c3..607011b3 100644 --- a/i18n/mobile/app/es.json +++ b/i18n/mobile/app/es.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Chats archivados", "TEXT_EXPORT_ALL_CHATS": "Exportar todos los chats", "TEXT_ARCHIVE_ALL": "Archivar todo", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Archivar todos los chats", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "¿Estás seguro de que quieres archivar todos los chats? Esta acción no se puede deshacer.", "TEXT_DELETE_ALL": "Eliminar todo", + "TEXT_DELETE_ALL_CHATS_TITLE": "Eliminar todos los chats", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "¿Estás seguro de que quieres eliminar todos los chats? Esta acción no se puede deshacer.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Búsqueda web siempre activa", "TEXT_HAPTIC_FEEDBACK": "Respuesta háptica", "TEXT_ENABLE_MESSAGE_QUEUE": "Activar cola de mensajes", diff --git a/i18n/mobile/app/fr.json b/i18n/mobile/app/fr.json index 53f0588a..da40c4de 100644 --- a/i18n/mobile/app/fr.json +++ b/i18n/mobile/app/fr.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Discussions archivées", "TEXT_EXPORT_ALL_CHATS": "Exporter toutes les discussions", "TEXT_ARCHIVE_ALL": "Tout archiver", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Archiver toutes les discussions", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "Voulez-vous vraiment archiver toutes les discussions ? Cette action est irréversible.", "TEXT_DELETE_ALL": "Tout supprimer", + "TEXT_DELETE_ALL_CHATS_TITLE": "Supprimer toutes les discussions", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "Voulez-vous vraiment supprimer toutes les discussions ? Cette action est irréversible.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Recherche web toujours active", "TEXT_HAPTIC_FEEDBACK": "Retour haptique", "TEXT_ENABLE_MESSAGE_QUEUE": "Activer la file d'attente des messages", diff --git a/i18n/mobile/app/ja.json b/i18n/mobile/app/ja.json index 3a26d3f5..1742e31e 100644 --- a/i18n/mobile/app/ja.json +++ b/i18n/mobile/app/ja.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "アーカイブ済みチャット", "TEXT_EXPORT_ALL_CHATS": "すべてのチャットをエクスポート", "TEXT_ARCHIVE_ALL": "すべてアーカイブ", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "すべてのチャットをアーカイブ", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "すべてのチャットをアーカイブしてもよろしいですか?この操作は元に戻せません。", "TEXT_DELETE_ALL": "すべて削除", + "TEXT_DELETE_ALL_CHATS_TITLE": "すべてのチャットを削除", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "すべてのチャットを削除してもよろしいですか?この操作は元に戻せません。", "TEXT_ALWAYS_ON_WEB_SEARCH": "ウェブ検索を常に有効にする", "TEXT_HAPTIC_FEEDBACK": "触覚フィードバック", "TEXT_ENABLE_MESSAGE_QUEUE": "メッセージキューを有効にする", diff --git a/i18n/mobile/app/pt.json b/i18n/mobile/app/pt.json index e044b0a5..1a3f673c 100644 --- a/i18n/mobile/app/pt.json +++ b/i18n/mobile/app/pt.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Chats arquivados", "TEXT_EXPORT_ALL_CHATS": "Exportar todos os chats", "TEXT_ARCHIVE_ALL": "Arquivar tudo", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Arquivar todos os chats", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "Tem certeza de que deseja arquivar todos os chats? Esta ação não pode ser desfeita.", "TEXT_DELETE_ALL": "Excluir tudo", + "TEXT_DELETE_ALL_CHATS_TITLE": "Excluir todos os chats", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "Tem certeza de que deseja excluir todos os chats? Esta ação não pode ser desfeita.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Busca na web sempre ativa", "TEXT_HAPTIC_FEEDBACK": "Resposta tátil", "TEXT_ENABLE_MESSAGE_QUEUE": "Ativar fila de mensagens", diff --git a/i18n/mobile/app/ru.json b/i18n/mobile/app/ru.json index f4cbc9a0..49d04999 100644 --- a/i18n/mobile/app/ru.json +++ b/i18n/mobile/app/ru.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "Архивные чаты", "TEXT_EXPORT_ALL_CHATS": "Экспортировать все чаты", "TEXT_ARCHIVE_ALL": "Архивировать все", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "Архивировать все чаты", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "Вы уверены, что хотите архивировать все чаты? Это действие нельзя отменить.", "TEXT_DELETE_ALL": "Удалить все", + "TEXT_DELETE_ALL_CHATS_TITLE": "Удалить все чаты", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "Вы уверены, что хотите удалить все чаты? Это действие нельзя отменить.", "TEXT_ALWAYS_ON_WEB_SEARCH": "Всегда искать в интернете", "TEXT_HAPTIC_FEEDBACK": "Тактильная отдача", "TEXT_ENABLE_MESSAGE_QUEUE": "Включить очередь сообщений", diff --git a/i18n/mobile/app/zh.json b/i18n/mobile/app/zh.json index c9e70742..5aeb143b 100644 --- a/i18n/mobile/app/zh.json +++ b/i18n/mobile/app/zh.json @@ -23,7 +23,11 @@ "TEXT_ARCHIVED_CHATS": "已归档聊天", "TEXT_EXPORT_ALL_CHATS": "导出所有聊天", "TEXT_ARCHIVE_ALL": "全部归档", + "TEXT_ARCHIVE_ALL_CHATS_TITLE": "归档所有聊天", + "TEXT_ARCHIVE_ALL_CHATS_MESSAGE": "确定要归档所有聊天吗?此操作无法撤销。", "TEXT_DELETE_ALL": "全部删除", + "TEXT_DELETE_ALL_CHATS_TITLE": "删除所有聊天", + "TEXT_DELETE_ALL_CHATS_MESSAGE": "确定要删除所有聊天吗?此操作无法撤销。", "TEXT_ALWAYS_ON_WEB_SEARCH": "始终开启联网搜索", "TEXT_HAPTIC_FEEDBACK": "触感反馈", "TEXT_ENABLE_MESSAGE_QUEUE": "启用消息队列", diff --git a/i18n/mobile/chat/de.json b/i18n/mobile/chat/de.json index 58a668a4..08fe0d30 100644 --- a/i18n/mobile/chat/de.json +++ b/i18n/mobile/chat/de.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Wird geladen...", - "TEXT_SELECT_A_MODEL": "Modell auswählen" + "TEXT_SELECT_A_MODEL": "Modell auswählen", + "TEXT_TEMPORARY_CHAT": "Temporärer Chat" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Nachricht senden", - "TEXT_MODEL_NOT_SELECTED": "Kein Modell ausgewählt" + "TEXT_MODEL_NOT_SELECTED": "Kein Modell ausgewählt", + "TEXT_MESSAGE_QUEUED": "Nachricht in Warteschlange" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/en.json b/i18n/mobile/chat/en.json index 98478474..8fc9b7c9 100644 --- a/i18n/mobile/chat/en.json +++ b/i18n/mobile/chat/en.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Loading...", - "TEXT_SELECT_A_MODEL": "Select a model" + "TEXT_SELECT_A_MODEL": "Select a model", + "TEXT_TEMPORARY_CHAT": "Temporary Chat" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Send a message", - "TEXT_MODEL_NOT_SELECTED": "Model not selected" + "TEXT_MODEL_NOT_SELECTED": "Model not selected", + "TEXT_MESSAGE_QUEUED": "Message queued" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/es.json b/i18n/mobile/chat/es.json index 5c4b823e..7e34b85b 100644 --- a/i18n/mobile/chat/es.json +++ b/i18n/mobile/chat/es.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Cargando...", - "TEXT_SELECT_A_MODEL": "Seleccionar un modelo" + "TEXT_SELECT_A_MODEL": "Seleccionar un modelo", + "TEXT_TEMPORARY_CHAT": "Chat temporal" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Enviar un mensaje", - "TEXT_MODEL_NOT_SELECTED": "Modelo no seleccionado" + "TEXT_MODEL_NOT_SELECTED": "Modelo no seleccionado", + "TEXT_MESSAGE_QUEUED": "Mensaje en cola" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/fr.json b/i18n/mobile/chat/fr.json index 6c610f3a..f1e14281 100644 --- a/i18n/mobile/chat/fr.json +++ b/i18n/mobile/chat/fr.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Chargement...", - "TEXT_SELECT_A_MODEL": "Sélectionner un modèle" + "TEXT_SELECT_A_MODEL": "Sélectionner un modèle", + "TEXT_TEMPORARY_CHAT": "Discussion temporaire" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Envoyer un message", - "TEXT_MODEL_NOT_SELECTED": "Modèle non sélectionné" + "TEXT_MODEL_NOT_SELECTED": "Modèle non sélectionné", + "TEXT_MESSAGE_QUEUED": "Message mis en file d'attente" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/ja.json b/i18n/mobile/chat/ja.json index 87a27b64..a2026e23 100644 --- a/i18n/mobile/chat/ja.json +++ b/i18n/mobile/chat/ja.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "読み込み中...", - "TEXT_SELECT_A_MODEL": "モデルを選択" + "TEXT_SELECT_A_MODEL": "モデルを選択", + "TEXT_TEMPORARY_CHAT": "一時チャット" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "メッセージを送信", - "TEXT_MODEL_NOT_SELECTED": "モデルが選択されていません" + "TEXT_MODEL_NOT_SELECTED": "モデルが選択されていません", + "TEXT_MESSAGE_QUEUED": "メッセージをキューに追加しました" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/pt.json b/i18n/mobile/chat/pt.json index 0379e46b..e323e881 100644 --- a/i18n/mobile/chat/pt.json +++ b/i18n/mobile/chat/pt.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Carregando...", - "TEXT_SELECT_A_MODEL": "Selecionar um modelo" + "TEXT_SELECT_A_MODEL": "Selecionar um modelo", + "TEXT_TEMPORARY_CHAT": "Chat temporário" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Enviar uma mensagem", - "TEXT_MODEL_NOT_SELECTED": "Modelo não selecionado" + "TEXT_MODEL_NOT_SELECTED": "Modelo não selecionado", + "TEXT_MESSAGE_QUEUED": "Mensagem na fila" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/ru.json b/i18n/mobile/chat/ru.json index 02a69f81..3afa8f2f 100644 --- a/i18n/mobile/chat/ru.json +++ b/i18n/mobile/chat/ru.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "Загрузка...", - "TEXT_SELECT_A_MODEL": "Выберите модель" + "TEXT_SELECT_A_MODEL": "Выберите модель", + "TEXT_TEMPORARY_CHAT": "Временный чат" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "Отправить сообщение", - "TEXT_MODEL_NOT_SELECTED": "Модель не выбрана" + "TEXT_MODEL_NOT_SELECTED": "Модель не выбрана", + "TEXT_MESSAGE_QUEUED": "Сообщение добавлено в очередь" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/i18n/mobile/chat/zh.json b/i18n/mobile/chat/zh.json index 8a05814d..9c072e05 100644 --- a/i18n/mobile/chat/zh.json +++ b/i18n/mobile/chat/zh.json @@ -2,11 +2,13 @@ "CHAT": { "CHAT_SCREEN": { "TEXT_LOADING": "加载中...", - "TEXT_SELECT_A_MODEL": "选择模型" + "TEXT_SELECT_A_MODEL": "选择模型", + "TEXT_TEMPORARY_CHAT": "临时聊天" }, "CHAT": { "TEXT_INPUT_PLACEHOLDER": "发送消息", - "TEXT_MODEL_NOT_SELECTED": "未选择模型" + "TEXT_MODEL_NOT_SELECTED": "未选择模型", + "TEXT_MESSAGE_QUEUED": "消息已加入队列" }, "CHAT_MENU_LIST": { "PINNED_CHAT_LIST": { diff --git a/libs/mobile/chat/features/archived-chats-list/src/lib/hooks/use-export-archived-chats.ts b/libs/mobile/chat/features/archived-chats-list/src/lib/hooks/use-export-archived-chats.ts index 42ef8340..950ee1ec 100644 --- a/libs/mobile/chat/features/archived-chats-list/src/lib/hooks/use-export-archived-chats.ts +++ b/libs/mobile/chat/features/archived-chats-list/src/lib/hooks/use-export-archived-chats.ts @@ -1,4 +1,3 @@ -import * as FileSystem from 'expo-file-system/legacy'; import { useState } from 'react'; import { fileSystemService, @@ -21,10 +20,7 @@ export const useExportArchivedChats = (): UseExportArchivedChatsResult => { setIsExporting(true); try { - const fileUri = `${FileSystem.documentDirectory}${downloadFileName}`; - const downloadedFile = await fileSystemService.downloadFile(exportAllChatsApiURL, fileUri); - - await fileSystemService.shareAsync(downloadedFile.uri, downloadFileName, MimeType.JSON, UtiType.JSON); + await fileSystemService.downloadAndShareFile(exportAllChatsApiURL, downloadFileName, MimeType.JSON, UtiType.JSON); } catch { ToastService.showError(); } finally { diff --git a/libs/mobile/chat/features/chat/src/lib/component.tsx b/libs/mobile/chat/features/chat/src/lib/component.tsx index 823cfbdf..89321bdc 100644 --- a/libs/mobile/chat/features/chat/src/lib/component.tsx +++ b/libs/mobile/chat/features/chat/src/lib/component.tsx @@ -20,8 +20,10 @@ import { ChatGenerationOption, chatQueriesKeys, createMessagesList, + isTemporaryChatId, + usersApi, } from '@open-webui-react-native/shared/data-access/api'; -import { Role } from '@open-webui-react-native/shared/data-access/common'; +import { FileData, ImageData, Role } from '@open-webui-react-native/shared/data-access/common'; import { useSubscribeToQueryCache } from '@open-webui-react-native/shared/data-access/query-client'; import { webSocketConfig, webSocketState$ } from '@open-webui-react-native/shared/data-access/websocket'; import { AnalyticsEvent, analyticsService } from '@open-webui-react-native/shared/utils/analytics-service'; @@ -46,11 +48,19 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C const [isInputFocusing, setIsInputFocusing] = useState(false); //NOTE: Needs to avoid ChatBottomButton jumping when auto-scrolling after focus const isSocketConnected = useSelector(webSocketState$.isSocketConnected); + const { data: userSettings } = usersApi.useGetUserSettings(); + const isMessageQueueEnabled = userSettings?.ui.enableMessageQueue ?? true; const [isMessagesListLoaded, setIsMessagesListLoaded] = useState(false); const [isChatVisible, setIsChatVisible] = useState(false); const [activeInputMode, setActiveInputMode] = useState(null); const [inputRerenderKey, setInputRerenderKey] = useState(0); + const [queuedMessage, setQueuedMessage] = useState<{ + inputValue: string; + options: Array; + attachedFiles: Array; + attachedImages: Array; + } | null>(null); const { attachedFiles, @@ -62,7 +72,15 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C resetAttachments, } = useAttachedFiles(); - const { data: chat, refetch, isLoading, isRefetching, isSuccess } = chatApi.useGet(chatId); + const isTemporaryChat = isTemporaryChatId(chatId); + // NOTE: Temporary chats are never persisted, so there's nothing to fetch — read the client-seeded cache only. + const { + data: chat, + refetch, + isLoading, + isRefetching, + isSuccess, + } = chatApi.useGet(chatId, { enabled: !isTemporaryChat }); const { sendMessage, isLoading: isSending } = useSendMessage({ chatData: chat }); const { editingMessageId, @@ -88,6 +106,10 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C const currentMessage = history?.messages[history.currentId]; const isResponseGenerating = !currentMessage?.done; const isAssistantMessage = currentMessage?.role === Role.ASSISTANT; + // NOTE: With message queueing enabled, a generating response no longer force-disables the text input — + // the user can keep typing and submit the next message immediately (queued via `queuedMessage`). The + // Stop button (isResponseGenerating prop below) still renders regardless, alongside the send button. + const isComposerBlockedByGeneration = isResponseGenerating && !isMessageQueueEnabled; const firstMessageGeneratedRef = useRef(false); @@ -102,6 +124,7 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C useAppStateChange({ onChange: (lastStatusChangeTimeStamp) => { if ( + !isTemporaryChat && lastStatusChangeTimeStamp && dayjs().diff(lastStatusChangeTimeStamp, 'seconds') > webSocketConfig.pingTimeout ) { @@ -178,7 +201,20 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C analyticsService.trackEvent(AnalyticsEvent.GENERATE_IMAGE_USED); } - sendMessage(inputValue, selectedModelId, options, attachedFiles.get(), attachedImages.get()); + if (isResponseGenerating && isMessageQueueEnabled) { + // NOTE: Snapshot attachments now — they're cleared by resetAttachments() below and must not + // be re-read live when this queued message is flushed later. + setQueuedMessage({ + inputValue, + options, + attachedFiles: attachedFiles.get(), + attachedImages: attachedImages.get(), + }); + ToastService.show(translate('TEXT_MESSAGE_QUEUED')); + } else { + sendMessage(inputValue, selectedModelId, options, attachedFiles.get(), attachedImages.get()); + } + analyticsService.trackEvent(AnalyticsEvent.MESSAGE_SENT, { modelId: selectedModelId }); reset(); resetAttachments(); @@ -212,6 +248,19 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C } }, [isNewChat, isSuccess, chatId]); + useEffect(() => { + if (!isResponseGenerating && queuedMessage && selectedModelId) { + sendMessage( + queuedMessage.inputValue, + selectedModelId, + queuedMessage.options, + queuedMessage.attachedFiles, + queuedMessage.attachedImages, + ); + setQueuedMessage(null); + } + }, [isResponseGenerating, queuedMessage, selectedModelId, sendMessage]); + useEffect(() => { if (isNewChat && !firstMessageGeneratedRef.current && isAssistantMessage && currentMessage.done) { firstMessageGeneratedRef.current = true; @@ -275,7 +324,7 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C onFocus={handleInputFocus} name='inputValue' onSubmit={onSubmit} - isLoading={isSending || !isSocketConnected || isResponseGenerating} + isLoading={isSending || !isSocketConnected || isComposerBlockedByGeneration} attachedFiles={attachedFiles} onFileUploaded={handleFileUploaded} onDeleteFilePress={handleDeleteFile} @@ -284,6 +333,7 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C onDeleteImagePress={handleDeleteImage} modelId={selectedModelId} isResponseGenerating={isResponseGenerating} + isMessageQueueEnabled={isMessageQueueEnabled} inputRerenderKey={inputRerenderKey} chat={chat} /> diff --git a/libs/mobile/chat/features/chat/src/lib/components/user-message/component.tsx b/libs/mobile/chat/features/chat/src/lib/components/user-message/component.tsx index d91bb7b2..a0e38246 100644 --- a/libs/mobile/chat/features/chat/src/lib/components/user-message/component.tsx +++ b/libs/mobile/chat/features/chat/src/lib/components/user-message/component.tsx @@ -10,7 +10,7 @@ import { import { AppMarkdownView } from '@open-webui-react-native/mobile/shared/features/markdown-view'; import { cn, colors, screenWidth, spacings } from '@open-webui-react-native/mobile/shared/ui/styles'; import { AppText, View } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; -import { Message } from '@open-webui-react-native/shared/data-access/api'; +import { Message, usersApi } from '@open-webui-react-native/shared/data-access/api'; import { AttachedFile, FileType } from '@open-webui-react-native/shared/data-access/common'; import { formatDateTime } from '@open-webui-react-native/shared/utils/date'; import { deepMemo } from '@open-webui-react-native/shared/utils/deep-memo'; @@ -39,6 +39,10 @@ function ChatUserMessageComponent({ }: ChatUserMessageProps): ReactElement { const { files, content: text, timestamp } = message; + const { data: userSettings } = usersApi.useGetUserSettings(); + const isChatBubbleUIEnabled = userSettings?.ui.chatBubble ?? true; + const isUserMessageMarkdownEnabled = userSettings?.ui.renderMarkdownInUserMessages ?? true; + const attachedFiles = useMemo( () => (files?.filter((file) => file.type === FileType.FILE) as Array) ?? [], [files], @@ -52,6 +56,26 @@ function ChatUserMessageComponent({ const { handleImagePress, handleAllPhotosPress, selectedImageIndex, isPreviewVisible, handleCloseImagePress } = useImagePreview(); + // NOTE: `isChatBubbleUIEnabled === false` flattens the user bubble to plain flowing text (matching the AI + // message style), independently of `isUserMessageMarkdownEnabled` which only controls markdown rendering. + const renderMessageContent = (): ReactElement => { + const content = isUserMessageMarkdownEnabled ? ( + + {text} + + ) : ( + {text} + ); + + if (!isChatBubbleUIEnabled) { + return content; + } + + return ( + {content} + ); + }; + return ( @@ -71,13 +95,7 @@ function ChatUserMessageComponent({ onShowAllImages={handleAllPhotosPress} /> - {!!text && ( - - - {text} - - - )} + {!!text && renderMessageContent()} + {isTemporaryChat && ( + + + + {translateChatScreen('TEXT_TEMPORARY_CHAT')} + + + )} extends AppInputProps { isLoading?: boolean; isSuggestionShown?: boolean; isResponseGenerating?: boolean; + isMessageQueueEnabled?: boolean; inputRerenderKey?: number; } @@ -67,6 +68,7 @@ export function FormChatInput({ isLoading, isSuggestionShown, isResponseGenerating, + isMessageQueueEnabled, inputRerenderKey, ...restProps }: FormChatInputProps): ReactElement { @@ -180,6 +182,7 @@ export function FormChatInput({ isVoiceModeAvailable={isFeatureEnabled(FeatureID.VOICE_MODE) && isInputEmpty} onStopGenerationPress={onStopGenerationPress} isResponseGenerating={isResponseGenerating} + isMessageQueueEnabled={isMessageQueueEnabled} isLoading={isLoading || isMicrophonePreparing}> diff --git a/libs/mobile/chat/features/form-chat-input/src/lib/components/chat-input-bottom-row/component.tsx b/libs/mobile/chat/features/form-chat-input/src/lib/components/chat-input-bottom-row/component.tsx index feb7ef5c..a8bef653 100644 --- a/libs/mobile/chat/features/form-chat-input/src/lib/components/chat-input-bottom-row/component.tsx +++ b/libs/mobile/chat/features/form-chat-input/src/lib/components/chat-input-bottom-row/component.tsx @@ -6,6 +6,9 @@ export interface ChatInputBottomRowProps extends PropsWithChildren { onVoiceModePress: () => void; onStopGenerationPress: () => void; isResponseGenerating?: boolean; + // NOTE: When queueing is enabled, the send button must stay reachable alongside Stop, so the + // next message can still be composed/queued while a response is generating. + isMessageQueueEnabled?: boolean; isVoiceModeAvailable?: boolean; isSubmitDisabled?: boolean; isLoading?: boolean; @@ -19,26 +22,30 @@ export function ChatInputBottomRow({ children, isSubmitDisabled, isResponseGenerating, + isMessageQueueEnabled, onStopGenerationPress, }: ChatInputBottomRowProps): ReactElement { + const showSendButton = !isResponseGenerating || isMessageQueueEnabled; + return ( {children} - {isResponseGenerating ? ( - + {isResponseGenerating && - ) : ( - - )} + onPress={onStopGenerationPress} />} + {showSendButton && ( + + )} + ); } diff --git a/libs/mobile/chat/features/use-create-new-chat/src/use-create-new-chat.ts b/libs/mobile/chat/features/use-create-new-chat/src/use-create-new-chat.ts index 3e6ed5a4..3ab0d9e4 100644 --- a/libs/mobile/chat/features/use-create-new-chat/src/use-create-new-chat.ts +++ b/libs/mobile/chat/features/use-create-new-chat/src/use-create-new-chat.ts @@ -1,12 +1,15 @@ import dayjs from 'dayjs'; import { + Chat, chatApi, ChatGenerationOption, chatQueriesKeys, ChatResponse, + createTemporaryChatId, patchChatList, prepareCompleteChatPayload, prepareCreateChatPayload, + usersApi, } from '@open-webui-react-native/shared/data-access/api'; import { FileData, ImageData } from '@open-webui-react-native/shared/data-access/common'; import { queryClient } from '@open-webui-react-native/shared/data-access/query-client'; @@ -23,6 +26,8 @@ export function useCreateNewChat({ onSuccess }: UseCreateNewChatArgs): typeof re const { mutate: createNewChat, isPending: isChatCreating } = chatApi.useCreateNewChat(); + const { data: userSettings } = usersApi.useGetUserSettings(); + const startChatCreation = ( prompt: string, model: string, @@ -33,6 +38,38 @@ export function useCreateNewChat({ onSuccess }: UseCreateNewChatArgs): typeof re ): void => { const payload = prepareCreateChatPayload({ prompt, model, attachedFiles, attachedImages, folderId }); + // NOTE: Temporary chats are never persisted (no POST /chats/new, no chat-list entry) — they only + // exist client-side for this session, matching the Open WebUI web app's "Temporary Chat" behavior. + if (userSettings?.ui.temporaryChatByDefault) { + const id = createTemporaryChatId(); + const chatResponse = new ChatResponse({ + id, + title: payload.chat.title, + chat: new Chat({ ...payload.chat, id }), + createdAt: dayjs(), + updatedAt: dayjs(), + archived: false, + pinned: false, + meta: {}, + }); + + queryClient.setQueryData(chatQueriesKeys.get(id).queryKey, chatResponse); + onSuccess?.(id); + + completeChat( + prepareCompleteChatPayload({ + chatId: id, + messageId: chatResponse.chat.history.currentId, + messages: chatResponse.chat.messages, + sessionId: socketSessionId, + model, + generationOptions, + }), + ); + + return; + } + createNewChat(payload, { onSuccess: (data) => { // NOTE: Seed get-chat cache so socket streaming and VoiceMode can track the assistant reply diff --git a/libs/mobile/chat/features/use-edit-message/src/use-edit-message.ts b/libs/mobile/chat/features/use-edit-message/src/use-edit-message.ts index 63bf7191..6236eb5e 100644 --- a/libs/mobile/chat/features/use-edit-message/src/use-edit-message.ts +++ b/libs/mobile/chat/features/use-edit-message/src/use-edit-message.ts @@ -4,6 +4,8 @@ import { FormValues } from '@open-webui-react-native/mobile/shared/utils/form'; import { chatApi, ChatResponse, + isTemporaryChatId, + patchChatQueryData, prepareCompleteChatPayload, prepareUpdateMessageToSendPayload, prepareEditAssistantMessagePayload, @@ -63,7 +65,14 @@ export const useEditMessage = ({ chat, modelId }: UseEditMessageProps): typeof r ? prepareCopyEditedMessagePayload(chat, editingMessageId, message) : prepareUpdateMessageInChatPayload(chat, editingMessageId, message); - await updateChat(preparedChat); + // NOTE: Temporary chats are never persisted — apply the same cache patch onSuccess would have + // done, skipping the PATCH /chats/{id} call that would otherwise fail against a synthetic id. + if (isTemporaryChatId(chat.id)) { + patchChatQueryData(chat.id, preparedChat); + } else { + await updateChat(preparedChat); + } + cancelEditing(); }; @@ -78,21 +87,28 @@ export const useEditMessage = ({ chat, modelId }: UseEditMessageProps): typeof r ? prepareEditAssistantMessagePayload(chat, editingMessageId, message) : prepareUpdateMessageToSendPayload(chat, message, modelId, editedMessage.parentId); - await updateChat(preparedChat, { - onSuccess: (data) => { - if (editedMessage.role === Role.ASSISTANT) return; - - const completePayload = prepareCompleteChatPayload({ - chatId: data.id, - messageId: data.chat!.history.currentId, - messages: data.chat!.messages, - sessionId: socketSessionId, - model: modelId, - }); - - completeChat(completePayload); - }, - }); + const triggerCompletion = (data: ChatResponse): void => { + if (editedMessage.role === Role.ASSISTANT) return; + + const completePayload = prepareCompleteChatPayload({ + chatId: data.id, + messageId: data.chat!.history.currentId, + messages: data.chat!.messages, + sessionId: socketSessionId, + model: modelId, + }); + + completeChat(completePayload); + }; + + // NOTE: Temporary chats are never persisted — apply the same cache patch onSuccess would have + // done, skipping the PATCH /chats/{id} call that would otherwise fail against a synthetic id. + if (isTemporaryChatId(chat.id)) { + patchChatQueryData(chat.id, preparedChat); + triggerCompletion(preparedChat); + } else { + await updateChat(preparedChat, { onSuccess: triggerCompletion }); + } cancelEditing(); }; diff --git a/libs/mobile/chat/features/use-send-message/src/use-send-message.ts b/libs/mobile/chat/features/use-send-message/src/use-send-message.ts index 037fba2f..96108bc6 100644 --- a/libs/mobile/chat/features/use-send-message/src/use-send-message.ts +++ b/libs/mobile/chat/features/use-send-message/src/use-send-message.ts @@ -1,7 +1,10 @@ +import { EntityPartial } from '@ronas-it/rtkq-entity-api'; +import { useCallback } from 'react'; import { ChatGenerationOption, ChatResponse, chatApi, + isTemporaryChatId, patchChatQueryData, prepareCompleteChatPayload, prepareSendMessagePayload, @@ -24,23 +27,23 @@ export function useSendMessage({ chatData }: UseSendMessageArgs): typeof result }, }); - const sendMessage = ( - prompt: string, - model: string, - generationOptions?: Array, - attachedFiles?: Array, - attachedImages?: Array, - ): void => { - if (!chatData) { - return; - } - - const payload = prepareSendMessagePayload({ prompt, chatData, model, attachedFiles, attachedImages }); - - updateChat(payload, { - onSuccess: (data) => { + const sendMessage = useCallback( + ( + prompt: string, + model: string, + generationOptions?: Array, + attachedFiles?: Array, + attachedImages?: Array, + ): void => { + if (!chatData) { + return; + } + + const payload = prepareSendMessagePayload({ prompt, chatData, model, attachedFiles, attachedImages }); + + const triggerCompletion = (data: EntityPartial): void => { const completePayload = prepareCompleteChatPayload({ - chatId: data.id, + chatId: data.id!, messageId: data.chat!.history.currentId, messages: data.chat!.messages, sessionId: socketSessionId, @@ -49,9 +52,23 @@ export function useSendMessage({ chatData }: UseSendMessageArgs): typeof result }); completeChat(completePayload); - }, - }); - }; + }; + + // NOTE: Temporary chats are never persisted (no POST /chats/{id}) — apply the same optimistic + // cache patch `useUpdate`'s onMutate would have done, then trigger completion directly. + if (isTemporaryChatId(payload.id)) { + patchChatQueryData(payload.id!, payload); + triggerCompletion(payload); + + return; + } + + updateChat(payload, { + onSuccess: triggerCompletion, + }); + }, + [chatData, socketSessionId, updateChat, completeChat], + ); const result = { sendMessage, diff --git a/libs/mobile/chat/features/voice-mode-modal/src/lib/component.tsx b/libs/mobile/chat/features/voice-mode-modal/src/lib/component.tsx index ac4672d0..0f619114 100644 --- a/libs/mobile/chat/features/voice-mode-modal/src/lib/component.tsx +++ b/libs/mobile/chat/features/voice-mode-modal/src/lib/component.tsx @@ -8,7 +8,7 @@ import { speechStreamingService } from '@open-webui-react-native/mobile/shared/d import { useDictateMode } from '@open-webui-react-native/mobile/shared/features/use-dictate-mode'; import { colors, useColorScheme } from '@open-webui-react-native/mobile/shared/ui/styles'; import { AppSafeAreaView, AppText, AppToast, IconButton, View } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; -import { chatApi } from '@open-webui-react-native/shared/data-access/api'; +import { chatApi, isTemporaryChatId } from '@open-webui-react-native/shared/data-access/api'; import { ImageData as ChatImageData } from '@open-webui-react-native/shared/data-access/common'; import { ToastService } from '@open-webui-react-native/shared/utils/toast-service'; import { CameraPreview, CameraPreviewMethods, Loader, SpeechListener } from './components'; @@ -65,7 +65,10 @@ export function VoiceModeModal({ onChatCreated, ref, ...props }: VoiceModeModalP } }; - const { data: chat, isLoading } = chatApi.useGet(chatId as string, { enabled: !!chatId }); + // NOTE: Temporary chats are never persisted, so there's nothing to fetch — read the client-seeded cache only. + const { data: chat, isLoading } = chatApi.useGet(chatId as string, { + enabled: !!chatId && !isTemporaryChatId(chatId), + }); const { sendMessage, isLoading: isSending } = useSendMessage({ chatData: chat }); const { startChatCreation, isLoading: isCreating } = useCreateNewChat({ onSuccess: handleChatCreated }); diff --git a/libs/mobile/settings/features/settings/src/lib/component.tsx b/libs/mobile/settings/features/settings/src/lib/component.tsx index ea008a04..9c82c149 100644 --- a/libs/mobile/settings/features/settings/src/lib/component.tsx +++ b/libs/mobile/settings/features/settings/src/lib/component.tsx @@ -1,6 +1,6 @@ import { useSelector } from '@legendapp/state/react'; import { useTranslation } from '@ronas-it/react-native-common-modules/i18n'; -import { ReactElement, useRef, useState } from 'react'; +import { ReactElement, useRef } from 'react'; import { Alert } from 'react-native'; import { ContactSupportSheet, @@ -13,12 +13,16 @@ import { Avatar, View } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; import { navigationConfig } from '@open-webui-react-native/mobile/shared/utils/navigation'; import { authApi, + chatApi, modelsApi, UiSettings, UserSettings, usersApi, + usersApiConfig, } from '@open-webui-react-native/shared/data-access/api'; import { appState$ } from '@open-webui-react-native/shared/data-access/app-state'; +import { queryClient } from '@open-webui-react-native/shared/data-access/query-client'; +import { alertService } from '@open-webui-react-native/shared/utils/alert-service'; import { availableLanguages, availableMarkdownRenderers, @@ -44,15 +48,7 @@ import { SelectionSheet, SelectionSheetMethods, } from './components'; -import { SettingsToggles } from './types'; - -const initialToggles: SettingsToggles = { - isWebSearchAlwaysOn: true, - isMessageQueueEnabled: true, - isChatBubbleUIEnabled: true, - isTemporaryChatEnabled: true, - isUserMessageMarkdownEnabled: true, -}; +import { useExportAllChats } from './hooks'; // NOTE: Work in progress, not all features are implemented yet. export function Settings(): ReactElement { @@ -66,10 +62,17 @@ export function Settings(): ReactElement { const { data: profile, isError: isProfileError } = authApi.useGetProfile(); const { isError: isModelsError } = modelsApi.useGetModels(); const { data: settings, isError: isSettingsError } = usersApi.useGetUserSettings(); - const { mutate: updateUserSettings } = usersApi.useUpdateUserSettings(); + const { mutate: updateUserSettings } = usersApi.useUpdateUserSettings({ + onError: () => ToastService.showError(), + }); const { modelId: defaultModelId, modelName: defaultModelName } = useSetSelectedModel(); - - const [toggles, setToggles] = useState(initialToggles); + const { mutate: archiveAllChats, isPending: isArchivingAllChats } = chatApi.useArchiveAllChats({ + onError: () => ToastService.showError(), + }); + const { mutate: deleteAllChats, isPending: isDeletingAllChats } = chatApi.useDeleteAllChats({ + onError: () => ToastService.showError(), + }); + const { exportAllChats, isExporting: isExportingAllChats } = useExportAllChats(); const profileAvatarSheetRef = useRef(null); const profileNameSheetRef = useRef(null); @@ -80,11 +83,6 @@ export function Settings(): ReactElement { const languageSheetRef = useRef(null); const markdownRendererSheetRef = useRef(null); - const createToggleHandler = - (toggle: keyof SettingsToggles) => - (isEnabled: boolean): void => - setToggles((currentToggles) => ({ ...currentToggles, [toggle]: isEnabled })); - const avatarSource = profile?.profileImageUrl ? { uri: profile.profileImageUrl } : undefined; const languageLabel = availableLanguages.find(({ code }) => code === locale)?.label; const markdownRendererOption = availableMarkdownRenderers.find(({ code }) => code === markdownRenderer); @@ -96,6 +94,12 @@ export function Settings(): ReactElement { const defaultSystemPromptValue = isSettingsError ? translate('TEXT_LOAD_ERROR') : settings?.ui.system; const profileNameValue = isProfileError ? translate('TEXT_LOAD_ERROR') : profile?.name; + const isWebSearchAlwaysOnEnabled = settings?.ui.webSearch ?? false; + const isMessageQueueEnabled = settings?.ui.enableMessageQueue ?? true; + const isChatBubbleUIEnabled = settings?.ui.chatBubble ?? true; + const isTemporaryChatByDefaultEnabled = settings?.ui.temporaryChatByDefault ?? false; + const isUserMessageMarkdownEnabled = settings?.ui.renderMarkdownInUserMessages ?? true; + const languageItems = availableLanguages.map(({ code, label }) => ({ value: code, label })); const markdownRendererItems = availableMarkdownRenderers.map(({ code, labelKey }) => ({ value: code, @@ -126,6 +130,20 @@ export function Settings(): ReactElement { appState$.setMarkdownRenderer(value); }; + const createUiSettingToggleHandler = + (key: K) => + (value: UiSettings[K]): void => { + // NOTE: Read the latest cached settings (not the `settings` render closure) so two rapid + // toggle taps before a re-render don't clobber each other with a stale `ui` snapshot. + const latestSettings = queryClient.getQueryData(usersApiConfig.getUserSettingsQueryKey); + + if (!latestSettings) { + return; + } + + updateUserSettings(new UserSettings({ ui: new UiSettings({ ...latestSettings.ui, [key]: value }) })); + }; + const handleArchivedChatsPress = (): void => isFeatureEnabled(FeatureID.ARCHIVE_CHAT) ? navigateOnce(`${navigationConfig.main.chat.index}/${navigationConfig.main.chat.archivedChats}`) @@ -169,6 +187,54 @@ export function Settings(): ReactElement { ); }; + const createChatActionHandler = ({ + isPending, + action, + confirm, + }: { + isPending: boolean; + action: () => void; + confirm?: { title: string; message: string; confirmButtonStyle?: 'destructive' }; + }): (() => void) => { + const guardedAction = (): void => { + if (!isPending) { + action(); + } + }; + + return (): void => { + if (confirm) { + alertService.confirm({ ...confirm, onConfirm: guardedAction }); + } else { + guardedAction(); + } + }; + }; + + const handleArchiveAllChatsPress = createChatActionHandler({ + isPending: isArchivingAllChats, + action: archiveAllChats, + confirm: { + title: translate('TEXT_ARCHIVE_ALL_CHATS_TITLE'), + message: translate('TEXT_ARCHIVE_ALL_CHATS_MESSAGE'), + }, + }); + + const handleDeleteAllChatsPress = createChatActionHandler({ + isPending: isDeletingAllChats, + action: deleteAllChats, + confirm: { + title: translate('TEXT_DELETE_ALL_CHATS_TITLE'), + message: translate('TEXT_DELETE_ALL_CHATS_MESSAGE'), + confirmButtonStyle: 'destructive', + }, + }); + + const handleExportAllChatsPress = createChatActionHandler({ + isPending: isExportingAllChats, + action: exportAllChats, + }); + const generalOptions: Array = [ { label: translate('TEXT_DEFAULT_MODEL'), @@ -234,13 +300,13 @@ export function Settings(): ReactElement { const chatsOptions: Array = [ { label: translate('TEXT_ARCHIVED_CHATS'), iconName: 'archive', onPress: handleArchivedChatsPress }, - { type: 'action', label: translate('TEXT_EXPORT_ALL_CHATS'), onPress: ToastService.showFeatureNotImplemented }, - { type: 'action', label: translate('TEXT_ARCHIVE_ALL'), onPress: ToastService.showFeatureNotImplemented }, + { type: 'action', label: translate('TEXT_EXPORT_ALL_CHATS'), onPress: handleExportAllChatsPress }, + { type: 'action', label: translate('TEXT_ARCHIVE_ALL'), onPress: handleArchiveAllChatsPress }, { type: 'action', label: translate('TEXT_DELETE_ALL'), isDanger: true, - onPress: ToastService.showFeatureNotImplemented, + onPress: handleDeleteAllChatsPress, }, ]; @@ -248,8 +314,8 @@ export function Settings(): ReactElement { { type: 'switch', label: translate('TEXT_ALWAYS_ON_WEB_SEARCH'), - isEnabled: toggles.isWebSearchAlwaysOn, - onValueChange: createToggleHandler('isWebSearchAlwaysOn'), + isEnabled: isWebSearchAlwaysOnEnabled, + onValueChange: createUiSettingToggleHandler('webSearch'), }, { type: 'switch', @@ -260,26 +326,26 @@ export function Settings(): ReactElement { { type: 'switch', label: translate('TEXT_ENABLE_MESSAGE_QUEUE'), - isEnabled: toggles.isMessageQueueEnabled, - onValueChange: createToggleHandler('isMessageQueueEnabled'), + isEnabled: isMessageQueueEnabled, + onValueChange: createUiSettingToggleHandler('enableMessageQueue'), }, { type: 'switch', label: translate('TEXT_CHAT_BUBBLE_UI'), - isEnabled: toggles.isChatBubbleUIEnabled, - onValueChange: createToggleHandler('isChatBubbleUIEnabled'), + isEnabled: isChatBubbleUIEnabled, + onValueChange: createUiSettingToggleHandler('chatBubble'), }, { type: 'switch', label: translate('TEXT_TEMPORARY_CHAT_BY_DEFAULT'), - isEnabled: toggles.isTemporaryChatEnabled, - onValueChange: createToggleHandler('isTemporaryChatEnabled'), + isEnabled: isTemporaryChatByDefaultEnabled, + onValueChange: createUiSettingToggleHandler('temporaryChatByDefault'), }, { type: 'switch', label: translate('TEXT_RENDER_MARKDOWN_IN_USER_MESSAGE'), - isEnabled: toggles.isUserMessageMarkdownEnabled, - onValueChange: createToggleHandler('isUserMessageMarkdownEnabled'), + isEnabled: isUserMessageMarkdownEnabled, + onValueChange: createUiSettingToggleHandler('renderMarkdownInUserMessages'), }, { label: translate('TEXT_MARKDOWN_RENDERER'), diff --git a/libs/mobile/settings/features/settings/src/lib/config.ts b/libs/mobile/settings/features/settings/src/lib/config.ts new file mode 100644 index 00000000..5ac56eb7 --- /dev/null +++ b/libs/mobile/settings/features/settings/src/lib/config.ts @@ -0,0 +1,6 @@ +import { getApiUrl } from '@open-webui-react-native/shared/utils/config'; + +export const settingsApiConfig = { + exportAllChatsApiURL: `${getApiUrl()}/api/v1/chats/all`, + downloadFileName: 'chats_export.json', +}; diff --git a/libs/mobile/settings/features/settings/src/lib/hooks/index.ts b/libs/mobile/settings/features/settings/src/lib/hooks/index.ts new file mode 100644 index 00000000..dfcc3dd1 --- /dev/null +++ b/libs/mobile/settings/features/settings/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-export-all-chats'; diff --git a/libs/mobile/settings/features/settings/src/lib/hooks/use-export-all-chats.ts b/libs/mobile/settings/features/settings/src/lib/hooks/use-export-all-chats.ts new file mode 100644 index 00000000..fa627051 --- /dev/null +++ b/libs/mobile/settings/features/settings/src/lib/hooks/use-export-all-chats.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import { + fileSystemService, + MimeType, + UtiType, +} from '@open-webui-react-native/mobile/shared/data-access/file-system-service'; +import { ToastService } from '@open-webui-react-native/shared/utils/toast-service'; +import { settingsApiConfig } from '../config'; + +interface UseExportAllChatsResult { + isExporting: boolean; + exportAllChats: () => Promise; +} + +export const useExportAllChats = (): UseExportAllChatsResult => { + const [isExporting, setIsExporting] = useState(false); + const { exportAllChatsApiURL, downloadFileName } = settingsApiConfig; + + const exportAllChats = async (): Promise => { + setIsExporting(true); + + try { + await fileSystemService.downloadAndShareFile(exportAllChatsApiURL, downloadFileName, MimeType.JSON, UtiType.JSON); + } catch { + ToastService.showError(); + } finally { + setIsExporting(false); + } + }; + + return { isExporting, exportAllChats }; +}; diff --git a/libs/mobile/settings/features/settings/src/lib/types/index.ts b/libs/mobile/settings/features/settings/src/lib/types/index.ts index d80f5a49..e3ef313f 100644 --- a/libs/mobile/settings/features/settings/src/lib/types/index.ts +++ b/libs/mobile/settings/features/settings/src/lib/types/index.ts @@ -1,2 +1 @@ -export * from './settings-toggles'; export * from './support-attachment'; diff --git a/libs/mobile/settings/features/settings/src/lib/types/settings-toggles.ts b/libs/mobile/settings/features/settings/src/lib/types/settings-toggles.ts deleted file mode 100644 index eab109f8..00000000 --- a/libs/mobile/settings/features/settings/src/lib/types/settings-toggles.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface SettingsToggles { - isWebSearchAlwaysOn: boolean; - isMessageQueueEnabled: boolean; - isChatBubbleUIEnabled: boolean; - isTemporaryChatEnabled: boolean; - isUserMessageMarkdownEnabled: boolean; -} diff --git a/libs/mobile/settings/ui/section/src/lib/components/settings-section-item/component.tsx b/libs/mobile/settings/ui/section/src/lib/components/settings-section-item/component.tsx index cdf7341b..068acfa9 100644 --- a/libs/mobile/settings/ui/section/src/lib/components/settings-section-item/component.tsx +++ b/libs/mobile/settings/ui/section/src/lib/components/settings-section-item/component.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from 'react'; +import { Fragment, ReactElement } from 'react'; import { cn } from '@open-webui-react-native/mobile/shared/ui/styles'; import { AppPressable, AppSwitch, AppText, Icon, View } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; import { SettingsSectionOption } from '../../types'; @@ -10,17 +10,17 @@ interface SettingsSectionItemProps { export function SettingsSectionItem({ option }: SettingsSectionItemProps): ReactElement { const { label, iconName, isDanger } = option; - const title = ( - + const titleContent = ( + {iconName && } - {label} - + {label} + ); if (option.type === 'switch') { return ( - {title} + {titleContent} ); @@ -30,7 +30,7 @@ export function SettingsSectionItem({ option }: SettingsSectionItemProps): React - {title} + {titleContent} {option.type !== 'action' && ( {!!option.value && ( diff --git a/libs/mobile/shared/data-access/file-system-service/src/service.ts b/libs/mobile/shared/data-access/file-system-service/src/service.ts index b009a730..4e5ee77c 100644 --- a/libs/mobile/shared/data-access/file-system-service/src/service.ts +++ b/libs/mobile/shared/data-access/file-system-service/src/service.ts @@ -66,6 +66,18 @@ export class FileSystemService { } } + public async downloadAndShareFile( + uri: string, + fileName: string, + mimeType: MimeType, + utiType: UtiType, + ): Promise { + const fileUri = `${FileSystem.documentDirectory}${fileName}`; + const downloadedFile = await this.downloadFile(uri, fileUri); + + await this.shareAsync(downloadedFile.uri, fileName, mimeType, utiType); + } + private createTemporaryFile(fileName: string, content: string, extension: FileExtension): string { const file = new File(this._cacheDirectory, `${fileName}.${extension}`); file.write(content); diff --git a/libs/mobile/shared/features/use-set-selected-model/src/hook.ts b/libs/mobile/shared/features/use-set-selected-model/src/hook.ts index 5980d0af..d5d07f3c 100644 --- a/libs/mobile/shared/features/use-set-selected-model/src/hook.ts +++ b/libs/mobile/shared/features/use-set-selected-model/src/hook.ts @@ -3,6 +3,7 @@ import { appConfigurationApi, chatApi, isModelSelectable, + isTemporaryChatId, modelsApi, usersApi, } from '@open-webui-react-native/shared/data-access/api'; @@ -24,13 +25,17 @@ export const useSetSelectedModel = (chatId?: string): UseSetSelectedModelResult isLoading: isConfigLoading, } = appConfigurationApi.useGetAppConfiguration(); const { data: models, isSuccess: isModelsSuccess, isLoading: isModelsLoading } = modelsApi.useGetModels(); + const isTemporaryChat = isTemporaryChatId(chatId); const { data: chat, isSuccess: isChatSuccess, isLoading: isChatLoading, - } = chatApi.useGet(chatId as string, { enabled: !!chatId }); + } = chatApi.useGet(chatId as string, { enabled: !!chatId && !isTemporaryChat }); - const isDataReceived = isSettingsSuccess && isConfigSuccess && isModelsSuccess && (!chatId || isChatSuccess); + // NOTE: Temporary chats are never persisted, so their useGet query is disabled above (it can't + // succeed) — the seeded cache value it still returns is enough, don't wait on isChatSuccess for it. + const isDataReceived = + isSettingsSuccess && isConfigSuccess && isModelsSuccess && (!chatId || isTemporaryChat || isChatSuccess); const isLoading = isSettingsLoading || isConfigLoading || isModelsLoading || isChatLoading; 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 5bd90ce8..0c9a282d 100644 --- a/libs/shared/data-access/api/src/lib/chats/api.ts +++ b/libs/shared/data-access/api/src/lib/chats/api.ts @@ -738,6 +738,48 @@ export function useGetAllArchivedChatsJson(): UseQueryResult }); } +function invalidateAllChatListsQueries(): void { + queryClient.invalidateQueries({ queryKey: chatServiceConfig.getChatListQueryKey }); + queryClient.invalidateQueries({ queryKey: chatServiceConfig.getPinnedChatListQueryKey }); + // NOTE: Invalidate all folders chat lists regardless of folderId + queryClient.invalidateQueries({ queryKey: foldersApiConfig.getFolderChatListQueryKeyPrefix, exact: false }); + queryClient.invalidateQueries({ queryKey: foldersApiConfig.getFolderChatsQueryKeyPrefix, exact: false }); + // NOTE: Also invalidates chatServiceConfig.getAllArchivedChatsQueryKey + invalidateArchivedChatListQuery(); + + // NOTE: Bulk archive/delete doesn't tell us which chat ids were affected — clear every individually + // cached chat (mirroring useDelete's single-chat cache clear) so a screen still holding one of them + // notices via useSubscribeToQueryCache('removed') instead of rendering stale, now-gone data. + queryClient + .getQueryCache() + .findAll({ queryKey: chatQueriesKeys.get._def }) + .forEach((query) => queryClient.setQueryData(query.queryKey, undefined)); +} + +export function useArchiveAllChats( + options?: UseMutationOptions, void>, +): UseMutationResult, void> { + return useMutation({ + mutationFn: () => chatService.archiveAllChats(), + onSuccess: () => { + invalidateAllChatListsQueries(); + }, + ...options, + }); +} + +export function useDeleteAllChats( + options?: UseMutationOptions, void>, +): UseMutationResult, void> { + return useMutation({ + mutationFn: () => chatService.deleteAllChats(), + onSuccess: () => { + invalidateAllChatListsQueries(); + }, + ...options, + }); +} + export const chatApi = { useGet, useUpdate, @@ -757,4 +799,6 @@ export const chatApi = { useGetArchivedChatList, useGetAllArchivedChatsJson, useUpdateChatFolder, + useArchiveAllChats, + useDeleteAllChats, }; diff --git a/libs/shared/data-access/api/src/lib/chats/service.ts b/libs/shared/data-access/api/src/lib/chats/service.ts index d1e1d12b..39753450 100644 --- a/libs/shared/data-access/api/src/lib/chats/service.ts +++ b/libs/shared/data-access/api/src/lib/chats/service.ts @@ -174,6 +174,14 @@ export class ChatService extends EntityPromiseService { }), ); } + + public async archiveAllChats(): Promise { + await getApiService().post(`${chatServiceConfig.versionedRoute}/archive/all`); + } + + public async deleteAllChats(): Promise { + await getApiService().delete(`${chatServiceConfig.versionedRoute}/`); + } } export const chatService = new ChatService(); 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 33cecb36..2ada02b0 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 @@ -5,6 +5,7 @@ import { chatQueriesKeys } from '../chat-queries-keys'; import { Chat, ChatResponse, History, Message } from '../models'; import { chatService } from '../service'; import { prepareCompletedChatPayload } from './prepare-completed-chat-payload'; +import { isTemporaryChatId } from './temporary-chat-id'; export const handleCompletedChat = async ( message: string, @@ -13,6 +14,12 @@ export const handleCompletedChat = async ( sources?: Array, output?: Message['output'], ): Promise => { + // NOTE: Temporary chats are never persisted — the completed message is already reflected in the + // cache by patchCompletedMessage (see handleChatCompletionEvent), so there's nothing left to do. + if (isTemporaryChatId(chatId)) { + return; + } + const chatData = queryClient.getQueryData(chatQueriesKeys.get(chatId).queryKey); if (!chatData) { diff --git a/libs/shared/data-access/api/src/lib/chats/utils/index.ts b/libs/shared/data-access/api/src/lib/chats/utils/index.ts index 8cb90130..faac6da1 100644 --- a/libs/shared/data-access/api/src/lib/chats/utils/index.ts +++ b/libs/shared/data-access/api/src/lib/chats/utils/index.ts @@ -18,3 +18,4 @@ export * from './prepare-update-message-in-chat-payload'; export * from './prepare-update-message-to-send-payload'; export * from './prepare-edit-assistant-message-payload'; export * from './prepare-copy-edited-message-payload'; +export * from './temporary-chat-id'; 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 7cc23b13..d6e7f530 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 @@ -32,12 +32,12 @@ export function prepareCompleteChatPayload({ userMessage, assistantMessageId, }: PrepareCompleteChatPayloadArgs): CompleteChatRequest { + const userSettings = queryClient.getQueryData(usersApiConfig.getUserSettingsQueryKey); + const prepareChatMessages = (): Array => { const chatResponse = queryClient.getQueryData(chatQueriesKeys.get(chatId).queryKey); const chatSystemPrompt = (chatResponse?.chat.params?.system as string | undefined)?.trim(); - const globalSystemPrompt = queryClient - .getQueryData(usersApiConfig.getUserSettingsQueryKey) - ?.ui.system?.trim(); + const globalSystemPrompt = userSettings?.ui.system?.trim(); // The user's default system prompt (Settings > General). Prepended to every completion // request rather than persisted into chat history, since the full history is resent each call. // Chat system prompt overrides the global system prompt. @@ -96,7 +96,7 @@ export function prepareCompleteChatPayload({ features: new Features({ codeInterpreter: generationOptions?.includes(ChatGenerationOption.CODE_INTERPRETER), imageGeneration: generationOptions?.includes(ChatGenerationOption.IMAGE_GENERATION), - webSearch: generationOptions?.includes(ChatGenerationOption.WEB_SEARCH), + webSearch: (userSettings?.ui.webSearch ?? false) || generationOptions?.includes(ChatGenerationOption.WEB_SEARCH), }), stream: true, model, diff --git a/libs/shared/data-access/api/src/lib/chats/utils/temporary-chat-id.ts b/libs/shared/data-access/api/src/lib/chats/utils/temporary-chat-id.ts new file mode 100644 index 00000000..89111923 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/chats/utils/temporary-chat-id.ts @@ -0,0 +1,10 @@ +import uuid from 'react-native-uuid'; + +// NOTE: Prefix mirrors the Open WebUI web app's own `temporary:${socketId}` convention +// (src/lib/utils/chatId.ts) — a synthetic id used only for socket/streaming correlation, +// never round-tripped through a persisted chats-table row. +const TEMPORARY_CHAT_ID_PREFIX = 'temporary:'; + +export const createTemporaryChatId = (): string => `${TEMPORARY_CHAT_ID_PREFIX}${uuid.v4()}`; + +export const isTemporaryChatId = (id?: string | null): boolean => !!id?.startsWith(TEMPORARY_CHAT_ID_PREFIX); diff --git a/libs/shared/data-access/api/src/lib/folders/config.ts b/libs/shared/data-access/api/src/lib/folders/config.ts index 7419b731..3c89435a 100644 --- a/libs/shared/data-access/api/src/lib/folders/config.ts +++ b/libs/shared/data-access/api/src/lib/folders/config.ts @@ -1,3 +1,6 @@ +const folderChatListQueryKeyPrefix = ['folders', 'chat-list']; +const folderChatsQueryKeyPrefix = ['folders', 'chats']; + export const foldersApiConfig = { route: 'v1/folders', chatsRoute: 'v1/chats', @@ -5,8 +8,10 @@ export const foldersApiConfig = { updateFolderQueryKey: ['folders', 'update'], deleteFolderQueryKey: ['folders', 'delete'], getFoldersQueryKey: ['folders', 'get'], - getFolderChatListQueryKey: (folderId: string): Array => ['folders', 'chat-list', folderId], - getFolderChatsQueryKey: (folderId: string): Array => ['folders', 'chats', folderId], + getFolderChatListQueryKeyPrefix: folderChatListQueryKeyPrefix, + getFolderChatListQueryKey: (folderId: string): Array => [...folderChatListQueryKeyPrefix, folderId], + getFolderChatsQueryKeyPrefix: folderChatsQueryKeyPrefix, + getFolderChatsQueryKey: (folderId: string): Array => [...folderChatsQueryKeyPrefix, folderId], getFolderQueryKey: (folderId: string): Array => ['folders', 'folder', folderId], chatsPerPage: 60, }; diff --git a/libs/shared/data-access/api/src/lib/users/api.ts b/libs/shared/data-access/api/src/lib/users/api.ts index e86380e9..a42788d0 100644 --- a/libs/shared/data-access/api/src/lib/users/api.ts +++ b/libs/shared/data-access/api/src/lib/users/api.ts @@ -21,11 +21,29 @@ function useGetUserSettings(): UseQueryResult, UserSettings>, -): UseMutationResult, UserSettings> { - return useMutation, UserSettings>({ + props?: UseMutationOptions, UserSettings, UpdateUserSettingsContext>, +): UseMutationResult, UserSettings, UpdateUserSettingsContext> { + return useMutation, UserSettings, UpdateUserSettingsContext>({ mutationFn: usersService.updateUserSettings, + onMutate: async (settings) => { + await queryClient.cancelQueries({ queryKey: usersApiConfig.getUserSettingsQueryKey }); + + const previousSettings = queryClient.getQueryData(usersApiConfig.getUserSettingsQueryKey); + + queryClient.setQueryData(usersApiConfig.getUserSettingsQueryKey, settings); + + return { previousSettings }; + }, + onError: (_error, _settings, context) => { + if (context?.previousSettings) { + queryClient.setQueryData(usersApiConfig.getUserSettingsQueryKey, context.previousSettings); + } + }, onSuccess: (settings) => { queryClient.setQueryData(usersApiConfig.getUserSettingsQueryKey, settings); }, diff --git a/libs/shared/data-access/api/src/lib/users/index.ts b/libs/shared/data-access/api/src/lib/users/index.ts index b25e08b0..bd92859f 100644 --- a/libs/shared/data-access/api/src/lib/users/index.ts +++ b/libs/shared/data-access/api/src/lib/users/index.ts @@ -1,3 +1,4 @@ export * from './service'; export * from './api'; export * from './models'; +export * from './config'; diff --git a/libs/shared/data-access/api/src/lib/users/models/ui-settings.ts b/libs/shared/data-access/api/src/lib/users/models/ui-settings.ts index cf9de988..c02ad610 100644 --- a/libs/shared/data-access/api/src/lib/users/models/ui-settings.ts +++ b/libs/shared/data-access/api/src/lib/users/models/ui-settings.ts @@ -10,6 +10,21 @@ export class UiSettings { @Expose() public system?: string; + @Expose() + public webSearch?: boolean; + + @Expose() + public enableMessageQueue?: boolean; + + @Expose() + public chatBubble?: boolean; + + @Expose() + public temporaryChatByDefault?: boolean; + + @Expose() + public renderMarkdownInUserMessages?: boolean; + constructor(response: Partial) { Object.assign(this, response); }