@inploi/plugin-chatbot 1.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/.env.example +3 -0
- package/.eslintrc.cjs +10 -0
- package/CHANGELOG.md +17 -0
- package/bunfig.toml +2 -0
- package/happydom.ts +10 -0
- package/index.html +28 -0
- package/package.json +58 -0
- package/postcss.config.cjs +7 -0
- package/public/mockServiceWorker.js +292 -0
- package/src/chatbot.api.ts +46 -0
- package/src/chatbot.constants.ts +6 -0
- package/src/chatbot.css +100 -0
- package/src/chatbot.dom.ts +27 -0
- package/src/chatbot.state.ts +180 -0
- package/src/chatbot.ts +77 -0
- package/src/chatbot.utils.ts +38 -0
- package/src/index.cdn.ts +12 -0
- package/src/index.dev.ts +32 -0
- package/src/index.ts +1 -0
- package/src/interpreter/interpreter.test.ts +69 -0
- package/src/interpreter/interpreter.ts +241 -0
- package/src/mocks/browser.ts +5 -0
- package/src/mocks/example.flows.ts +763 -0
- package/src/mocks/handlers.ts +28 -0
- package/src/ui/chat-bubble.tsx +36 -0
- package/src/ui/chat-input/chat-input.boolean.tsx +57 -0
- package/src/ui/chat-input/chat-input.file.tsx +211 -0
- package/src/ui/chat-input/chat-input.multiple-choice.tsx +92 -0
- package/src/ui/chat-input/chat-input.text.tsx +105 -0
- package/src/ui/chat-input/chat-input.tsx +57 -0
- package/src/ui/chatbot-header.tsx +89 -0
- package/src/ui/chatbot.tsx +53 -0
- package/src/ui/input-error.tsx +39 -0
- package/src/ui/job-application-content.tsx +122 -0
- package/src/ui/job-application-messages.tsx +56 -0
- package/src/ui/loading-indicator.tsx +37 -0
- package/src/ui/send-button.tsx +27 -0
- package/src/ui/transition.tsx +1 -0
- package/src/ui/typing-indicator.tsx +12 -0
- package/src/ui/useChatService.ts +82 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.ts +119 -0
- package/tsconfig.json +33 -0
- package/tsconfig.node.json +10 -0
- package/types.d.ts +2 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
+
import { useRef } from 'preact/hooks';
|
|
3
|
+
import { Transition } from 'react-transition-group';
|
|
4
|
+
import { overlayClassNames } from '~/chatbot.dom';
|
|
5
|
+
|
|
6
|
+
import { useChatbotStore } from '../chatbot.state';
|
|
7
|
+
import { ChatbotHeader } from './chatbot-header';
|
|
8
|
+
import { JobApplicationContent } from './job-application-content';
|
|
9
|
+
import { TransitionState } from './transition';
|
|
10
|
+
|
|
11
|
+
type ChatbotProps = {
|
|
12
|
+
apiClient: ApiClient;
|
|
13
|
+
logger?: Logger;
|
|
14
|
+
};
|
|
15
|
+
export const Chatbot = ({ logger, apiClient }: ChatbotProps) => {
|
|
16
|
+
const { currentApplication, viewState } = useChatbotStore();
|
|
17
|
+
const isOpen = currentApplication !== null;
|
|
18
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const chatRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<Transition nodeRef={overlayRef} in={isOpen && viewState === 'maximised'} timeout={100}>
|
|
23
|
+
{(state: TransitionState) => {
|
|
24
|
+
if (state === 'exited') return null;
|
|
25
|
+
return <div ref={overlayRef} data-transition={state} class={overlayClassNames} />;
|
|
26
|
+
}}
|
|
27
|
+
</Transition>
|
|
28
|
+
<Transition appear={true} nodeRef={chatRef} in={isOpen} timeout={0}>
|
|
29
|
+
{(state: TransitionState) => {
|
|
30
|
+
if (state === 'exited') return null;
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
ref={chatRef}
|
|
34
|
+
style={{ '--header-height': '44px' }}
|
|
35
|
+
data-transition={state}
|
|
36
|
+
data-state={viewState}
|
|
37
|
+
class="isolate h-[75vh] h-[75lvh] data-[transition=entered]:translate-y-0 data-[transition=entered]:data-[state=minimised]:translate-y-[calc(100%-var(--header-height)-0.5rem)] transition-transform ease-expo-out duration-500 translate-y-full fixed left-0 right-0 mx-auto w-full max-w-[450px] bottom-0 p-2 max-h-full overflow-hidden focus:outline-none"
|
|
38
|
+
>
|
|
39
|
+
<div class="outline outline-1 h-full outline-neutral-5 max-h-full relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden">
|
|
40
|
+
{currentApplication && (
|
|
41
|
+
<>
|
|
42
|
+
<ChatbotHeader application={currentApplication} />
|
|
43
|
+
<JobApplicationContent application={currentApplication} apiClient={apiClient} logger={logger} />
|
|
44
|
+
</>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}}
|
|
50
|
+
</Transition>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Ref } from 'preact';
|
|
2
|
+
import type { FieldError } from 'react-hook-form';
|
|
3
|
+
import { Transition } from 'react-transition-group';
|
|
4
|
+
|
|
5
|
+
import { TransitionState } from './transition';
|
|
6
|
+
|
|
7
|
+
export const InputError = ({ error }: { error?: FieldError }) => {
|
|
8
|
+
return (
|
|
9
|
+
<Transition in={Boolean(error)} timeout={15}>
|
|
10
|
+
{(state: TransitionState) => {
|
|
11
|
+
if (!error) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
ref={error.ref as Ref<HTMLDivElement> | undefined}
|
|
16
|
+
data-transition={state}
|
|
17
|
+
role="alert"
|
|
18
|
+
class="transition-all opacity-0 ease-expo-out duration-300 translate-y-1/2 data-[transition=entered]:opacity-100 data-[transition=entered]:translate-y-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
|
|
19
|
+
>
|
|
20
|
+
<svg
|
|
21
|
+
class="text-error-10"
|
|
22
|
+
width="16"
|
|
23
|
+
height="16"
|
|
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>
|
|
32
|
+
|
|
33
|
+
<p class="text-sm truncate pr-1">{error.message}</p>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}}
|
|
37
|
+
</Transition>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { JobApplication } from '~/chatbot.api';
|
|
5
|
+
import { submissionsToPayload } from '~/chatbot.utils';
|
|
6
|
+
|
|
7
|
+
import { ERROR_MESSAGES } from '../chatbot.constants';
|
|
8
|
+
import { useApplicationLocalState, useLocalState } from '../chatbot.state';
|
|
9
|
+
import { createFlowInterpreter } from '../interpreter/interpreter';
|
|
10
|
+
import { ChatInput } from './chat-input/chat-input';
|
|
11
|
+
import { JobApplicationMessages } from './job-application-messages';
|
|
12
|
+
import { useChatService } from './useChatService';
|
|
13
|
+
|
|
14
|
+
type JobApplicationContentProps = {
|
|
15
|
+
apiClient: ApiClient;
|
|
16
|
+
logger?: Logger;
|
|
17
|
+
application: JobApplication;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const JobApplicationContent = ({ application, logger, apiClient }: JobApplicationContentProps) => {
|
|
21
|
+
const currentApplicationLocalState = useApplicationLocalState(application);
|
|
22
|
+
const chatRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const { chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({ application, chatRef, logger });
|
|
24
|
+
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
const { getApplicationLocalState, setInput, updateApplicationLocalState, updateApplicationCurrentNode } =
|
|
27
|
+
useLocalState.getState();
|
|
28
|
+
const applicationLocalState = getApplicationLocalState(application);
|
|
29
|
+
scrollToEnd({ behavior: 'instant' });
|
|
30
|
+
if (applicationLocalState?.isFinished) return;
|
|
31
|
+
|
|
32
|
+
const { interpret, abort } = createFlowInterpreter({
|
|
33
|
+
flow: application.flow.nodes,
|
|
34
|
+
chatService,
|
|
35
|
+
getSubmissions: () => getApplicationLocalState(application)?.submissions,
|
|
36
|
+
beforeStart: async node => {
|
|
37
|
+
setInput({ application, data: null });
|
|
38
|
+
|
|
39
|
+
const isResuming = !node.isHead;
|
|
40
|
+
if (isResuming) {
|
|
41
|
+
await chatService.send({
|
|
42
|
+
message: {
|
|
43
|
+
type: 'system',
|
|
44
|
+
text: 'Restored in progress application',
|
|
45
|
+
variant: 'info',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
onInterpret: node => {
|
|
51
|
+
updateApplicationCurrentNode({ application, currentNodeId: node.id });
|
|
52
|
+
},
|
|
53
|
+
onFlowEnd: async lastNode => {
|
|
54
|
+
updateApplicationLocalState({ application, data: { isFinished: true } });
|
|
55
|
+
return match(lastNode)
|
|
56
|
+
.with({ type: 'abandon-flow' }, () => {
|
|
57
|
+
chatService.send({
|
|
58
|
+
message: {
|
|
59
|
+
type: 'system',
|
|
60
|
+
text: 'Application ended',
|
|
61
|
+
variant: 'success',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
})
|
|
65
|
+
.with({ type: 'complete-flow' }, async () => {
|
|
66
|
+
const submissions = getApplicationLocalState(application)?.submissions;
|
|
67
|
+
if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
|
|
68
|
+
|
|
69
|
+
const response = await apiClient.fetch(`/flow/job/${application.job.id}`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: JSON.stringify(submissionsToPayload(submissions)),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
match(response)
|
|
75
|
+
.with({ message: 'Success' }, () => {
|
|
76
|
+
chatService.send({
|
|
77
|
+
message: {
|
|
78
|
+
type: 'system',
|
|
79
|
+
text: 'Application submitted',
|
|
80
|
+
variant: 'success',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
})
|
|
84
|
+
.otherwise(response => {
|
|
85
|
+
logger?.error(response);
|
|
86
|
+
chatService.send({
|
|
87
|
+
message: {
|
|
88
|
+
type: 'system',
|
|
89
|
+
text: 'Error submitting application',
|
|
90
|
+
variant: 'error',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
})
|
|
95
|
+
.otherwise(() => {
|
|
96
|
+
logger?.error(ERROR_MESSAGES.invalid_end_node, lastNode);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
interpret(applicationLocalState?.currentNodeId);
|
|
102
|
+
|
|
103
|
+
return abort;
|
|
104
|
+
}, [apiClient, application, chatService, logger, scrollToEnd]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
ref={chatRef}
|
|
109
|
+
class="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
|
|
110
|
+
style={{ WebkitOverflowScrolling: 'touch' }}
|
|
111
|
+
>
|
|
112
|
+
<JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplicationLocalState?.messages ?? []} />
|
|
113
|
+
<div class="sticky bottom-0 w-full p-2 border-t border-neutral-5 rounded-b-3xl bg-neutral-4/80 backdrop-blur-md backdrop-saturate-150">
|
|
114
|
+
<ChatInput
|
|
115
|
+
onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
|
|
116
|
+
application={application}
|
|
117
|
+
onSubmit={onSubmitSuccessFn}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { P, match } from 'ts-pattern';
|
|
2
|
+
|
|
3
|
+
import { ChatMessage } from '../chatbot.state';
|
|
4
|
+
import { ChatBubble } from './chat-bubble';
|
|
5
|
+
import { FileThumbnail } from './chat-input/chat-input.file';
|
|
6
|
+
import { TypingIndicator } from './typing-indicator';
|
|
7
|
+
|
|
8
|
+
type JobApplicationMessagesProps = {
|
|
9
|
+
messages: ChatMessage[];
|
|
10
|
+
isBotTyping: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const authorToSide = {
|
|
14
|
+
bot: 'left',
|
|
15
|
+
user: 'right',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
|
|
19
|
+
return (
|
|
20
|
+
<div class="p-2 pt-[calc(var(--header-height)+0.5rem)] flex flex-col gap-2 flex-grow">
|
|
21
|
+
{messages.map((message, i) =>
|
|
22
|
+
match(message)
|
|
23
|
+
.with({ type: 'system' }, message => (
|
|
24
|
+
<p key={i} class="uppercase text-[10px] text-neutral-10 tracking-widest text-center py-2">
|
|
25
|
+
{message.text}
|
|
26
|
+
</p>
|
|
27
|
+
))
|
|
28
|
+
.with({ type: 'text', author: P.union('bot', 'user') }, message => {
|
|
29
|
+
return (
|
|
30
|
+
<ChatBubble key={i} side={authorToSide[message.author]}>
|
|
31
|
+
{message.text}
|
|
32
|
+
</ChatBubble>
|
|
33
|
+
);
|
|
34
|
+
})
|
|
35
|
+
.with({ type: 'image' }, image => (
|
|
36
|
+
<img
|
|
37
|
+
key={i}
|
|
38
|
+
class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
|
|
39
|
+
src={image.url}
|
|
40
|
+
style={{ aspectRatio: image.width / image.height }}
|
|
41
|
+
/>
|
|
42
|
+
))
|
|
43
|
+
.with({ type: 'file' }, file => {
|
|
44
|
+
return (
|
|
45
|
+
<FileThumbnail
|
|
46
|
+
class={file.author === 'bot' ? 'self-start' : 'self-end'}
|
|
47
|
+
file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
})
|
|
51
|
+
.exhaustive(),
|
|
52
|
+
)}
|
|
53
|
+
<aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const LoadingIndicator = () => (
|
|
2
|
+
<svg viewBox="0 0 24 24">
|
|
3
|
+
<style>
|
|
4
|
+
{`#s1{animation:3s linear infinite forwards s1__to}@keyframes s1__to{0%{transform:translate(12px,0)}66.666667%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%{transform:translate(12px,12px)}}#s2{animation:3s linear infinite forwards s2__ts}@keyframes s2__ts{0%{transform:scale(0,0)}70%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}100%{transform:scale(1,1)}}#s3{animation:3s linear infinite forwards s3__to}@keyframes s3__to{0%{transform:translate(12px,12px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%,33.333333%{transform:translate(12px,24px)}}#s4{animation:3s linear infinite forwards s4__ts}@keyframes s4__ts{0%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}100%,30%{transform:scale(0,0)}}#s5{animation:3s linear infinite forwards s5__to}@keyframes s5__to{0%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}33.333333%{transform:translate(12px,12.045742px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%,66.666667%{transform:translate(12px,24px)}}#s6{animation:3s linear infinite forwards s6__ts}@keyframes s6__ts{0%,100%,63.333333%{transform:scale(0,0)}3.333333%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}33.333333%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}}#s7{animation:3s linear infinite forwards s7__to}@keyframes s7__to{0%{transform:translate(12px,0)}33.333333%{transform:translate(12px,0);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}66.666667%{transform:translate(12px,12px);animation-timing-function:cubic-bezier(0.77,0,0.175,1)}100%{transform:translate(12px,24px)}}#s8{animation:3s linear infinite forwards s8__ts}@keyframes s8__ts{0%,100%,96.666667%{transform:scale(0,0)}36.666667%{transform:scale(0,0);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}66.666667%{transform:scale(1,1);animation-timing-function:cubic-bezier(0.86,0,0.07,1)}}`}
|
|
5
|
+
</style>
|
|
6
|
+
<g id="s1" transform="translate(12,0)">
|
|
7
|
+
<g id="s2" transform="scale(0,0)">
|
|
8
|
+
<circle r="6.5" transform="translate(0,0)" fill="hsl(226, 70.0%, 55.5%)" />
|
|
9
|
+
</g>
|
|
10
|
+
</g>
|
|
11
|
+
<g id="s3" transform="translate(12,12)">
|
|
12
|
+
<g id="s4" transform="scale(1,1)">
|
|
13
|
+
<circle r="6.5" transform="translate(0,0)" fill="hsl(226, 70.0%, 55.5%)" />
|
|
14
|
+
</g>
|
|
15
|
+
</g>
|
|
16
|
+
<g id="s5" transform="translate(12,0)">
|
|
17
|
+
<g id="s6" transform="scale(0,0)">
|
|
18
|
+
<path
|
|
19
|
+
d="M6.5,13c3.5899,0,6.5-2.9101,6.5-6.5s-2.9101-6.5-6.5-6.5-6.5,2.91015-6.5,6.5s2.91015,6.5,6.5,6.5Zm0-4C7.88071,9,9,7.88071,9,6.5s-1.11929-2.5-2.5-2.5-2.5,1.11929-2.5,2.5s1.11929,2.5,2.5,2.5Z"
|
|
20
|
+
transform="translate(-6.5,-6.5)"
|
|
21
|
+
clip-rule="evenodd"
|
|
22
|
+
fill="hsl(226, 70.0%, 55.5%)"
|
|
23
|
+
fill-rule="evenodd"
|
|
24
|
+
/>
|
|
25
|
+
</g>
|
|
26
|
+
</g>
|
|
27
|
+
<g id="s7" transform="translate(12,0)">
|
|
28
|
+
<g id="s8" transform="scale(0,0)">
|
|
29
|
+
<path
|
|
30
|
+
d="M0,6c0,3.58984,2.91016,6.5,6.5,6.5s6.5-2.91016,6.5-6.5h-4C9,7.38086,7.88086,8.5,6.5,8.5s-2.5-1.11914-2.5-2.5h-4Z"
|
|
31
|
+
transform="translate(-6.5,-9.25)"
|
|
32
|
+
fill="hsl(226, 70.0%, 55.5%)"
|
|
33
|
+
/>
|
|
34
|
+
</g>
|
|
35
|
+
</g>
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { ComponentProps } from 'preact';
|
|
3
|
+
|
|
4
|
+
export const SendButton = ({ class: className, ...props }: ComponentProps<'button'>) => (
|
|
5
|
+
<button
|
|
6
|
+
class={clsx(
|
|
7
|
+
'p-2 flex-shrink-0 bg-accent-9 active:bg-accent-12 active:text-accent-6 rounded-full text-lowest pointer-coarse:touch-hitbox disabled:opacity-50 disabled:cursor-not-allowed',
|
|
8
|
+
className,
|
|
9
|
+
)}
|
|
10
|
+
{...props}
|
|
11
|
+
>
|
|
12
|
+
<svg
|
|
13
|
+
class="block"
|
|
14
|
+
width="16"
|
|
15
|
+
height="16"
|
|
16
|
+
viewBox="0 0 16 16"
|
|
17
|
+
fill="transparent"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
stroke-linecap="round"
|
|
20
|
+
stroke-width="2"
|
|
21
|
+
>
|
|
22
|
+
<title>Send</title>
|
|
23
|
+
<path d="M3.5 7.5L8 3L12.5 7.5" />
|
|
24
|
+
<path d="M8 4V13" />
|
|
25
|
+
</svg>
|
|
26
|
+
</button>
|
|
27
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type TransitionState = 'entering' | 'entered' | 'exiting' | 'exited';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
export const TypingIndicator = ({ className, ...props }: ComponentProps<'div'>) => {
|
|
5
|
+
return (
|
|
6
|
+
<div class={clsx('flex gap-1 p-4', className)} {...props}>
|
|
7
|
+
{Array.from({ length: 3 }, (_, i) => (
|
|
8
|
+
<div class="h-1.5 w-1.5 rounded-full bg-accent-9 animate-bounce" style={{ animationDelay: `${-i * 200}ms` }} />
|
|
9
|
+
))}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Logger } from '@inploi/sdk';
|
|
2
|
+
import { Ref, useMemo, useState } from 'preact/hooks';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { JobApplication } from '~/chatbot.api';
|
|
5
|
+
import { useLocalState } from '~/chatbot.state';
|
|
6
|
+
import { ChatService } from '~/interpreter/interpreter';
|
|
7
|
+
|
|
8
|
+
import { SubmitSuccessFn } from './chat-input/chat-input';
|
|
9
|
+
|
|
10
|
+
const TYPING_SPEED_MS_PER_CHARACTER = 25;
|
|
11
|
+
|
|
12
|
+
type UseChatServiceParams = {
|
|
13
|
+
chatRef: Ref<HTMLDivElement>;
|
|
14
|
+
application: JobApplication;
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const useChatService = ({ logger, application, chatRef }: UseChatServiceParams) => {
|
|
19
|
+
const [isBotTyping, setIsBotTyping] = useState(false);
|
|
20
|
+
const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
|
|
21
|
+
const scrollToEnd = useMemo(
|
|
22
|
+
() => (options?: Omit<ScrollToOptions, 'top'>) =>
|
|
23
|
+
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, ...options }),
|
|
24
|
+
[chatRef],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const chatService = useMemo(() => {
|
|
28
|
+
const { setInput, updateSubmission, addMessage } = useLocalState.getState();
|
|
29
|
+
const chatService: ChatService = {
|
|
30
|
+
send: async ({ message, signal }) => {
|
|
31
|
+
await match(message)
|
|
32
|
+
/** Delay sending and add typing indicator if bot is sending a message */
|
|
33
|
+
.with({ author: 'bot', type: 'text' }, async message => {
|
|
34
|
+
if (signal?.aborted) {
|
|
35
|
+
logger?.info(`Aborted sending message`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setIsBotTyping(true);
|
|
39
|
+
const typingTime = message.text.length * TYPING_SPEED_MS_PER_CHARACTER;
|
|
40
|
+
await new Promise(resolve => {
|
|
41
|
+
return setTimeout(resolve, typingTime, { signal });
|
|
42
|
+
});
|
|
43
|
+
setIsBotTyping(false);
|
|
44
|
+
})
|
|
45
|
+
.otherwise(async () => void 0);
|
|
46
|
+
|
|
47
|
+
/** The signal could have been aborted while typing */
|
|
48
|
+
if (signal?.aborted) {
|
|
49
|
+
logger?.info(`Aborted sending message`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
addMessage({ application, data: message });
|
|
53
|
+
scrollToEnd({ behavior: 'smooth' });
|
|
54
|
+
},
|
|
55
|
+
input: async input => {
|
|
56
|
+
setInput({ application, data: input });
|
|
57
|
+
|
|
58
|
+
return await new Promise(resolve => {
|
|
59
|
+
const submitFunction: SubmitSuccessFn = submission => {
|
|
60
|
+
setInput({ application, data: null });
|
|
61
|
+
updateSubmission({
|
|
62
|
+
application,
|
|
63
|
+
fieldKey: input.key,
|
|
64
|
+
data: submission,
|
|
65
|
+
});
|
|
66
|
+
resolve(submission as any);
|
|
67
|
+
};
|
|
68
|
+
setOnSubmitSuccessFn(() => submitFunction);
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return chatService;
|
|
74
|
+
}, [application, logger, scrollToEnd]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
chatService,
|
|
78
|
+
isBotTyping,
|
|
79
|
+
onSubmitSuccessFn,
|
|
80
|
+
scrollToEnd,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @ts-expect-error - there aren't any typings for this
|
|
2
|
+
import touchPlugin from 'tailwindcss-touch';
|
|
3
|
+
import plugin from 'tailwindcss/plugin';
|
|
4
|
+
import { Config } from 'tailwindcss/types/config';
|
|
5
|
+
|
|
6
|
+
const config: Config = {
|
|
7
|
+
content: ['./**/*.{tsx,ts}'],
|
|
8
|
+
theme: {
|
|
9
|
+
colors: {
|
|
10
|
+
transparent: 'transparent',
|
|
11
|
+
lowest: 'hsl(var(--i-lowest) / <alpha-value>)',
|
|
12
|
+
neutral: {
|
|
13
|
+
1: 'hsl(var(--i-n-1) / <alpha-value>)',
|
|
14
|
+
2: 'hsl(var(--i-n-2) / <alpha-value>)',
|
|
15
|
+
3: 'hsl(var(--i-n-3) / <alpha-value>)',
|
|
16
|
+
4: 'hsl(var(--i-n-4) / <alpha-value>)',
|
|
17
|
+
5: 'hsl(var(--i-n-5) / <alpha-value>)',
|
|
18
|
+
6: 'hsl(var(--i-n-6) / <alpha-value>)',
|
|
19
|
+
7: 'hsl(var(--i-n-7) / <alpha-value>)',
|
|
20
|
+
8: 'hsl(var(--i-n-8) / <alpha-value>)',
|
|
21
|
+
9: 'hsl(var(--i-n-9) / <alpha-value>)',
|
|
22
|
+
10: 'hsl(var(--i-n-10) / <alpha-value>)',
|
|
23
|
+
11: 'hsl(var(--i-n-11) / <alpha-value>)',
|
|
24
|
+
12: 'hsl(var(--i-n-12) / <alpha-value>)',
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
accent: {
|
|
28
|
+
1: 'hsl(var(--i-a-1) / <alpha-value>)',
|
|
29
|
+
2: 'hsl(var(--i-a-2) / <alpha-value>)',
|
|
30
|
+
3: 'hsl(var(--i-a-3) / <alpha-value>)',
|
|
31
|
+
4: 'hsl(var(--i-a-4) / <alpha-value>)',
|
|
32
|
+
5: 'hsl(var(--i-a-5) / <alpha-value>)',
|
|
33
|
+
6: 'hsl(var(--i-a-6) / <alpha-value>)',
|
|
34
|
+
7: 'hsl(var(--i-a-7) / <alpha-value>)',
|
|
35
|
+
8: 'hsl(var(--i-a-8) / <alpha-value>)',
|
|
36
|
+
9: 'hsl(var(--i-a-9) / <alpha-value>)',
|
|
37
|
+
10: 'hsl(var(--i-a-10) / <alpha-value>)',
|
|
38
|
+
11: 'hsl(var(--i-a-11) / <alpha-value>)',
|
|
39
|
+
12: 'hsl(var(--i-a-12) / <alpha-value>)',
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
error: {
|
|
43
|
+
1: 'hsl(var(--i-e-1) / <alpha-value>)',
|
|
44
|
+
2: 'hsl(var(--i-e-2) / <alpha-value>)',
|
|
45
|
+
3: 'hsl(var(--i-e-3) / <alpha-value>)',
|
|
46
|
+
4: 'hsl(var(--i-e-4) / <alpha-value>)',
|
|
47
|
+
5: 'hsl(var(--i-e-5) / <alpha-value>)',
|
|
48
|
+
6: 'hsl(var(--i-e-6) / <alpha-value>)',
|
|
49
|
+
7: 'hsl(var(--i-e-7) / <alpha-value>)',
|
|
50
|
+
8: 'hsl(var(--i-e-8) / <alpha-value>)',
|
|
51
|
+
9: 'hsl(var(--i-e-9) / <alpha-value>)',
|
|
52
|
+
10: 'hsl(var(--i-e-10) / <alpha-value>)',
|
|
53
|
+
11: 'hsl(var(--i-e-11) / <alpha-value>)',
|
|
54
|
+
12: 'hsl(var(--i-e-12) / <alpha-value>)',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
extend: {
|
|
59
|
+
boxShadow: {
|
|
60
|
+
'surface-sm': '0px 3px 3px 0px hsl(var(--i-a-12) / 0.03), 0px 4px 4px 0px hsl(var(--i-a-12) / 0.02)',
|
|
61
|
+
'surface-md':
|
|
62
|
+
'0px 3px 3px 0px hsl(var(--i-a-12) / 0.03), 0px 6px 4px 0px hsl(var(--i-a-12) / 0.02), 0px 6px 4px 0px hsl(var(--i-a-12) / 0.01)',
|
|
63
|
+
'surface-lg':
|
|
64
|
+
'0px 3px 3px 0px hsl(var(--i-a-12) / 0.03), 0px 6px 4px 0px hsl(var(--i-a-12) / 0.02), 0px 11px 4px 0px hsl(var(--i-a-12) / 0.01), 0px 32px 24px -12px hsl(var(--i-a-12) / 0.06)',
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
keyframes: {
|
|
68
|
+
overlayShow: {
|
|
69
|
+
from: { opacity: '0' },
|
|
70
|
+
to: { opacity: '1' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
transitionTimingFunction: {
|
|
74
|
+
'expo-out': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
|
75
|
+
},
|
|
76
|
+
animation: {
|
|
77
|
+
overlayShow: 'overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
corePlugins: {
|
|
82
|
+
preflight: false,
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
plugins: [
|
|
86
|
+
touchPlugin(),
|
|
87
|
+
plugin(({ addUtilities }) => {
|
|
88
|
+
addUtilities({
|
|
89
|
+
'.gutter-stable': {
|
|
90
|
+
'scrollbar-gutter': 'stable',
|
|
91
|
+
},
|
|
92
|
+
'.hide-scrollbars': {
|
|
93
|
+
'&::-webkit-scrollbar': {
|
|
94
|
+
display: 'none',
|
|
95
|
+
},
|
|
96
|
+
scrollbarWidth: 'none',
|
|
97
|
+
'-ms-overflow-style': 'none',
|
|
98
|
+
},
|
|
99
|
+
'.touch-hitbox': {
|
|
100
|
+
'&::before': {
|
|
101
|
+
content: "''",
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
display: 'block',
|
|
104
|
+
top: '50%',
|
|
105
|
+
left: '50%',
|
|
106
|
+
transform: 'translate(-50%, -50%)',
|
|
107
|
+
width: '100%',
|
|
108
|
+
height: '100%',
|
|
109
|
+
minHeight: '44px',
|
|
110
|
+
minWidth: '44px',
|
|
111
|
+
zIndex: '9999',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default config;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"jsxImportSource": "preact",
|
|
17
|
+
|
|
18
|
+
/* Linting */
|
|
19
|
+
"strict": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"types": ["bun-types"],
|
|
23
|
+
|
|
24
|
+
"baseUrl": ".",
|
|
25
|
+
"paths": {
|
|
26
|
+
"react": ["./node_modules/preact/compat/"],
|
|
27
|
+
"react-dom": ["./node_modules/preact/compat/"],
|
|
28
|
+
"~/*": ["./src/*"]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": ["src", ".eslintrc.cjs", "tailwind.config.ts", "vite.config.ts"],
|
|
32
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
33
|
+
}
|
package/types.d.ts
ADDED
package/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import preact from '@preact/preset-vite';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { visualizer } from 'rollup-plugin-visualizer';
|
|
4
|
+
import { defineConfig } from 'vite';
|
|
5
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
6
|
+
|
|
7
|
+
// https://vitejs.dev/config/
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
build: {
|
|
10
|
+
lib: {
|
|
11
|
+
formats: ['es'],
|
|
12
|
+
entry: resolve(__dirname, 'src/index.cdn.ts'),
|
|
13
|
+
name: 'inploi.chatbotPlugin',
|
|
14
|
+
},
|
|
15
|
+
rollupOptions: {},
|
|
16
|
+
},
|
|
17
|
+
plugins: [preact(), visualizer(), tsconfigPaths()],
|
|
18
|
+
});
|