@inploi/plugin-chatbot 2.0.0 → 2.1.1

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.
Files changed (50) hide show
  1. package/cdn/index.js +56 -0
  2. package/{public → cdn}/mockServiceWorker.js +4 -9
  3. package/package.json +25 -5
  4. package/.env +0 -3
  5. package/.env.example +0 -3
  6. package/.eslintrc.cjs +0 -10
  7. package/CHANGELOG.md +0 -85
  8. package/bunfig.toml +0 -2
  9. package/happydom.ts +0 -10
  10. package/index.html +0 -28
  11. package/postcss.config.cjs +0 -7
  12. package/src/chatbot.api.ts +0 -46
  13. package/src/chatbot.constants.ts +0 -9
  14. package/src/chatbot.css +0 -107
  15. package/src/chatbot.dom.ts +0 -17
  16. package/src/chatbot.idb.ts +0 -17
  17. package/src/chatbot.state.ts +0 -89
  18. package/src/chatbot.ts +0 -54
  19. package/src/chatbot.utils.ts +0 -50
  20. package/src/index.cdn.ts +0 -12
  21. package/src/index.dev.ts +0 -36
  22. package/src/index.ts +0 -1
  23. package/src/interpreter/interpreter.test.ts +0 -69
  24. package/src/interpreter/interpreter.ts +0 -241
  25. package/src/mocks/browser.ts +0 -5
  26. package/src/mocks/example.flows.ts +0 -763
  27. package/src/mocks/handlers.ts +0 -28
  28. package/src/ui/chat-bubble.tsx +0 -52
  29. package/src/ui/chat-input/chat-input.boolean.tsx +0 -62
  30. package/src/ui/chat-input/chat-input.file.tsx +0 -213
  31. package/src/ui/chat-input/chat-input.multiple-choice.tsx +0 -117
  32. package/src/ui/chat-input/chat-input.text.tsx +0 -111
  33. package/src/ui/chat-input/chat-input.tsx +0 -81
  34. package/src/ui/chatbot-header.tsx +0 -98
  35. package/src/ui/chatbot.tsx +0 -105
  36. package/src/ui/input-error.tsx +0 -33
  37. package/src/ui/job-application-content.tsx +0 -145
  38. package/src/ui/job-application-messages.tsx +0 -64
  39. package/src/ui/loading-indicator.tsx +0 -37
  40. package/src/ui/send-button.tsx +0 -27
  41. package/src/ui/transition.tsx +0 -1
  42. package/src/ui/typing-indicator.tsx +0 -12
  43. package/src/ui/useChatService.ts +0 -75
  44. package/src/ui/useFocus.ts +0 -10
  45. package/src/vite-env.d.ts +0 -1
  46. package/tailwind.config.ts +0 -119
  47. package/tsconfig.json +0 -33
  48. package/tsconfig.node.json +0 -10
  49. package/types.d.ts +0 -2
  50. package/vite.config.ts +0 -18
