mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-07 03:48:44 +08:00
feat: add whatsapp web qr chat channel (#16238)
Adds a WhatsApp chat channel backed by a QR-based web login flow so users can connect without manual token setup.
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
import { DynamicForm, FormFieldConfig } from '@/components/dynamic-form';
|
||||
import { Modal } from '@/components/ui/modal/modal';
|
||||
import { IModalProps } from '@/interfaces/common';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FieldValues } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChatChannelFormDefaultValues,
|
||||
ChatChannelKey,
|
||||
getChatChannelFields,
|
||||
getChatChannelRuntimeStatusClass,
|
||||
getChatChannelRuntimeStatusText,
|
||||
mergeChatChannelFormValues,
|
||||
} from './constant';
|
||||
import { IChatChannel, IChatChannelInfo } from './interface';
|
||||
|
||||
const getRuntimeSnapshot = (payload: any) =>
|
||||
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
|
||||
|
||||
const AddChatChannelModal = ({
|
||||
visible,
|
||||
hideModal,
|
||||
@@ -24,6 +32,10 @@ const AddChatChannelModal = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fields, setFields] = useState<FormFieldConfig[]>([]);
|
||||
const [runtimeStatus, setRuntimeStatus] = useState('');
|
||||
const [runtimeError, setRuntimeError] = useState('');
|
||||
const [runtimeQr, setRuntimeQr] = useState('');
|
||||
const runtimePollingInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
@@ -31,6 +43,42 @@ const AddChatChannelModal = ({
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const refreshRuntime = useCallback(async () => {
|
||||
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
|
||||
return;
|
||||
}
|
||||
if (runtimePollingInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
runtimePollingInFlightRef.current = true;
|
||||
try {
|
||||
const { data } = await fetchChatChannelRuntime(record.id);
|
||||
const snapshot = getRuntimeSnapshot(data);
|
||||
setRuntimeStatus(snapshot?.status || '');
|
||||
setRuntimeError(snapshot?.last_error || '');
|
||||
setRuntimeQr(snapshot?.qr_data_url || '');
|
||||
} catch (error: any) {
|
||||
setRuntimeError(error?.message || 'Failed to load QR.');
|
||||
setRuntimeQr('');
|
||||
} finally {
|
||||
runtimePollingInFlightRef.current = false;
|
||||
}
|
||||
}, [channel?.id, record?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
|
||||
return;
|
||||
}
|
||||
setRuntimeStatus('');
|
||||
setRuntimeError('');
|
||||
setRuntimeQr('');
|
||||
void refreshRuntime();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshRuntime();
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [channel?.id, record?.id, refreshRuntime]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const base = channel ? ChatChannelFormDefaultValues[channel.id] : undefined;
|
||||
return mergeChatChannelFormValues(base, record) as FieldValues;
|
||||
@@ -63,6 +111,46 @@ const AddChatChannelModal = ({
|
||||
defaultValues={defaultValues}
|
||||
labelClassName="font-normal"
|
||||
>
|
||||
{channel?.id === ChatChannelKey.WHATSAPP && (
|
||||
<div className="mb-6 rounded-lg border border-border-button bg-bg-card p-4">
|
||||
{record?.id ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(runtimeStatus)}`}
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
<span>
|
||||
{getChatChannelRuntimeStatusText(runtimeStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{runtimeError ? (
|
||||
<div className="text-sm text-state-error">{runtimeError}</div>
|
||||
) : null}
|
||||
{runtimeQr && runtimeStatus !== 'connected' ? (
|
||||
<img
|
||||
src={runtimeQr}
|
||||
alt="WhatsApp QR"
|
||||
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
|
||||
/>
|
||||
) : runtimeStatus === 'connected' ? (
|
||||
<div className="text-sm text-state-success">
|
||||
Channel is connected.
|
||||
</div>
|
||||
) : !runtimeStatus ? (
|
||||
<div className="text-sm text-text-secondary">
|
||||
QR will appear after the channel starts.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
Save this WhatsApp channel first, then scan the QR code here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
|
||||
<DynamicForm.CancelButton
|
||||
handleCancel={() => {
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Modal } from '@/components/ui/modal/modal';
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { Link2, Settings, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
|
||||
import { Link2, QrCode, RefreshCw, Settings, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useChatChannelInfo } from '../constant';
|
||||
import {
|
||||
ChatChannelKey,
|
||||
getChatChannelRuntimeStatusClass,
|
||||
getChatChannelRuntimeStatusText,
|
||||
useChatChannelInfo,
|
||||
} from '../constant';
|
||||
import { useDeleteChatChannel, useFetchChatChannelDetail } from '../hooks';
|
||||
import { IChatChannel, IChatChannelBase, IChatChannelInfo } from '../interface';
|
||||
import ConnectDialogModal from './connect-dialog-modal';
|
||||
import { delChannelModal } from './delete-channel-modal';
|
||||
|
||||
const getRuntimeSnapshot = (payload: any) =>
|
||||
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
|
||||
|
||||
export type IAddedChannelCardProps = IChatChannelInfo & {
|
||||
list: IChatChannelBase[];
|
||||
onEdit: (channel: IChatChannelInfo, record: IChatChannel) => void;
|
||||
@@ -29,6 +39,13 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
|
||||
const [connectTarget, setConnectTarget] = useState<
|
||||
IChatChannelBase | undefined
|
||||
>(undefined);
|
||||
const [qrVisible, setQrVisible] = useState(false);
|
||||
const [qrChannelId, setQrChannelId] = useState<string>('');
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrData, setQrData] = useState<string>('');
|
||||
const [qrStatus, setQrStatus] = useState<string>('');
|
||||
const [qrError, setQrError] = useState<string>('');
|
||||
const qrPollingInFlightRef = useRef(false);
|
||||
|
||||
const handleEdit = async (id: string) => {
|
||||
const record = await fetchDetail(id);
|
||||
@@ -42,6 +59,42 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
|
||||
showConnectModal();
|
||||
};
|
||||
|
||||
const loadQr = useCallback(async () => {
|
||||
if (!qrChannelId) {
|
||||
return;
|
||||
}
|
||||
if (qrPollingInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
qrPollingInFlightRef.current = true;
|
||||
setQrLoading(true);
|
||||
try {
|
||||
const { data } = await fetchChatChannelRuntime(qrChannelId);
|
||||
const snapshot = getRuntimeSnapshot(data);
|
||||
setQrData(snapshot?.qr_data_url || '');
|
||||
setQrStatus(snapshot?.status || '');
|
||||
setQrError(snapshot?.last_error || '');
|
||||
} catch (error: any) {
|
||||
setQrData('');
|
||||
setQrStatus('');
|
||||
setQrError(error?.message || 'Failed to load QR.');
|
||||
} finally {
|
||||
qrPollingInFlightRef.current = false;
|
||||
setQrLoading(false);
|
||||
}
|
||||
}, [qrChannelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrVisible) {
|
||||
return;
|
||||
}
|
||||
void loadQr();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadQr();
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [qrVisible, loadQr]);
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
|
||||
@@ -85,6 +138,22 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
|
||||
>
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
{channel.channel === ChatChannelKey.WHATSAPP && (
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="rounded-lg px-2 py-1 bg-transparent hover:bg-bg-card"
|
||||
onClick={() => {
|
||||
setQrChannelId(item.id);
|
||||
setQrData('');
|
||||
setQrStatus('');
|
||||
setQrError('');
|
||||
setQrVisible(true);
|
||||
}}
|
||||
title="Show QR"
|
||||
>
|
||||
<QrCode size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="rounded-lg px-2 py-1 bg-transparent hover:bg-state-error-5 hover:text-state-error"
|
||||
@@ -111,6 +180,46 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
|
||||
channel={connectTarget}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
open={qrVisible}
|
||||
onOpenChange={(open) => !open && setQrVisible(false)}
|
||||
title="WhatsApp QR Code"
|
||||
size="large"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void loadQr()}>
|
||||
<RefreshCw className={qrLoading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setQrVisible(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(qrStatus)}`}
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
<span>{getChatChannelRuntimeStatusText(qrStatus)}</span>
|
||||
</div>
|
||||
{qrError ? (
|
||||
<div className="text-sm text-state-error">{qrError}</div>
|
||||
) : null}
|
||||
{qrData ? (
|
||||
<img
|
||||
src={qrData}
|
||||
alt="WhatsApp QR"
|
||||
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
QR is not ready yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,6 +93,60 @@ export const useChatChannelInfo = () => {
|
||||
return { chatChannelInfo };
|
||||
};
|
||||
|
||||
export const getChatChannelRuntimeStatusClass = (status?: string) => {
|
||||
const normalized = (status || '').toLowerCase();
|
||||
if (normalized === 'connected') {
|
||||
return 'bg-state-success/10 text-state-success border-state-success/20';
|
||||
}
|
||||
if (
|
||||
normalized === 'connecting' ||
|
||||
normalized === 'reconnecting' ||
|
||||
normalized === 'qr'
|
||||
) {
|
||||
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
|
||||
}
|
||||
if (normalized === 'waiting') {
|
||||
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
|
||||
}
|
||||
if (
|
||||
normalized === 'error' ||
|
||||
normalized === 'disconnected' ||
|
||||
normalized === 'stopped'
|
||||
) {
|
||||
return 'bg-state-error/10 text-state-error border-state-error/20';
|
||||
}
|
||||
return 'bg-gray-500/10 text-text-secondary border-border-button';
|
||||
};
|
||||
|
||||
export const getChatChannelRuntimeStatusText = (status?: string) => {
|
||||
const normalized = (status || '').toLowerCase();
|
||||
if (normalized === 'connected') {
|
||||
return 'Connected';
|
||||
}
|
||||
if (normalized === 'connecting') {
|
||||
return 'Connecting...';
|
||||
}
|
||||
if (normalized === 'reconnecting') {
|
||||
return 'Reconnecting...';
|
||||
}
|
||||
if (normalized === 'qr') {
|
||||
return 'Scan the QR code below';
|
||||
}
|
||||
if (normalized === 'waiting') {
|
||||
return 'Waiting for the channel to start';
|
||||
}
|
||||
if (normalized === 'error') {
|
||||
return 'Runtime error';
|
||||
}
|
||||
if (normalized === 'disconnected') {
|
||||
return 'Disconnected';
|
||||
}
|
||||
if (normalized === 'stopped') {
|
||||
return 'Stopped';
|
||||
}
|
||||
return 'Waiting for runtime...';
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -697,7 +751,6 @@ ChatChannelFormDefaultValues[
|
||||
].config.credential.connection_type = 'webhook';
|
||||
ChatChannelFormDefaultValues[ChatChannelKey.FEISHU].config.credential.domain =
|
||||
'feishu';
|
||||
|
||||
export const getChatChannelFields = (
|
||||
key?: ChatChannelKey,
|
||||
): FormFieldConfig[] => {
|
||||
|
||||
@@ -102,9 +102,16 @@ export const useAddChatChannel = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ChatChannelKeys.detail(values.id),
|
||||
});
|
||||
} else if (values.channel === ChatChannelKey.WHATSAPP) {
|
||||
setEditingRecord(res.data as IChatChannel);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ChatChannelKeys.detail(res.data?.id),
|
||||
});
|
||||
}
|
||||
message.success(t('message.operated'));
|
||||
hideModal();
|
||||
if (isEdit || values.channel !== ChatChannelKey.WHATSAPP) {
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -66,6 +66,7 @@ const ChatChannel = () => {
|
||||
ChatChannelKey.TELEGRAM,
|
||||
ChatChannelKey.QQBOT,
|
||||
ChatChannelKey.WECOM,
|
||||
ChatChannelKey.WHATSAPP,
|
||||
].includes(id), // Show only selected chat channels
|
||||
)
|
||||
.map((id) => ({
|
||||
|
||||
@@ -28,4 +28,7 @@ export const updateChatChannel = (id: string, data: Record<string, any>) =>
|
||||
export const deleteChatChannel = (id: string) =>
|
||||
request.delete(api.chatChannelDel(id));
|
||||
|
||||
export const fetchChatChannelRuntime = (id: string) =>
|
||||
request.get(api.chatChannelRuntime(id));
|
||||
|
||||
export default chatChannelService;
|
||||
|
||||
@@ -102,6 +102,8 @@ export default {
|
||||
chatChannelDetail: (id: string) => `${restAPIv1}/chat-channels/${id}`,
|
||||
chatChannelUpdate: (id: string) => `${restAPIv1}/chat-channels/${id}`,
|
||||
chatChannelDel: (id: string) => `${restAPIv1}/chat-channels/${id}`,
|
||||
chatChannelRuntime: (id: string) =>
|
||||
`${restAPIv1}/chat-channels/${id}/runtime`,
|
||||
|
||||
// plugin
|
||||
llmTools: `${restAPIv1}/plugin/tools`,
|
||||
|
||||
Reference in New Issue
Block a user