Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
33 changes: 33 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion libs/mobile/chat/features/chat/src/lib/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
</View>
Expand Down
33 changes: 29 additions & 4 deletions libs/mobile/chat/features/form-chat-input/src/lib/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,11 +37,12 @@ interface FormChatInputProps<T extends FieldValues> extends AppInputProps {
attachedImages: Observable<Array<ImageData>>;
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 {
Expand All @@ -52,16 +59,18 @@ export function FormChatInput<T extends FieldValues>({
attachedImages,
onImageUploaded,
onDeleteImagePress,
chatId,
chat,
modelId,
onChatCreated,
isLoading,
isSuggestionShown,
isResponseGenerating,
...restProps
}: FormChatInputProps<T>): ReactElement {
const translate = useTranslation('CHAT.FORM_CHAT_INPUT');

const { data: config } = appConfigurationApi.useGetAppConfiguration();
const stopTaskMutation = tasksApi.useStopTask();

const { field } = useController({ control, name });

Expand All @@ -88,7 +97,7 @@ export function FormChatInput<T extends FieldValues>({
return ToastService.showError(translate('TEXT_MODEL_NOT_SELECTED'));
}
setIsMicrophonePreparing(true);
await openVoiceModeModal({ chatId, modelId });
await openVoiceModeModal({ chatId: chat?.id, modelId });
setIsMicrophonePreparing(false);
};

Expand All @@ -110,6 +119,20 @@ export function FormChatInput<T extends FieldValues>({
field.onChange(text);
};

const onStopGenerationPress = async (): Promise<void> => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After stopping response generation, we should also mark the last message as done: true. We use this flag to determine whether a response is still being generated or not. This can be done by patching the chat like this: patch-new-chat.ts.
We also patch this field for messages when retrieving the chat in chat api ( there is similar logic in the web application ).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm already doing that here

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 (
<View>
{isDictateMode ? (
Expand Down Expand Up @@ -144,6 +167,8 @@ export function FormChatInput<T extends FieldValues>({
isSubmitDisabled={!isFeatureEnabled(FeatureID.VOICE_MODE) && isInputEmpty}
onVoiceModePress={onVoiceModePress}
isVoiceModeAvailable={isFeatureEnabled(FeatureID.VOICE_MODE) && isInputEmpty}
onStopGenerationPress={onStopGenerationPress}
isResponseGenerating={isResponseGenerating}
isLoading={isLoading || isMicrophonePreparing}>
<View className='flex-row flex-1 justify-between'>
<View className='gap-16 flex-row '>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,18 +18,27 @@ export function ChatInputBottomRow({
isLoading,
children,
isSubmitDisabled,
isResponseGenerating,
onStopGenerationPress,
}: ChatInputBottomRowProps): ReactElement {
return (
<View className='flex-row justify-between items-center mt-12'>
{children}
<IconButton
disabled={isSubmitDisabled}
onPress={isVoiceModeAvailable ? onVoiceModePress : onSubmit}
iconName={isVoiceModeAvailable ? 'headphones' : 'arrowUp'}
className='rounded-full self-end bg-text-primary p-4'
iconProps={{ className: 'color-background-primary' }}
isLoading={isLoading}
/>
{isResponseGenerating ? (
<IconButton
iconName='stopCircle'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Tomass673 Could you please use this icon to keep the button style consistent?
stop.svg.zip
So, the button should look lie this:
Screenshot 2025-12-04 at 08 55 14

className='p-0'
onPress={onStopGenerationPress} />
) : (
<IconButton
disabled={isSubmitDisabled}
onPress={isVoiceModeAvailable ? onVoiceModePress : onSubmit}
iconName={isVoiceModeAvailable ? 'headphones' : 'arrowUp'}
className='rounded-full self-end bg-text-primary p-4'
iconProps={{ className: 'color-background-primary' }}
isLoading={isLoading}
/>
)}
</View>
);
}
2 changes: 2 additions & 0 deletions libs/mobile/shared/ui/ui-kit/src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 stopCircle from './stop-circle.svg';
import strokeLeft from './stroke-left.svg';
import tick from './tick.svg';
import trashCan from './trash-can.svg';
Expand Down Expand Up @@ -94,4 +95,5 @@ export const Icons = {
closeSM,
strokeLeft,
tick,
stopCircle,
};
4 changes: 4 additions & 0 deletions libs/mobile/shared/ui/ui-kit/src/assets/icons/stop-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions libs/shared/data-access/api/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './files';
export * from './audio';
export * from './folders';
export * from './knowledge';
export * from './tasks';
40 changes: 40 additions & 0 deletions libs/shared/data-access/api/src/lib/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -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<StopTaskResponse, AxiosError<ApiErrorData>, StopTaskArgs>,
): UseMutationResult<StopTaskResponse, AxiosError<ApiErrorData>, 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,
};
3 changes: 3 additions & 0 deletions libs/shared/data-access/api/src/lib/tasks/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const tasksApiConfig = {
route: 'tasks',
};
3 changes: 3 additions & 0 deletions libs/shared/data-access/api/src/lib/tasks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './api';
export * from './service';
export * from './config';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Expose } from 'class-transformer';

export class ChatTasksResponse {
@Expose({ name: 'task_ids' })
public tasksIds: Array<string>;

constructor(data: Partial<ChatTasksResponse>) {
Object.assign(this, data);
}
}
2 changes: 2 additions & 0 deletions libs/shared/data-access/api/src/lib/tasks/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './chat-tasks-response';
export * from './stop-task-response';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Expose } from 'class-transformer';

export class StopTaskResponse {
@Expose()
public message: string;

@Expose()
public status: boolean;

constructor(data: Partial<StopTaskResponse>) {
Object.assign(this, data);
}
}
18 changes: 18 additions & 0 deletions libs/shared/data-access/api/src/lib/tasks/service.ts
Original file line number Diff line number Diff line change
@@ -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<ChatTasksResponse> {
const response = await getApiService().get<ChatTasksResponse>(`${tasksApiConfig.route}/chat/${chatId}`);

return plainToInstance(ChatTasksResponse, response, { excludeExtraneousValues: true });
}

public async stopTask(taskId: string): Promise<StopTaskResponse> {
return await getApiService().post<StopTaskResponse>(`${tasksApiConfig.route}/stop/${taskId}`);
}
}

export const tasksService = new TasksService();
Loading