diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a41de46 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,33 @@ +name: Validate + +on: + pull_request: + branches: [main, master, development] + push: + branches: [main, master, development] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint --if-present + + - name: Run tests + run: npm test --if-present + + - name: Run build + run: npm run build --if-present diff --git a/libs/mobile/chat/features/chat/src/lib/component.tsx b/libs/mobile/chat/features/chat/src/lib/component.tsx index b224713..21a6dc7 100644 --- a/libs/mobile/chat/features/chat/src/lib/component.tsx +++ b/libs/mobile/chat/features/chat/src/lib/component.tsx @@ -173,8 +173,9 @@ export function Chat({ chatId, selectedModelId, isNewChat, resetToChatsList }: C attachedImages={attachedImages} onImageUploaded={handleImageUploaded} onDeleteImagePress={handleDeleteImage} - chatId={chatId} modelId={selectedModelId} + isResponseGenerating={isResponseGenerating} + chat={chat} /> )} diff --git a/libs/mobile/chat/features/form-chat-input/src/lib/component.tsx b/libs/mobile/chat/features/form-chat-input/src/lib/component.tsx index c69cc47..5ae539f 100644 --- a/libs/mobile/chat/features/form-chat-input/src/lib/component.tsx +++ b/libs/mobile/chat/features/form-chat-input/src/lib/component.tsx @@ -13,7 +13,13 @@ import { useImagePreview, } from '@open-webui-react-native/mobile/shared/features/image-preview-modal'; import { AppTextInput, AppInputProps, View, IconButton } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; -import { appConfigurationApi, ChatGenerationOption } from '@open-webui-react-native/shared/data-access/api'; +import { + appConfigurationApi, + ChatGenerationOption, + ChatResponse, + tasksApi, + tasksService, +} from '@open-webui-react-native/shared/data-access/api'; import { AttachedImage, FileData, ImageData } from '@open-webui-react-native/shared/data-access/common'; import { withOfflineGuard } from '@open-webui-react-native/shared/features/network'; import { FeatureID, isFeatureEnabled } from '@open-webui-react-native/shared/utils/feature-flag'; @@ -31,11 +37,12 @@ interface FormChatInputProps extends AppInputProps { attachedImages: Observable>; onImageUploaded: (image: ImageData) => void; onDeleteImagePress: (fileName: string) => void; - chatId?: string; + chat?: ChatResponse; modelId?: string; onChatCreated?: (id: string) => void; isLoading?: boolean; isSuggestionShown?: boolean; + isResponseGenerating?: boolean; } export interface FormChatInputSchema { @@ -52,16 +59,18 @@ export function FormChatInput({ attachedImages, onImageUploaded, onDeleteImagePress, - chatId, + chat, modelId, onChatCreated, isLoading, isSuggestionShown, + isResponseGenerating, ...restProps }: FormChatInputProps): ReactElement { const translate = useTranslation('CHAT.FORM_CHAT_INPUT'); const { data: config } = appConfigurationApi.useGetAppConfiguration(); + const stopTaskMutation = tasksApi.useStopTask(); const { field } = useController({ control, name }); @@ -88,7 +97,7 @@ export function FormChatInput({ return ToastService.showError(translate('TEXT_MODEL_NOT_SELECTED')); } setIsMicrophonePreparing(true); - await openVoiceModeModal({ chatId, modelId }); + await openVoiceModeModal({ chatId: chat?.id, modelId }); setIsMicrophonePreparing(false); }; @@ -110,6 +119,20 @@ export function FormChatInput({ field.onChange(text); }; + const onStopGenerationPress = async (): Promise => { + if (!chat) return; + + const chatId = chat.id; + const lastMessageId = chat.chat.history.currentId; + + const tasksData = await tasksService.getChatTasks(chatId); + const taskId = tasksData?.tasksIds[0]; + + if (taskId) { + stopTaskMutation.mutate({ taskId, chatId, lastMessageId }); + } + }; + return ( {isDictateMode ? ( @@ -144,6 +167,8 @@ export function FormChatInput({ isSubmitDisabled={!isFeatureEnabled(FeatureID.VOICE_MODE) && isInputEmpty} onVoiceModePress={onVoiceModePress} isVoiceModeAvailable={isFeatureEnabled(FeatureID.VOICE_MODE) && isInputEmpty} + onStopGenerationPress={onStopGenerationPress} + isResponseGenerating={isResponseGenerating} 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 9b47ba4..feb7ef5 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 @@ -4,6 +4,8 @@ import { IconButton, View } from '@open-webui-react-native/mobile/shared/ui/ui-k export interface ChatInputBottomRowProps extends PropsWithChildren { onSubmit: () => void; onVoiceModePress: () => void; + onStopGenerationPress: () => void; + isResponseGenerating?: boolean; isVoiceModeAvailable?: boolean; isSubmitDisabled?: boolean; isLoading?: boolean; @@ -16,18 +18,27 @@ export function ChatInputBottomRow({ isLoading, children, isSubmitDisabled, + isResponseGenerating, + onStopGenerationPress, }: ChatInputBottomRowProps): ReactElement { return ( {children} - + {isResponseGenerating ? ( + + ) : ( + + )} ); } diff --git a/libs/mobile/shared/ui/ui-kit/src/assets/icons/index.ts b/libs/mobile/shared/ui/ui-kit/src/assets/icons/index.ts index ca45c9e..8269688 100644 --- a/libs/mobile/shared/ui/ui-kit/src/assets/icons/index.ts +++ b/libs/mobile/shared/ui/ui-kit/src/assets/icons/index.ts @@ -39,6 +39,7 @@ import pin from './pin.svg'; import plusInCircle from './plus-in-circle.svg'; import plus from './plus.svg'; import search from './search.svg'; +import stop from './stop.svg'; import strokeLeft from './stroke-left.svg'; import tick from './tick.svg'; import trashCan from './trash-can.svg'; @@ -94,4 +95,5 @@ export const Icons = { closeSM, strokeLeft, tick, + stop, }; diff --git a/libs/mobile/shared/ui/ui-kit/src/assets/icons/stop.svg b/libs/mobile/shared/ui/ui-kit/src/assets/icons/stop.svg new file mode 100644 index 0000000..5e90cff --- /dev/null +++ b/libs/mobile/shared/ui/ui-kit/src/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/shared/data-access/api/src/lib/index.ts b/libs/shared/data-access/api/src/lib/index.ts index dfef958..4e90938 100644 --- a/libs/shared/data-access/api/src/lib/index.ts +++ b/libs/shared/data-access/api/src/lib/index.ts @@ -7,3 +7,4 @@ export * from './files'; export * from './audio'; export * from './folders'; export * from './knowledge'; +export * from './tasks'; diff --git a/libs/shared/data-access/api/src/lib/tasks/api.ts b/libs/shared/data-access/api/src/lib/tasks/api.ts new file mode 100644 index 0000000..2f8e13d --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/api.ts @@ -0,0 +1,40 @@ +import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { ApiErrorData } from '@open-webui-react-native/shared/data-access/api-client'; +import { Chat, patchChatQueryData } from '../chats'; +import { StopTaskResponse } from './models'; +import { tasksService } from './service'; + +type StopTaskArgs = { + taskId: string; + chatId: string; + lastMessageId: string; +}; + +function useStopTask( + props?: UseMutationOptions, StopTaskArgs>, +): UseMutationResult, StopTaskArgs> { + return useMutation({ + mutationFn: ({ taskId }) => tasksService.stopTask(taskId), + + onSuccess: (_, { chatId, lastMessageId }) => { + patchChatQueryData(chatId, { + chat: { + history: { + messages: { + [lastMessageId]: { + done: true, + }, + }, + }, + } as Chat, + }); + }, + + ...props, + }); +} + +export const tasksApi = { + useStopTask, +}; diff --git a/libs/shared/data-access/api/src/lib/tasks/config.ts b/libs/shared/data-access/api/src/lib/tasks/config.ts new file mode 100644 index 0000000..b0aa045 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/config.ts @@ -0,0 +1,3 @@ +export const tasksApiConfig = { + route: 'tasks', +}; diff --git a/libs/shared/data-access/api/src/lib/tasks/index.ts b/libs/shared/data-access/api/src/lib/tasks/index.ts new file mode 100644 index 0000000..97fbc73 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './service'; +export * from './config'; diff --git a/libs/shared/data-access/api/src/lib/tasks/models/chat-tasks-response.ts b/libs/shared/data-access/api/src/lib/tasks/models/chat-tasks-response.ts new file mode 100644 index 0000000..bbc2fc4 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/models/chat-tasks-response.ts @@ -0,0 +1,10 @@ +import { Expose } from 'class-transformer'; + +export class ChatTasksResponse { + @Expose({ name: 'task_ids' }) + public tasksIds: Array; + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/libs/shared/data-access/api/src/lib/tasks/models/index.ts b/libs/shared/data-access/api/src/lib/tasks/models/index.ts new file mode 100644 index 0000000..cae5973 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/models/index.ts @@ -0,0 +1,2 @@ +export * from './chat-tasks-response'; +export * from './stop-task-response'; diff --git a/libs/shared/data-access/api/src/lib/tasks/models/stop-task-response.ts b/libs/shared/data-access/api/src/lib/tasks/models/stop-task-response.ts new file mode 100644 index 0000000..76332c5 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/models/stop-task-response.ts @@ -0,0 +1,13 @@ +import { Expose } from 'class-transformer'; + +export class StopTaskResponse { + @Expose() + public message: string; + + @Expose() + public status: boolean; + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/libs/shared/data-access/api/src/lib/tasks/service.ts b/libs/shared/data-access/api/src/lib/tasks/service.ts new file mode 100644 index 0000000..c86ff90 --- /dev/null +++ b/libs/shared/data-access/api/src/lib/tasks/service.ts @@ -0,0 +1,18 @@ +import { plainToInstance } from 'class-transformer'; +import { getApiService } from '@open-webui-react-native/shared/data-access/api-client'; +import { tasksApiConfig } from './config'; +import { ChatTasksResponse, StopTaskResponse } from './models'; + +class TasksService { + public async getChatTasks(chatId: string): Promise { + const response = await getApiService().get(`${tasksApiConfig.route}/chat/${chatId}`); + + return plainToInstance(ChatTasksResponse, response, { excludeExtraneousValues: true }); + } + + public async stopTask(taskId: string): Promise { + return await getApiService().post(`${tasksApiConfig.route}/stop/${taskId}`); + } +} + +export const tasksService = new TasksService();