@inploi/plugin-chatbot 1.0.7 → 2.0.0
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 +11 -0
- package/package.json +11 -7
- package/src/chatbot.api.ts +14 -14
- package/src/chatbot.constants.ts +3 -0
- package/src/chatbot.css +8 -1
- package/src/chatbot.dom.ts +0 -6
- package/src/chatbot.idb.ts +17 -0
- package/src/chatbot.state.ts +52 -143
- package/src/chatbot.ts +16 -31
- package/src/chatbot.utils.ts +21 -9
- package/src/index.dev.ts +6 -0
- package/src/mocks/example.flows.ts +1 -1
- package/src/ui/chat-bubble.tsx +21 -5
- package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
- package/src/ui/chat-input/chat-input.file.tsx +8 -6
- package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
- package/src/ui/chat-input/chat-input.text.tsx +22 -16
- package/src/ui/chat-input/chat-input.tsx +47 -23
- package/src/ui/chatbot-header.tsx +35 -26
- package/src/ui/chatbot.tsx +95 -43
- package/src/ui/input-error.tsx +25 -31
- package/src/ui/job-application-content.tsx +65 -42
- package/src/ui/job-application-messages.tsx +42 -34
- package/src/ui/useChatService.ts +10 -17
- package/src/ui/useFocus.ts +10 -0
package/src/ui/input-error.tsx
CHANGED
|
@@ -1,39 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AnimatePresence, m } from 'framer-motion';
|
|
2
2
|
import type { FieldError } from 'react-hook-form';
|
|
3
|
-
import { Transition } from 'react-transition-group';
|
|
4
|
-
|
|
5
|
-
import { TransitionState } from './transition';
|
|
6
3
|
|
|
7
4
|
export const InputError = ({ error }: { error?: FieldError }) => {
|
|
8
5
|
return (
|
|
9
|
-
<
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
6
|
+
<AnimatePresence>
|
|
7
|
+
{error && (
|
|
8
|
+
<m.div
|
|
9
|
+
initial={{ scale: 0.5, opacity: 0 }}
|
|
10
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
11
|
+
exit={{ scale: 0, opacity: 0 }}
|
|
12
|
+
role="alert"
|
|
13
|
+
class="opacity-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
|
|
14
|
+
>
|
|
15
|
+
<svg
|
|
16
|
+
class="text-error-10"
|
|
17
|
+
width="16"
|
|
18
|
+
height="16"
|
|
19
|
+
viewBox="0 0 16 16"
|
|
20
|
+
fill="none"
|
|
21
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
19
22
|
>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
viewBox="0 0 16 16"
|
|
25
|
-
fill="none"
|
|
26
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
27
|
-
>
|
|
28
|
-
<circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
|
|
29
|
-
<rect x="7" y="4" width="2" height="5" fill="currentColor" />
|
|
30
|
-
<rect x="7" y="10" width="2" height="2" fill="currentColor" />
|
|
31
|
-
</svg>
|
|
23
|
+
<circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
|
|
24
|
+
<rect x="7" y="4" width="2" height="5" fill="currentColor" />
|
|
25
|
+
<rect x="7" y="10" width="2" height="2" fill="currentColor" />
|
|
26
|
+
</svg>
|
|
32
27
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</Transition>
|
|
28
|
+
<p class="text-sm truncate pr-1">{error.message}</p>
|
|
29
|
+
</m.div>
|
|
30
|
+
)}
|
|
31
|
+
</AnimatePresence>
|
|
38
32
|
);
|
|
39
33
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
-
import {
|
|
1
|
+
import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
+
import { useFocusGuards } from '@radix-ui/react-focus-guards';
|
|
3
|
+
import { AnimatePresence } from 'framer-motion';
|
|
4
|
+
import { useEffect, useLayoutEffect } from 'react';
|
|
3
5
|
import { match } from 'ts-pattern';
|
|
4
|
-
import {
|
|
6
|
+
import { StartedJobApplication, application, inputHeight, viewState } from '~/chatbot.state';
|
|
5
7
|
import { submissionsToPayload } from '~/chatbot.utils';
|
|
6
8
|
|
|
7
9
|
import { ERROR_MESSAGES } from '../chatbot.constants';
|
|
8
|
-
import { useApplicationLocalState, useLocalState } from '../chatbot.state';
|
|
9
10
|
import { createFlowInterpreter } from '../interpreter/interpreter';
|
|
10
11
|
import { ChatInput } from './chat-input/chat-input';
|
|
11
12
|
import { JobApplicationMessages } from './job-application-messages';
|
|
@@ -13,31 +14,55 @@ import { useChatService } from './useChatService';
|
|
|
13
14
|
|
|
14
15
|
type JobApplicationContentProps = {
|
|
15
16
|
apiClient: ApiClient;
|
|
16
|
-
logger
|
|
17
|
-
|
|
17
|
+
logger: Logger;
|
|
18
|
+
currentApplication: StartedJobApplication;
|
|
19
|
+
analytics: AnalyticsService;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
|
-
export const JobApplicationContent = ({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
export const JobApplicationContent = ({
|
|
23
|
+
currentApplication,
|
|
24
|
+
logger,
|
|
25
|
+
apiClient,
|
|
26
|
+
analytics,
|
|
27
|
+
}: JobApplicationContentProps) => {
|
|
28
|
+
const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({
|
|
29
|
+
logger,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
useFocusGuards();
|
|
33
|
+
|
|
34
|
+
const view = viewState.value;
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
// This significantly improves performance for maximising the view
|
|
37
|
+
if (view === 'maximised') scrollToEnd({ behavior: 'instant' });
|
|
38
|
+
}, [scrollToEnd, view]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
scrollToEnd({ behavior: 'smooth' });
|
|
42
|
+
}, [currentApplication.data.messages, scrollToEnd]);
|
|
24
43
|
|
|
25
44
|
useLayoutEffect(() => {
|
|
26
|
-
const { getApplicationLocalState, setInput, updateApplicationLocalState, updateApplicationCurrentNode } =
|
|
27
|
-
useLocalState.getState();
|
|
28
|
-
const applicationLocalState = getApplicationLocalState(application);
|
|
29
45
|
scrollToEnd({ behavior: 'instant' });
|
|
30
|
-
|
|
46
|
+
const currentApplication = application.current$.peek();
|
|
47
|
+
if (!currentApplication || currentApplication.data.isFinished) return;
|
|
31
48
|
|
|
32
49
|
const { interpret, abort } = createFlowInterpreter({
|
|
33
|
-
flow:
|
|
50
|
+
flow: currentApplication.flow.nodes,
|
|
34
51
|
chatService,
|
|
35
|
-
getSubmissions: () =>
|
|
36
|
-
beforeStart: async
|
|
37
|
-
setInput(
|
|
52
|
+
getSubmissions: () => application.current$.peek()?.data.submissions,
|
|
53
|
+
beforeStart: async () => {
|
|
54
|
+
application.setInput(undefined);
|
|
38
55
|
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
56
|
+
const fromBeginning = currentApplication.data.messages.length === 0;
|
|
57
|
+
if (fromBeginning) {
|
|
58
|
+
analytics.log({
|
|
59
|
+
event: 'APPLY_START',
|
|
60
|
+
properties: { job_id: currentApplication.job.id },
|
|
61
|
+
customProperties: {
|
|
62
|
+
flow_id: currentApplication.flow.id,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
41
66
|
await chatService.send({
|
|
42
67
|
message: {
|
|
43
68
|
type: 'system',
|
|
@@ -48,10 +73,10 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
|
|
|
48
73
|
}
|
|
49
74
|
},
|
|
50
75
|
onInterpret: node => {
|
|
51
|
-
|
|
76
|
+
application.setCurrentNodeId(node.id);
|
|
52
77
|
},
|
|
53
78
|
onFlowEnd: async lastNode => {
|
|
54
|
-
|
|
79
|
+
application.markAsFinished();
|
|
55
80
|
return match(lastNode)
|
|
56
81
|
.with({ type: 'abandon-flow' }, () => {
|
|
57
82
|
chatService.send({
|
|
@@ -63,12 +88,12 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
|
|
|
63
88
|
});
|
|
64
89
|
})
|
|
65
90
|
.with({ type: 'complete-flow' }, async () => {
|
|
66
|
-
const submissions =
|
|
91
|
+
const submissions = application.current$.peek()?.data.submissions;
|
|
67
92
|
if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
|
|
68
93
|
|
|
69
|
-
const response = await apiClient.fetch(`/flow/job/${
|
|
94
|
+
const response = await apiClient.fetch(`/flow/job/${currentApplication.job.id}`, {
|
|
70
95
|
method: 'POST',
|
|
71
|
-
body: JSON.stringify(submissionsToPayload(submissions)),
|
|
96
|
+
body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
|
|
72
97
|
});
|
|
73
98
|
|
|
74
99
|
match(response)
|
|
@@ -82,7 +107,7 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
|
|
|
82
107
|
});
|
|
83
108
|
})
|
|
84
109
|
.otherwise(response => {
|
|
85
|
-
logger
|
|
110
|
+
logger.error(response);
|
|
86
111
|
chatService.send({
|
|
87
112
|
message: {
|
|
88
113
|
type: 'system',
|
|
@@ -93,30 +118,28 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
|
|
|
93
118
|
});
|
|
94
119
|
})
|
|
95
120
|
.otherwise(() => {
|
|
96
|
-
logger
|
|
121
|
+
logger.error(ERROR_MESSAGES.invalid_end_node, lastNode);
|
|
97
122
|
});
|
|
98
123
|
},
|
|
99
124
|
});
|
|
100
125
|
|
|
101
|
-
interpret(
|
|
126
|
+
interpret(currentApplication.data.currentNodeId);
|
|
102
127
|
|
|
103
128
|
return abort;
|
|
104
|
-
}, [
|
|
129
|
+
}, [analytics, apiClient, chatService, logger, scrollToEnd]);
|
|
105
130
|
|
|
106
131
|
return (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
application={application}
|
|
117
|
-
onSubmit={onSubmitSuccessFn}
|
|
118
|
-
/>
|
|
132
|
+
<>
|
|
133
|
+
<div
|
|
134
|
+
ref={chatRef}
|
|
135
|
+
className="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
|
|
136
|
+
style={{ WebkitOverflowScrolling: 'touch', paddingBottom: inputHeight.value }}
|
|
137
|
+
>
|
|
138
|
+
<AnimatePresence>
|
|
139
|
+
<JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplication.data.messages} />
|
|
140
|
+
</AnimatePresence>
|
|
119
141
|
</div>
|
|
120
|
-
|
|
142
|
+
<ChatInput onInputChange={() => scrollToEnd({ behavior: 'smooth' })} onSubmit={onSubmitSuccessFn} />
|
|
143
|
+
</>
|
|
121
144
|
);
|
|
122
145
|
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { AnimatePresence } from 'framer-motion';
|
|
1
2
|
import { P, match } from 'ts-pattern';
|
|
2
3
|
|
|
3
4
|
import { ChatMessage } from '../chatbot.state';
|
|
4
5
|
import { ChatBubble } from './chat-bubble';
|
|
5
6
|
import { FileThumbnail } from './chat-input/chat-input.file';
|
|
7
|
+
// import { AnimatePresence } from './motion/animate-presence';
|
|
6
8
|
import { TypingIndicator } from './typing-indicator';
|
|
7
9
|
|
|
8
10
|
type JobApplicationMessagesProps = {
|
|
@@ -17,40 +19,46 @@ const authorToSide = {
|
|
|
17
19
|
|
|
18
20
|
export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
|
|
19
21
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
22
|
+
<ol
|
|
23
|
+
aria-label="Chat messages"
|
|
24
|
+
class="p-2 justify-end pt-[calc(var(--header-height)+1rem)] flex flex-col gap-2 flex-grow"
|
|
25
|
+
>
|
|
26
|
+
<AnimatePresence initial={false}>
|
|
27
|
+
{messages.map((message, i) => (
|
|
28
|
+
<li class="flex" key={i}>
|
|
29
|
+
{match(message)
|
|
30
|
+
.with({ type: 'system' }, message => (
|
|
31
|
+
<p class="uppercase w-full drop-shadow-[0_1.5px_white] text-[10px] text-neutral-8 select-none tracking-widest text-center py-2">
|
|
32
|
+
{message.text}
|
|
33
|
+
</p>
|
|
34
|
+
))
|
|
35
|
+
.with({ type: 'text', author: P.union('bot', 'user') }, message => {
|
|
36
|
+
return (
|
|
37
|
+
<ChatBubble key={i} side={authorToSide[message.author]}>
|
|
38
|
+
{message.text}
|
|
39
|
+
</ChatBubble>
|
|
40
|
+
);
|
|
41
|
+
})
|
|
42
|
+
.with({ type: 'image' }, image => (
|
|
43
|
+
<img
|
|
44
|
+
class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
|
|
45
|
+
src={image.url}
|
|
46
|
+
style={{ aspectRatio: image.width / image.height }}
|
|
47
|
+
/>
|
|
48
|
+
))
|
|
49
|
+
.with({ type: 'file' }, file => {
|
|
50
|
+
return (
|
|
51
|
+
<FileThumbnail
|
|
52
|
+
class={file.author === 'bot' ? '' : 'ml-auto'}
|
|
53
|
+
file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
})
|
|
57
|
+
.exhaustive()}
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</AnimatePresence>
|
|
53
61
|
<aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
|
|
54
|
-
</
|
|
62
|
+
</ol>
|
|
55
63
|
);
|
|
56
64
|
};
|
package/src/ui/useChatService.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Logger } from '@inploi/sdk';
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { match } from 'ts-pattern';
|
|
4
|
-
import {
|
|
5
|
-
import { useLocalState } from '~/chatbot.state';
|
|
4
|
+
import { application } from '~/chatbot.state';
|
|
6
5
|
import { ChatService } from '~/interpreter/interpreter';
|
|
7
6
|
|
|
8
7
|
import { SubmitSuccessFn } from './chat-input/chat-input';
|
|
@@ -10,12 +9,11 @@ import { SubmitSuccessFn } from './chat-input/chat-input';
|
|
|
10
9
|
const TYPING_SPEED_MS_PER_CHARACTER = 25;
|
|
11
10
|
|
|
12
11
|
type UseChatServiceParams = {
|
|
13
|
-
chatRef: Ref<HTMLDivElement>;
|
|
14
|
-
application: JobApplication;
|
|
15
12
|
logger?: Logger;
|
|
16
13
|
};
|
|
17
14
|
|
|
18
|
-
export const useChatService = ({ logger
|
|
15
|
+
export const useChatService = ({ logger }: UseChatServiceParams) => {
|
|
16
|
+
const chatRef = useRef<HTMLDivElement>(null);
|
|
19
17
|
const [isBotTyping, setIsBotTyping] = useState(false);
|
|
20
18
|
const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
|
|
21
19
|
const scrollToEnd = useMemo(
|
|
@@ -25,7 +23,6 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
|
|
|
25
23
|
);
|
|
26
24
|
|
|
27
25
|
const chatService = useMemo(() => {
|
|
28
|
-
const { setInput, updateSubmission, addMessage } = useLocalState.getState();
|
|
29
26
|
const chatService: ChatService = {
|
|
30
27
|
send: async ({ message, signal }) => {
|
|
31
28
|
await match(message)
|
|
@@ -49,20 +46,15 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
|
|
|
49
46
|
logger?.info(`Aborted sending message`);
|
|
50
47
|
return;
|
|
51
48
|
}
|
|
52
|
-
addMessage(
|
|
53
|
-
scrollToEnd({ behavior: 'smooth' });
|
|
49
|
+
application.addMessage(message);
|
|
54
50
|
},
|
|
55
51
|
input: async input => {
|
|
56
|
-
setInput(
|
|
52
|
+
application.setInput(input);
|
|
57
53
|
|
|
58
54
|
return await new Promise(resolve => {
|
|
59
55
|
const submitFunction: SubmitSuccessFn = submission => {
|
|
60
|
-
setInput(
|
|
61
|
-
|
|
62
|
-
application,
|
|
63
|
-
fieldKey: input.key,
|
|
64
|
-
data: submission,
|
|
65
|
-
});
|
|
56
|
+
application.setInput(undefined);
|
|
57
|
+
application.setSubmission(input.key, submission);
|
|
66
58
|
resolve(submission as any);
|
|
67
59
|
};
|
|
68
60
|
setOnSubmitSuccessFn(() => submitFunction);
|
|
@@ -71,9 +63,10 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
|
|
|
71
63
|
};
|
|
72
64
|
|
|
73
65
|
return chatService;
|
|
74
|
-
}, [
|
|
66
|
+
}, [logger]);
|
|
75
67
|
|
|
76
68
|
return {
|
|
69
|
+
chatRef,
|
|
77
70
|
chatService,
|
|
78
71
|
isBotTyping,
|
|
79
72
|
onSubmitSuccessFn,
|