@@ -1,98 +0,0 @@
1
- import clsx from 'clsx';
2
- import { ComponentProps } from 'preact';
3
-
4
- import { StartedJobApplication, application, cancelCurrentApplication, viewState } from '../chatbot.state';
5
-
6
- const HeaderIconButton = ({ class: className, children, ...props }: ComponentProps<'button'>) => {
7
- return (
8
- <button
9
- class={clsx(
10
- 'p-2 touch-hitbox relative rounded-full text-neutral-11 hover:text-neutral-12 active:text-neutral-12 hover:bg-neutral-12/5 active:bg-neutral-12/10',
11
- className,
12
- )}
13
- {...props}
14
- >
15
- <svg
16
- class="block"
17
- width="16"
18
- height="16"
19
- viewBox="0 0 16 16"
20
- fill="none"
21
- stroke="currentColor"
22
- stroke-width="1.5"
23
- stroke-linecap="round"
24
- xmlns="http://www.w3.org/2000/svg"
25
- >
26
- {children}
27
- </svg>
28
- </button>
29
- );
30
- };
31
-
32
- export const ChatbotHeader = ({ currentApplication }: { currentApplication?: StartedJobApplication }) => {
33
- const view = viewState.value;
34
- const headerText = currentApplication
35
- ? `Applying for “${currentApplication.job.title}” at ${currentApplication.company.name}`
36
- : 'Applying';
37
-
38
- return (
39
- <header class="z-20 flex gap-2 h-[var(--header-height)] rounded-t-2xl left-0 outline outline-1 outline-neutral-5 right-0 mx-auto items-center absolute top-0 bg-neutral-1/90 backdrop-blur-md backdrop-saturate-150">
40
- <p id="chatbot-header" class="sr-only">
41
- {headerText}
42
- </p>
43
- <button
44
- tabIndex={-1}
45
- aria-hidden
46
- onClick={() => {
47
- if (view === 'minimised') {
48
- viewState.value = 'maximised';
49
- } else {
50
- viewState.value = 'minimised';
51
- }
52
- }}
53
- class="flex-grow h-full overflow-hidden py-1 px-4"
54
- >
55
- <p aria-hidden class="font-bold text-sm tracking-tight text-neutral-12 truncate">
56
- {headerText}
57
- </p>
58
- </button>
59
-
60
- <div class="flex-shrink-0 flex items-center gap-3 p-1.5">
61
- {viewState.value === 'minimised' ? (
62
- <>
63
- <HeaderIconButton
64
- key="minmax"
65
- aria-label="Maximise job application"
66
- onClick={() => (viewState.value = 'maximised')}
67
- >
68
- <path d="M12.5 9.5L8 5L3.5 9.5" />
69
- </HeaderIconButton>
70
- <HeaderIconButton key="close" aria-label="Close application" onClick={cancelCurrentApplication}>
71
- <path d="M12.5 6.5L8 11L3.5 6.5" />
72
- </HeaderIconButton>
73
- </>
74
- ) : (
75
- <>
76
- <HeaderIconButton
77
- key="restart"
78
- aria-label="Restart"
79
- onClick={() => {
80
- application.restart();
81
- }}
82
- >
83
- <path d="M12 8.5C12 9.29113 11.7654 10.0645 11.3259 10.7223C10.8864 11.3801 10.2616 11.8928 9.53073 12.1955C8.79983 12.4983 7.99556 12.5775 7.21964 12.4231C6.44371 12.2688 5.73098 11.8878 5.17157 11.3284C4.61216 10.769 4.2312 10.0563 4.07686 9.28036C3.92252 8.50444 4.00173 7.70017 4.30448 6.96927C4.60723 6.23836 5.11992 5.61365 5.77772 5.17412C6.43552 4.7346 7.20887 4.5 8 4.5H9" />
84
- <path d="M8 7L10 4.5L8 2.5" />
85
- </HeaderIconButton>
86
- <HeaderIconButton
87
- key="minmax"
88
- aria-label="Minimise application"
89
- onClick={() => (viewState.value = 'minimised')}
90
- >
91
- <path d="M12.5 6.5L8 11L3.5 6.5" />
92
- </HeaderIconButton>
93
- </>
94
- )}
95
- </div>
96
- </header>
97
- );
98
- };
@@ -1,105 +0,0 @@
1
- import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
- import { FocusScope } from '@radix-ui/react-focus-scope';
3
- import { LazyMotion, domAnimation } from 'framer-motion';
4
- import { AnimatePresence, Variants, m } from 'framer-motion';
5
- import { Suspense, lazy, useRef } from 'preact/compat';
6
- import { HEADER_HEIGHT } from '~/chatbot.constants';
7
-
8
- import { application, viewState } from '../chatbot.state';
9
- import { ChatbotHeader } from './chatbot-header';
10
-
11
- const JobApplicationContent = lazy(() =>
12
- import('./job-application-content').then(module => module.JobApplicationContent),
13
- );
14
-
15
- const MotionProvider = ({ children }: { children: JSX.Element }) => {
16
- return <LazyMotion features={domAnimation}>{children}</LazyMotion>;
17
- };
18
-
19
- const chatbotVariants: Variants = {
20
- closed: { y: 'calc(100% + 1rem)', height: HEADER_HEIGHT },
21
- maximised: { y: 0, height: '75vh' },
22
- minimised: { y: 0, height: HEADER_HEIGHT },
23
- };
24
-
25
- type ChatbotProps = {
26
- apiClient: ApiClient;
27
- logger: Logger;
28
- analytics: AnalyticsService;
29
- };
30
- export const Chatbot = ({ logger, apiClient, analytics }: ChatbotProps) => {
31
- const currentApplication = application.current$.value;
32
- const isApplying = currentApplication !== null;
33
- const view = viewState.value;
34
-
35
- const drawerRef = useRef<HTMLDivElement>(null);
36
- // useEffect(() => {
37
- // function onVisualViewportChange() {
38
- // if (!drawerRef.current) return;
39
- // const visualViewportHeight = window.visualViewport?.height ?? 0;
40
- // const keyboardHeight = window.innerHeight - visualViewportHeight;
41
-
42
- // // Difference between window height and height excluding the keyboard
43
- // const diffFromInitial = window.innerHeight - visualViewportHeight;
44
-
45
- // const drawerHeight = drawerRef.current.getBoundingClientRect().height;
46
- // const offset = keyboardHeight - drawerHeight;
47
-
48
- // drawerRef.current.style.height = `${visualViewportHeight - offset}px`;
49
- // drawerRef.current.style.bottom = `${Math.max(diffFromInitial, 0)}px`;
50
- // }
51
-
52
- // window.visualViewport?.addEventListener('resize', onVisualViewportChange);
53
-
54
- // return () => window.visualViewport?.removeEventListener('resize', onVisualViewportChange);
55
- // }, []);
56
-
57
- return (
58
- <MotionProvider>
59
- <AnimatePresence>
60
- {isApplying && viewState.value === 'maximised' ? (
61
- <m.div
62
- key="bg"
63
- initial={{ opacity: 0 }}
64
- animate={{ opacity: 1 }}
65
- exit={{ opacity: 0 }}
66
- class="bg-neutral-12/60 fixed inset-0"
67
- />
68
- ) : null}
69
-
70
- {isApplying ? (
71
- <m.div
72
- key="content"
73
- ref={drawerRef}
74
- aria-modal="true"
75
- role="dialog"
76
- aria-labelledby="chatbot-header"
77
- variants={chatbotVariants}
78
- initial="closed"
79
- animate={view}
80
- exit="closed"
81
- style={{ '--header-height': `${HEADER_HEIGHT}px` }}
82
- class="isolate fixed left-2 right-2 mx-auto max-w-[450px] bottom-2 max-h-full focus:outline-none"
83
- >
84
- <FocusScope
85
- class="outline outline-1 h-full outline-neutral-5 relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden"
86
- loop
87
- trapped={false}
88
- >
89
- <ChatbotHeader currentApplication={currentApplication} />
90
-
91
- <Suspense fallback={<div>loading…</div>}>
92
- <JobApplicationContent
93
- analytics={analytics}
94
- currentApplication={currentApplication}
95
- apiClient={apiClient}
96
- logger={logger}
97
- />
98
- </Suspense>
99
- </FocusScope>
100
- </m.div>
101
- ) : null}
102
- </AnimatePresence>
103
- </MotionProvider>
104
- );
105
- };
@@ -1,33 +0,0 @@
1
- import { AnimatePresence, m } from 'framer-motion';
2
- import type { FieldError } from 'react-hook-form';
3
-
4
- export const InputError = ({ error }: { error?: FieldError }) => {
5
- return (
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"
22
- >
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>
27
-
28
- <p class="text-sm truncate pr-1">{error.message}</p>
29
- </m.div>
30
- )}
31
- </AnimatePresence>
32
- );
33
- };
@@ -1,145 +0,0 @@
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';
5
- import { match } from 'ts-pattern';
6
- import { StartedJobApplication, application, inputHeight, viewState } from '~/chatbot.state';
7
- import { submissionsToPayload } from '~/chatbot.utils';
8
-
9
- import { ERROR_MESSAGES } from '../chatbot.constants';
10
- import { createFlowInterpreter } from '../interpreter/interpreter';
11
- import { ChatInput } from './chat-input/chat-input';
12
- import { JobApplicationMessages } from './job-application-messages';
13
- import { useChatService } from './useChatService';
14
-
15
- type JobApplicationContentProps = {
16
- apiClient: ApiClient;
17
- logger: Logger;
18
- currentApplication: StartedJobApplication;
19
- analytics: AnalyticsService;
20
- };
21
-
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]);
43
-
44
- useLayoutEffect(() => {
45
- scrollToEnd({ behavior: 'instant' });
46
- const currentApplication = application.current$.peek();
47
- if (!currentApplication || currentApplication.data.isFinished) return;
48
-
49
- const { interpret, abort } = createFlowInterpreter({
50
- flow: currentApplication.flow.nodes,
51
- chatService,
52
- getSubmissions: () => application.current$.peek()?.data.submissions,
53
- beforeStart: async () => {
54
- application.setInput(undefined);
55
-
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 {
66
- await chatService.send({
67
- message: {
68
- type: 'system',
69
- text: 'Restored in progress application',
70
- variant: 'info',
71
- },
72
- });
73
- }
74
- },
75
- onInterpret: node => {
76
- application.setCurrentNodeId(node.id);
77
- },
78
- onFlowEnd: async lastNode => {
79
- application.markAsFinished();
80
- return match(lastNode)
81
- .with({ type: 'abandon-flow' }, () => {
82
- chatService.send({
83
- message: {
84
- type: 'system',
85
- text: 'Application ended',
86
- variant: 'success',
87
- },
88
- });
89
- })
90
- .with({ type: 'complete-flow' }, async () => {
91
- const submissions = application.current$.peek()?.data.submissions;
92
- if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
93
-
94
- const response = await apiClient.fetch(`/flow/job/${currentApplication.job.id}`, {
95
- method: 'POST',
96
- body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
97
- });
98
-
99
- match(response)
100
- .with({ message: 'Success' }, () => {
101
- chatService.send({
102
- message: {
103
- type: 'system',
104
- text: 'Application submitted',
105
- variant: 'success',
106
- },
107
- });
108
- })
109
- .otherwise(response => {
110
- logger.error(response);
111
- chatService.send({
112
- message: {
113
- type: 'system',
114
- text: 'Error submitting application',
115
- variant: 'error',
116
- },
117
- });
118
- });
119
- })
120
- .otherwise(() => {
121
- logger.error(ERROR_MESSAGES.invalid_end_node, lastNode);
122
- });
123
- },
124
- });
125
-
126
- interpret(currentApplication.data.currentNodeId);
127
-
128
- return abort;
129
- }, [analytics, apiClient, chatService, logger, scrollToEnd]);
130
-
131
- return (
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>
141
- </div>
142
- <ChatInput onInputChange={() => scrollToEnd({ behavior: 'smooth' })} onSubmit={onSubmitSuccessFn} />
143
- </>
144
- );
145
- };
@@ -1,64 +0,0 @@
1
- import { AnimatePresence } from 'framer-motion';
2
- import { P, match } from 'ts-pattern';
3
-
4
- import { ChatMessage } from '../chatbot.state';
5
- import { ChatBubble } from './chat-bubble';
6
- import { FileThumbnail } from './chat-input/chat-input.file';
7
- // import { AnimatePresence } from './motion/animate-presence';
8
- import { TypingIndicator } from './typing-indicator';
9
-
10
- type JobApplicationMessagesProps = {
11
- messages: ChatMessage[];
12
- isBotTyping: boolean;
13
- };
14
-
15
- const authorToSide = {
16
- bot: 'left',
17
- user: 'right',
18
- } as const;
19
-
20
- export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
21
- return (
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>
61
- <aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
62
- </ol>
63
- );
64
- };
@@ -1,37 +0,0 @@
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
- );
@@ -1,27 +0,0 @@
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
- );
@@ -1 +0,0 @@
1
- export type TransitionState = 'entering' | 'entered' | 'exiting' | 'exited';
@@ -1,12 +0,0 @@
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
- };
@@ -1,75 +0,0 @@
1
- import { Logger } from '@inploi/sdk';
2
- import { useMemo, useRef, useState } from 'preact/hooks';
3
- import { match } from 'ts-pattern';
4
- import { application } from '~/chatbot.state';
5
- import { ChatService } from '~/interpreter/interpreter';
6
-
7
- import { SubmitSuccessFn } from './chat-input/chat-input';
8
-
9
- const TYPING_SPEED_MS_PER_CHARACTER = 25;
10
-
11
- type UseChatServiceParams = {
12
- logger?: Logger;
13
- };
14
-
15
- export const useChatService = ({ logger }: UseChatServiceParams) => {
16
- const chatRef = useRef<HTMLDivElement>(null);
17
- const [isBotTyping, setIsBotTyping] = useState(false);
18
- const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
19
- const scrollToEnd = useMemo(
20
- () => (options?: Omit<ScrollToOptions, 'top'>) =>
21
- chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, ...options }),
22
- [chatRef],
23
- );
24
-
25
- const chatService = useMemo(() => {
26
- const chatService: ChatService = {
27
- send: async ({ message, signal }) => {
28
- await match(message)
29
- /** Delay sending and add typing indicator if bot is sending a message */
30
- .with({ author: 'bot', type: 'text' }, async message => {
31
- if (signal?.aborted) {
32
- logger?.info(`Aborted sending message`);
33
- return;
34
- }
35
- setIsBotTyping(true);
36
- const typingTime = message.text.length * TYPING_SPEED_MS_PER_CHARACTER;
37
- await new Promise(resolve => {
38
- return setTimeout(resolve, typingTime, { signal });
39
- });
40
- setIsBotTyping(false);
41
- })
42
- .otherwise(async () => void 0);
43
-
44
- /** The signal could have been aborted while typing */
45
- if (signal?.aborted) {
46
- logger?.info(`Aborted sending message`);
47
- return;
48
- }
49
- application.addMessage(message);
50
- },
51
- input: async input => {
52
- application.setInput(input);
53
-
54
- return await new Promise(resolve => {
55
- const submitFunction: SubmitSuccessFn = submission => {
56
- application.setInput(undefined);
57
- application.setSubmission(input.key, submission);
58
- resolve(submission as any);
59
- };
60
- setOnSubmitSuccessFn(() => submitFunction);
61
- });
62
- },
63
- };
64
-
65
- return chatService;
66
- }, [logger]);
67
-
68
- return {
69
- chatRef,
70
- chatService,
71
- isBotTyping,
72
- onSubmitSuccessFn,
73
- scrollToEnd,
74
- };
75
- };
@@ -1,10 +0,0 @@
1
- import { useEffect, useRef } from 'preact/hooks';
2
-
3
- export const useFocusOnMount = () => {
4
- const focusRef = useRef<any>(null);
5
- useEffect(() => {
6
- focusRef.current?.focus();
7
- }, []);
8
-
9
- return focusRef;
10
- };
package/src/vite-env.d.ts DELETED
@@ -1 +0,0 @@
1
- /// <reference types="vite/client" />