@tuturuuu/ui 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +6 -6
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
- package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
- package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
- package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
- package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
- package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
- package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
- package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
- package/src/components/ui/chat/chat-workspace.tsx +31 -1
- package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -1
- package/src/components/ui/chat/hooks-realtime.ts +13 -16
- package/src/hooks/use-semantic-task-search.ts +10 -33
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
Check,
|
|
6
|
+
LoaderCircle,
|
|
7
|
+
Play,
|
|
8
|
+
QrCode,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
ScanLine,
|
|
11
|
+
Square,
|
|
12
|
+
X,
|
|
13
|
+
} from '@tuturuuu/icons';
|
|
14
|
+
import type {
|
|
15
|
+
AiAgentChannelConfig,
|
|
16
|
+
AiAgentZaloPersonalAction,
|
|
17
|
+
AiAgentZaloPersonalHistorySyncResult,
|
|
18
|
+
AiAgentZaloPersonalPhoneSyncJobSnapshot,
|
|
19
|
+
AiAgentZaloPersonalPhoneSyncResult,
|
|
20
|
+
AiAgentZaloPersonalQrLoginSession,
|
|
21
|
+
AiAgentZaloPersonalQrLoginStatus,
|
|
22
|
+
} from '@tuturuuu/internal-api/infrastructure';
|
|
23
|
+
import {
|
|
24
|
+
abortAiAgentZaloPersonalQrLogin,
|
|
25
|
+
getAiAgentZaloPersonalQrLoginStatus,
|
|
26
|
+
getAiAgentZaloPersonalStatus,
|
|
27
|
+
runAiAgentZaloPersonalAction,
|
|
28
|
+
startAiAgentZaloPersonalQrLogin,
|
|
29
|
+
} from '@tuturuuu/internal-api/infrastructure';
|
|
30
|
+
import Image from 'next/image';
|
|
31
|
+
import { useTranslations } from 'next-intl';
|
|
32
|
+
import type { ReactNode } from 'react';
|
|
33
|
+
import { useEffect, useRef, useState } from 'react';
|
|
34
|
+
import { Badge } from '../badge';
|
|
35
|
+
import { Button } from '../button';
|
|
36
|
+
import { toast } from '../sonner';
|
|
37
|
+
import {
|
|
38
|
+
formatZaloPersonalError,
|
|
39
|
+
KeyValue,
|
|
40
|
+
PanelSection,
|
|
41
|
+
} from './chat-agent-details-utils';
|
|
42
|
+
|
|
43
|
+
const QR_QUERY_KEY = 'chat-zalo-personal-qr';
|
|
44
|
+
const STATUS_QUERY_KEY = 'chat-zalo-personal-status';
|
|
45
|
+
|
|
46
|
+
const ACTIVE_QR_STATUSES = new Set<AiAgentZaloPersonalQrLoginStatus>([
|
|
47
|
+
'pending',
|
|
48
|
+
'qr_generated',
|
|
49
|
+
'scanned',
|
|
50
|
+
'credentials_ready',
|
|
51
|
+
'authenticated',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const QR_STATUS_KEYS = {
|
|
55
|
+
aborted: 'agent_zalo_personal_qr_aborted',
|
|
56
|
+
authenticated: 'agent_zalo_personal_qr_authenticated',
|
|
57
|
+
credentials_ready: 'agent_zalo_personal_qr_credentials_ready',
|
|
58
|
+
declined: 'agent_zalo_personal_qr_declined',
|
|
59
|
+
expired: 'agent_zalo_personal_qr_expired',
|
|
60
|
+
failed: 'agent_zalo_personal_qr_failed',
|
|
61
|
+
pending: 'agent_zalo_personal_qr_pending',
|
|
62
|
+
persisted: 'agent_zalo_personal_qr_persisted',
|
|
63
|
+
qr_generated: 'agent_zalo_personal_qr_generated',
|
|
64
|
+
scanned: 'agent_zalo_personal_qr_scanned',
|
|
65
|
+
} as const satisfies Record<AiAgentZaloPersonalQrLoginStatus, string>;
|
|
66
|
+
|
|
67
|
+
const ACTION_LABEL_KEYS = {
|
|
68
|
+
start: 'agent_zalo_personal_start',
|
|
69
|
+
stop: 'agent_zalo_personal_stop',
|
|
70
|
+
'sync-history': 'agent_zalo_personal_sync_history',
|
|
71
|
+
'sync-phone': 'agent_zalo_personal_sync_phone',
|
|
72
|
+
validate: 'agent_zalo_personal_validate',
|
|
73
|
+
} as const satisfies Record<AiAgentZaloPersonalAction, string>;
|
|
74
|
+
|
|
75
|
+
const ACTION_SUCCESS_KEYS = {
|
|
76
|
+
start: 'agent_zalo_personal_start_success',
|
|
77
|
+
stop: 'agent_zalo_personal_stop_success',
|
|
78
|
+
validate: 'agent_zalo_personal_validate_success',
|
|
79
|
+
} as const satisfies Record<
|
|
80
|
+
Exclude<AiAgentZaloPersonalAction, 'sync-history' | 'sync-phone'>,
|
|
81
|
+
string
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
export function AgentZaloPersonalPanel({
|
|
85
|
+
agentId,
|
|
86
|
+
channel,
|
|
87
|
+
isPending,
|
|
88
|
+
onRefresh,
|
|
89
|
+
}: {
|
|
90
|
+
agentId: string;
|
|
91
|
+
channel: AiAgentChannelConfig;
|
|
92
|
+
isPending: boolean;
|
|
93
|
+
onRefresh: () => void;
|
|
94
|
+
}) {
|
|
95
|
+
const t = useTranslations('chat');
|
|
96
|
+
const queryClient = useQueryClient();
|
|
97
|
+
const [startedSession, setStartedSession] =
|
|
98
|
+
useState<AiAgentZaloPersonalQrLoginSession | null>(null);
|
|
99
|
+
const handledTerminalRef = useRef<string | null>(null);
|
|
100
|
+
const sessionId = startedSession?.sessionId ?? null;
|
|
101
|
+
const statusQuery = useQuery({
|
|
102
|
+
queryFn: () => getAiAgentZaloPersonalStatus(agentId, channel.id),
|
|
103
|
+
queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
|
|
104
|
+
refetchInterval: (query) =>
|
|
105
|
+
query.state.data?.phoneSyncJob?.status === 'running' ? 3000 : 10_000,
|
|
106
|
+
});
|
|
107
|
+
const qrQuery = useQuery({
|
|
108
|
+
enabled: Boolean(sessionId),
|
|
109
|
+
queryFn: () =>
|
|
110
|
+
getAiAgentZaloPersonalQrLoginStatus(agentId, channel.id, sessionId || ''),
|
|
111
|
+
queryKey: [QR_QUERY_KEY, agentId, channel.id, sessionId],
|
|
112
|
+
refetchInterval: (query) =>
|
|
113
|
+
isActiveQrStatus(query.state.data?.session.status) ? 2000 : false,
|
|
114
|
+
retry: false,
|
|
115
|
+
});
|
|
116
|
+
const session = qrQuery.data?.session ?? startedSession;
|
|
117
|
+
const startMutation = useMutation({
|
|
118
|
+
mutationFn: () => startAiAgentZaloPersonalQrLogin(agentId, channel.id),
|
|
119
|
+
onError: (error) =>
|
|
120
|
+
toast.error(error.message || t('agent_zalo_personal_qr_start_failed')),
|
|
121
|
+
onSuccess: (result) => {
|
|
122
|
+
handledTerminalRef.current = null;
|
|
123
|
+
setStartedSession(result.session);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const abortMutation = useMutation({
|
|
127
|
+
mutationFn: (targetSessionId: string) =>
|
|
128
|
+
abortAiAgentZaloPersonalQrLogin(agentId, channel.id, targetSessionId),
|
|
129
|
+
onError: (error) =>
|
|
130
|
+
toast.error(error.message || t('agent_zalo_personal_qr_abort_failed')),
|
|
131
|
+
onSuccess: (result) => {
|
|
132
|
+
setStartedSession(result.session);
|
|
133
|
+
toast.success(t('agent_zalo_personal_qr_abort_success'));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const actionMutation = useMutation({
|
|
137
|
+
mutationFn: (action: AiAgentZaloPersonalAction) =>
|
|
138
|
+
runAiAgentZaloPersonalAction(agentId, channel.id, action),
|
|
139
|
+
onError: (error) =>
|
|
140
|
+
toast.error(error.message || t('agent_zalo_personal_action_failed')),
|
|
141
|
+
onSuccess: (result, action) => {
|
|
142
|
+
queryClient.setQueryData([STATUS_QUERY_KEY, agentId, channel.id], {
|
|
143
|
+
phoneSyncJob: result.phoneSyncJob ?? null,
|
|
144
|
+
status: result.status,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (action === 'sync-history') {
|
|
148
|
+
const sync = (result.sync ?? {
|
|
149
|
+
synced: 0,
|
|
150
|
+
threads: 0,
|
|
151
|
+
timedOut: false,
|
|
152
|
+
}) as AiAgentZaloPersonalHistorySyncResult;
|
|
153
|
+
|
|
154
|
+
toast.success(
|
|
155
|
+
t('agent_zalo_personal_sync_history_success', {
|
|
156
|
+
count: sync.synced,
|
|
157
|
+
threads: sync.threads,
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (sync.timedOut) {
|
|
162
|
+
toast.warning(t('agent_zalo_personal_sync_history_timed_out'));
|
|
163
|
+
}
|
|
164
|
+
} else if (action === 'sync-phone') {
|
|
165
|
+
const sync = (result.sync ?? {
|
|
166
|
+
error: null,
|
|
167
|
+
status: 'waiting_for_phone',
|
|
168
|
+
synced: 0,
|
|
169
|
+
threads: 0,
|
|
170
|
+
}) as AiAgentZaloPersonalPhoneSyncResult;
|
|
171
|
+
|
|
172
|
+
if (sync.status === 'failed') {
|
|
173
|
+
toast.error(sync.error || t('agent_zalo_personal_sync_phone_failed'));
|
|
174
|
+
} else if (sync.status === 'waiting_for_phone') {
|
|
175
|
+
toast.warning(t('agent_zalo_personal_sync_phone_waiting'));
|
|
176
|
+
} else if (sync.status === 'completed_no_payload') {
|
|
177
|
+
toast.warning(t('agent_zalo_personal_sync_phone_no_payload'));
|
|
178
|
+
} else {
|
|
179
|
+
toast.success(
|
|
180
|
+
t('agent_zalo_personal_sync_phone_success', {
|
|
181
|
+
count: sync.synced,
|
|
182
|
+
threads: sync.threads,
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (sync.status === 'partial') {
|
|
187
|
+
toast.warning(t('agent_zalo_personal_sync_phone_partial'));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
toast.success(t(ACTION_SUCCESS_KEYS[action]));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
void queryClient.invalidateQueries({
|
|
195
|
+
queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
|
|
196
|
+
});
|
|
197
|
+
onRefresh();
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!session || isActiveQrStatus(session.status)) return;
|
|
203
|
+
const terminalKey = `${session.sessionId}:${session.status}`;
|
|
204
|
+
if (handledTerminalRef.current === terminalKey) return;
|
|
205
|
+
handledTerminalRef.current = terminalKey;
|
|
206
|
+
|
|
207
|
+
if (session.status === 'persisted') {
|
|
208
|
+
toast.success(t('agent_zalo_personal_qr_persisted_success'));
|
|
209
|
+
onRefresh();
|
|
210
|
+
} else if (session.status === 'failed') {
|
|
211
|
+
toast.error(session.error || t('agent_zalo_personal_qr_failed'));
|
|
212
|
+
}
|
|
213
|
+
}, [onRefresh, session, t]);
|
|
214
|
+
|
|
215
|
+
const listenerStatus = statusQuery.data?.status;
|
|
216
|
+
const phoneSyncJob = statusQuery.data?.phoneSyncJob ?? null;
|
|
217
|
+
const phoneSyncRunning = phoneSyncJob?.status === 'running';
|
|
218
|
+
const qrBusy = startMutation.isPending || abortMutation.isPending;
|
|
219
|
+
const actionBusy = isPending || actionMutation.isPending || phoneSyncRunning;
|
|
220
|
+
const listenerError =
|
|
221
|
+
listenerStatus?.lastError ===
|
|
222
|
+
'zalo_personal_phone_sync_waiting_for_phone' && phoneSyncJob
|
|
223
|
+
? null
|
|
224
|
+
: formatZaloPersonalError(listenerStatus?.lastError, t);
|
|
225
|
+
const phoneSyncJobMessage = getPhoneSyncJobMessage(phoneSyncJob, t);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<>
|
|
229
|
+
<PanelSection
|
|
230
|
+
icon={<QrCode className="size-4" />}
|
|
231
|
+
title={t('agent_zalo_personal_qr_title')}
|
|
232
|
+
>
|
|
233
|
+
<div className="min-w-0 space-y-3 overflow-hidden">
|
|
234
|
+
<div className="flex items-center justify-between gap-2">
|
|
235
|
+
<Badge
|
|
236
|
+
variant={
|
|
237
|
+
session?.status === 'persisted' ? 'success' : 'secondary'
|
|
238
|
+
}
|
|
239
|
+
>
|
|
240
|
+
{session
|
|
241
|
+
? t(QR_STATUS_KEYS[session.status])
|
|
242
|
+
: t('agent_zalo_personal_qr_not_started')}
|
|
243
|
+
</Badge>
|
|
244
|
+
{session?.expiresAt ? (
|
|
245
|
+
<span className="text-muted-foreground text-xs">
|
|
246
|
+
{new Date(session.expiresAt).toLocaleTimeString()}
|
|
247
|
+
</span>
|
|
248
|
+
) : null}
|
|
249
|
+
</div>
|
|
250
|
+
<div className="grid min-w-0 place-items-center overflow-hidden rounded-md border bg-background p-3">
|
|
251
|
+
{session?.qrImageDataUrl ? (
|
|
252
|
+
<Image
|
|
253
|
+
alt={t('agent_zalo_personal_qr_alt')}
|
|
254
|
+
className="aspect-square w-full max-w-56 rounded-sm"
|
|
255
|
+
height={224}
|
|
256
|
+
src={session.qrImageDataUrl}
|
|
257
|
+
unoptimized
|
|
258
|
+
width={224}
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<div className="grid aspect-square w-full max-w-56 place-items-center rounded-sm bg-muted/30 text-muted-foreground">
|
|
262
|
+
{qrBusy || isActiveQrStatus(session?.status) ? (
|
|
263
|
+
<LoaderCircle className="size-8 animate-spin" />
|
|
264
|
+
) : (
|
|
265
|
+
<QrCode className="size-8" />
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
{session?.scannedProfile ? (
|
|
271
|
+
<div className="flex items-center gap-2 rounded-md border bg-muted/20 p-2 text-sm">
|
|
272
|
+
<ScanLine className="size-4 text-dynamic-green" />
|
|
273
|
+
<span className="min-w-0 truncate">
|
|
274
|
+
{session.scannedProfile.displayName ??
|
|
275
|
+
t('agent_zalo_personal_qr_scanned')}
|
|
276
|
+
</span>
|
|
277
|
+
</div>
|
|
278
|
+
) : null}
|
|
279
|
+
{getQrErrorMessage(session, t) ? (
|
|
280
|
+
<p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
|
|
281
|
+
{getQrErrorMessage(session, t)}
|
|
282
|
+
</p>
|
|
283
|
+
) : null}
|
|
284
|
+
<div className="grid min-w-0 grid-cols-2 gap-2">
|
|
285
|
+
<Button
|
|
286
|
+
disabled={qrBusy}
|
|
287
|
+
onClick={() => startMutation.mutate()}
|
|
288
|
+
size="sm"
|
|
289
|
+
type="button"
|
|
290
|
+
>
|
|
291
|
+
{startMutation.isPending ? (
|
|
292
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
293
|
+
) : (
|
|
294
|
+
<QrCode className="size-4" />
|
|
295
|
+
)}
|
|
296
|
+
{t(
|
|
297
|
+
session
|
|
298
|
+
? 'agent_zalo_personal_qr_retry'
|
|
299
|
+
: 'agent_zalo_personal_qr_start'
|
|
300
|
+
)}
|
|
301
|
+
</Button>
|
|
302
|
+
<Button
|
|
303
|
+
disabled={
|
|
304
|
+
!sessionId || qrBusy || !isActiveQrStatus(session?.status)
|
|
305
|
+
}
|
|
306
|
+
onClick={() => sessionId && abortMutation.mutate(sessionId)}
|
|
307
|
+
size="sm"
|
|
308
|
+
type="button"
|
|
309
|
+
variant="secondary"
|
|
310
|
+
>
|
|
311
|
+
<X className="size-4" />
|
|
312
|
+
{t('agent_zalo_personal_qr_abort')}
|
|
313
|
+
</Button>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</PanelSection>
|
|
317
|
+
|
|
318
|
+
<PanelSection
|
|
319
|
+
icon={<Play className="size-4" />}
|
|
320
|
+
title={t('agent_zalo_personal_listener_title')}
|
|
321
|
+
>
|
|
322
|
+
<div className="min-w-0 space-y-3 overflow-hidden">
|
|
323
|
+
<KeyValue
|
|
324
|
+
label={t('agent_zalo_personal_feature')}
|
|
325
|
+
value={
|
|
326
|
+
listenerStatus
|
|
327
|
+
? listenerStatus.enabled
|
|
328
|
+
? t('agent_zalo_personal_enabled')
|
|
329
|
+
: t('agent_zalo_personal_disabled')
|
|
330
|
+
: t('agent_zalo_personal_not_available')
|
|
331
|
+
}
|
|
332
|
+
/>
|
|
333
|
+
<KeyValue
|
|
334
|
+
label={t('agent_zalo_personal_running')}
|
|
335
|
+
value={
|
|
336
|
+
listenerStatus
|
|
337
|
+
? listenerStatus.running
|
|
338
|
+
? t('agent_zalo_personal_running_value')
|
|
339
|
+
: t('agent_zalo_personal_stopped')
|
|
340
|
+
: t('agent_zalo_personal_not_available')
|
|
341
|
+
}
|
|
342
|
+
/>
|
|
343
|
+
<KeyValue
|
|
344
|
+
label={t('agent_zalo_personal_own_id')}
|
|
345
|
+
value={
|
|
346
|
+
listenerStatus?.ownId ??
|
|
347
|
+
channel.zaloPersonalOwnId ??
|
|
348
|
+
t('agent_zalo_personal_not_available')
|
|
349
|
+
}
|
|
350
|
+
/>
|
|
351
|
+
{listenerError ? (
|
|
352
|
+
<p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
|
|
353
|
+
{listenerError}
|
|
354
|
+
</p>
|
|
355
|
+
) : null}
|
|
356
|
+
{phoneSyncJobMessage ? (
|
|
357
|
+
<p
|
|
358
|
+
className={`break-words rounded-md border p-2 text-xs ${getPhoneSyncJobClassName(
|
|
359
|
+
phoneSyncJob
|
|
360
|
+
)}`}
|
|
361
|
+
>
|
|
362
|
+
{phoneSyncJobMessage}
|
|
363
|
+
</p>
|
|
364
|
+
) : null}
|
|
365
|
+
<div className="grid min-w-0 grid-cols-2 gap-2">
|
|
366
|
+
<ActionButton
|
|
367
|
+
action="validate"
|
|
368
|
+
busy={actionBusy}
|
|
369
|
+
icon={<Check className="size-4" />}
|
|
370
|
+
onRun={actionMutation.mutate}
|
|
371
|
+
/>
|
|
372
|
+
<ActionButton
|
|
373
|
+
action="start"
|
|
374
|
+
busy={actionBusy}
|
|
375
|
+
icon={<Play className="size-4" />}
|
|
376
|
+
onRun={actionMutation.mutate}
|
|
377
|
+
/>
|
|
378
|
+
<ActionButton
|
|
379
|
+
action="sync-history"
|
|
380
|
+
busy={actionBusy}
|
|
381
|
+
icon={<RefreshCw className="size-4" />}
|
|
382
|
+
onRun={actionMutation.mutate}
|
|
383
|
+
/>
|
|
384
|
+
<ActionButton
|
|
385
|
+
action="sync-phone"
|
|
386
|
+
busy={actionBusy}
|
|
387
|
+
icon={<RefreshCw className="size-4" />}
|
|
388
|
+
onRun={actionMutation.mutate}
|
|
389
|
+
/>
|
|
390
|
+
<ActionButton
|
|
391
|
+
action="stop"
|
|
392
|
+
busy={actionBusy}
|
|
393
|
+
icon={<Square className="size-4" />}
|
|
394
|
+
onRun={actionMutation.mutate}
|
|
395
|
+
/>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</PanelSection>
|
|
399
|
+
</>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getPhoneSyncJobMessage(
|
|
404
|
+
job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null,
|
|
405
|
+
t: ReturnType<typeof useTranslations>
|
|
406
|
+
) {
|
|
407
|
+
if (!job) return null;
|
|
408
|
+
|
|
409
|
+
if (job.status === 'running') {
|
|
410
|
+
return t('agent_zalo_personal_sync_phone_waiting');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (job.status === 'failed') {
|
|
414
|
+
return (
|
|
415
|
+
formatZaloPersonalError(job.error, t) ||
|
|
416
|
+
t('agent_zalo_personal_sync_phone_failed')
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const sync = job.sync;
|
|
421
|
+
|
|
422
|
+
if (!sync) return null;
|
|
423
|
+
|
|
424
|
+
if (sync.status === 'failed') {
|
|
425
|
+
return (
|
|
426
|
+
formatZaloPersonalError(sync.error, t) ||
|
|
427
|
+
t('agent_zalo_personal_sync_phone_failed')
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (sync.status === 'waiting_for_phone') {
|
|
432
|
+
if (sync.pullAttempts > 0) {
|
|
433
|
+
return sync.requestHttpError
|
|
434
|
+
? t('agent_zalo_personal_sync_phone_no_approval_http_unavailable')
|
|
435
|
+
: t('agent_zalo_personal_sync_phone_no_approval');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return t('agent_zalo_personal_sync_phone_waiting');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (sync.status === 'completed_no_payload') {
|
|
442
|
+
return sync.requestHttpError
|
|
443
|
+
? t('agent_zalo_personal_sync_phone_no_payload_http_unavailable')
|
|
444
|
+
: t('agent_zalo_personal_sync_phone_no_payload');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (sync.status === 'partial') {
|
|
448
|
+
return t('agent_zalo_personal_sync_phone_partial');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return t('agent_zalo_personal_sync_phone_success', {
|
|
452
|
+
count: sync.synced,
|
|
453
|
+
threads: sync.threads,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getPhoneSyncJobClassName(
|
|
458
|
+
job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null
|
|
459
|
+
) {
|
|
460
|
+
if (job?.status === 'failed' || job?.sync?.status === 'failed') {
|
|
461
|
+
return 'border-dynamic-red/20 bg-dynamic-red/5 text-dynamic-red';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (job?.sync?.status === 'completed') {
|
|
465
|
+
return 'border-dynamic-green/20 bg-dynamic-green/5 text-dynamic-green';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return 'border-dynamic-yellow/20 bg-dynamic-yellow/5 text-dynamic-yellow';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getQrErrorMessage(
|
|
472
|
+
session: AiAgentZaloPersonalQrLoginSession | null | undefined,
|
|
473
|
+
t: ReturnType<typeof useTranslations>
|
|
474
|
+
) {
|
|
475
|
+
if (!session?.error) return null;
|
|
476
|
+
|
|
477
|
+
if (
|
|
478
|
+
session.status === 'aborted' ||
|
|
479
|
+
session.status === 'declined' ||
|
|
480
|
+
session.status === 'expired'
|
|
481
|
+
) {
|
|
482
|
+
return t(QR_STATUS_KEYS[session.status]);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return session.error;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function ActionButton({
|
|
489
|
+
action,
|
|
490
|
+
busy,
|
|
491
|
+
icon,
|
|
492
|
+
onRun,
|
|
493
|
+
}: {
|
|
494
|
+
action: AiAgentZaloPersonalAction;
|
|
495
|
+
busy: boolean;
|
|
496
|
+
icon: ReactNode;
|
|
497
|
+
onRun: (action: AiAgentZaloPersonalAction) => void;
|
|
498
|
+
}) {
|
|
499
|
+
const t = useTranslations('chat');
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<Button
|
|
503
|
+
disabled={busy}
|
|
504
|
+
onClick={() => onRun(action)}
|
|
505
|
+
size="sm"
|
|
506
|
+
type="button"
|
|
507
|
+
variant="outline"
|
|
508
|
+
>
|
|
509
|
+
{busy ? <LoaderCircle className="size-4 animate-spin" /> : icon}
|
|
510
|
+
{t(ACTION_LABEL_KEYS[action])}
|
|
511
|
+
</Button>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isActiveQrStatus(status?: AiAgentZaloPersonalQrLoginStatus) {
|
|
516
|
+
return status ? ACTIVE_QR_STATUSES.has(status) : false;
|
|
517
|
+
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ChatAttachment,
|
|
6
6
|
ChatAttachmentDraft,
|
|
7
7
|
ChatConversation,
|
|
8
|
+
ChatMessage,
|
|
8
9
|
} from '@tuturuuu/internal-api';
|
|
9
10
|
import { cn } from '@tuturuuu/utils/format';
|
|
10
11
|
import { usePathname, useSearchParams } from 'next/navigation';
|
|
@@ -152,11 +153,17 @@ export function ChatWorkspace({
|
|
|
152
153
|
conversationId: selectedVirtualReadOnly ? null : activeConversationId,
|
|
153
154
|
wsId,
|
|
154
155
|
});
|
|
156
|
+
const fetchedMessages = selectedVirtualReadOnly
|
|
157
|
+
? []
|
|
158
|
+
: flattenChatMessagePages(messagesQuery.data);
|
|
155
159
|
const messages = selectedVirtualReadOnly
|
|
156
160
|
? selectedConversation?.latestMessage
|
|
157
161
|
? [selectedConversation.latestMessage]
|
|
158
162
|
: []
|
|
159
|
-
:
|
|
163
|
+
: mergeConversationLatestMessage(
|
|
164
|
+
fetchedMessages,
|
|
165
|
+
selectedConversation?.latestMessage
|
|
166
|
+
);
|
|
160
167
|
const sendMessage = useSendChatMessage({
|
|
161
168
|
conversationId: activeConversationId,
|
|
162
169
|
currentUserId,
|
|
@@ -567,6 +574,29 @@ export function ChatWorkspace({
|
|
|
567
574
|
);
|
|
568
575
|
}
|
|
569
576
|
|
|
577
|
+
function mergeConversationLatestMessage(
|
|
578
|
+
messages: ChatMessage[],
|
|
579
|
+
latestMessage?: ChatMessage | null
|
|
580
|
+
) {
|
|
581
|
+
if (
|
|
582
|
+
!latestMessage ||
|
|
583
|
+
messages.some((message) => message.id === latestMessage.id)
|
|
584
|
+
) {
|
|
585
|
+
return messages;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return [...messages, latestMessage].sort(
|
|
589
|
+
(left, right) =>
|
|
590
|
+
readChatMessageTimestamp(left.createdAt) -
|
|
591
|
+
readChatMessageTimestamp(right.createdAt)
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function readChatMessageTimestamp(value: string) {
|
|
596
|
+
const timestamp = Date.parse(value);
|
|
597
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
570
600
|
const POSTGRES_UUID_PATTERN =
|
|
571
601
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
572
602
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
1
2
|
import type { ChatMessage } from '@tuturuuu/internal-api';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type ChatMessagesPage,
|
|
6
|
+
mergeCachedMessages,
|
|
7
|
+
patchCachedMessages,
|
|
8
|
+
} from './hooks-messages';
|
|
9
|
+
import { chatQueryKeys } from './query-keys';
|
|
4
10
|
|
|
5
11
|
function createMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
|
6
12
|
return {
|
|
@@ -65,3 +71,41 @@ describe('mergeCachedMessages', () => {
|
|
|
65
71
|
).toEqual(['message-user', 'message-assistant']);
|
|
66
72
|
});
|
|
67
73
|
});
|
|
74
|
+
|
|
75
|
+
describe('patchCachedMessages', () => {
|
|
76
|
+
it('patches infinite message pages used by the chat workspace', () => {
|
|
77
|
+
const queryClient = new QueryClient();
|
|
78
|
+
const wsId = 'workspace-1';
|
|
79
|
+
const conversationId = 'conversation-1';
|
|
80
|
+
const message = createMessage();
|
|
81
|
+
const queryKey = chatQueryKeys.messagesInfinite(wsId, conversationId, 80);
|
|
82
|
+
|
|
83
|
+
queryClient.setQueryData<{
|
|
84
|
+
pageParams: (string | null)[];
|
|
85
|
+
pages: ChatMessagesPage[];
|
|
86
|
+
}>(queryKey, {
|
|
87
|
+
pageParams: [null],
|
|
88
|
+
pages: [
|
|
89
|
+
{
|
|
90
|
+
hasMore: false,
|
|
91
|
+
limit: 80,
|
|
92
|
+
messages: [],
|
|
93
|
+
nextBefore: null,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
patchCachedMessages(queryClient, wsId, conversationId, (current) =>
|
|
99
|
+
mergeCachedMessages(current, [message])
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
queryClient
|
|
104
|
+
.getQueryData<{
|
|
105
|
+
pageParams: (string | null)[];
|
|
106
|
+
pages: ChatMessagesPage[];
|
|
107
|
+
}>(queryKey)
|
|
108
|
+
?.pages[0]?.messages.map(({ id }) => id)
|
|
109
|
+
).toEqual(['message-1']);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
5
5
|
import { useEffect } from 'react';
|
|
6
|
+
import { mergeCachedMessages, patchCachedMessages } from './hooks-messages';
|
|
6
7
|
import { chatQueryKeys } from './query-keys';
|
|
7
8
|
|
|
8
9
|
type ChatRealtimeEvent =
|
|
@@ -137,21 +138,17 @@ function patchMessage(
|
|
|
137
138
|
wsId: string,
|
|
138
139
|
message: ChatMessage
|
|
139
140
|
) {
|
|
140
|
-
queryClient.
|
|
141
|
-
|
|
142
|
-
queryKey: [
|
|
143
|
-
...chatQueryKeys.all(wsId),
|
|
144
|
-
'messages',
|
|
145
|
-
message.conversationId,
|
|
146
|
-
],
|
|
147
|
-
},
|
|
148
|
-
(current = []) => {
|
|
149
|
-
const existingIndex = current.findIndex((item) => item.id === message.id);
|
|
150
|
-
if (existingIndex < 0) return [...current, message];
|
|
151
|
-
|
|
152
|
-
return current.map((item, index) =>
|
|
153
|
-
index === existingIndex ? message : item
|
|
154
|
-
);
|
|
155
|
-
}
|
|
141
|
+
patchCachedMessages(queryClient, wsId, message.conversationId, (current) =>
|
|
142
|
+
mergeCachedMessages(current, [message])
|
|
156
143
|
);
|
|
144
|
+
queryClient.invalidateQueries({
|
|
145
|
+
queryKey: [...chatQueryKeys.all(wsId), 'messages', message.conversationId],
|
|
146
|
+
});
|
|
147
|
+
queryClient.invalidateQueries({
|
|
148
|
+
queryKey: [
|
|
149
|
+
...chatQueryKeys.all(wsId),
|
|
150
|
+
'messages-infinite',
|
|
151
|
+
message.conversationId,
|
|
152
|
+
],
|
|
153
|
+
});
|
|
157
154
|
}
|