@inploi/plugin-chatbot 1.0.7 → 2.1.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 +0 -1
- package/.env.example +0 -1
- package/.env.test +2 -0
- package/CHANGELOG.md +17 -0
- package/index.html +2 -1
- package/package.json +21 -8
- package/playwright.config.ts +82 -0
- package/public/mockServiceWorker.js +4 -9
- package/src/chatbot.api.ts +14 -14
- package/src/chatbot.constants.ts +3 -0
- package/src/chatbot.css +8 -15
- package/src/chatbot.dom.ts +10 -5
- package/src/chatbot.idb.ts +17 -0
- package/src/chatbot.state.ts +78 -144
- package/src/chatbot.ts +25 -35
- package/src/chatbot.utils.ts +27 -9
- package/src/index.dev.ts +7 -6
- package/src/interpreter/interpreter.ts +28 -20
- package/src/mocks/browser.ts +2 -2
- package/src/mocks/example.flows.ts +56 -18
- package/src/mocks/handlers.ts +37 -8
- package/src/style/palette.test.ts +20 -0
- package/src/style/palette.ts +69 -0
- package/src/ui/chat-bubble.tsx +20 -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 +23 -17
- package/src/ui/chat-input/chat-input.tsx +47 -23
- package/src/ui/chatbot-header.tsx +34 -28
- package/src/ui/chatbot.tsx +83 -42
- package/src/ui/input-error.tsx +25 -31
- package/src/ui/job-application-content.tsx +68 -46
- package/src/ui/job-application-messages.tsx +42 -34
- package/src/ui/send-button.tsx +1 -1
- package/src/ui/typing-indicator.tsx +1 -1
- package/src/ui/useChatService.ts +18 -33
- package/src/ui/useFocus.ts +10 -0
- package/tests/integration.spec.ts +19 -0
- package/tests/test.ts +22 -0
- package/tsconfig.json +1 -1
package/src/chatbot.ts
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
import { createPlugin } from '@inploi/sdk';
|
|
2
2
|
import { h, render } from 'preact';
|
|
3
|
+
import { Chatbot } from '~/ui/chatbot';
|
|
3
4
|
|
|
4
5
|
import { getApplicationData } from './chatbot.api';
|
|
5
|
-
import './chatbot.css';
|
|
6
|
+
import tailwind from './chatbot.css?inline';
|
|
6
7
|
import { ChatbotDomManager, createChatbotDomManager } from './chatbot.dom';
|
|
7
|
-
import {
|
|
8
|
+
import { application } from './chatbot.state';
|
|
9
|
+
import { formatCssVariables, generatePalette } from './style/palette';
|
|
8
10
|
|
|
9
11
|
export const chatbotPlugin = ({
|
|
10
12
|
_internal_domManager: dom = createChatbotDomManager(),
|
|
13
|
+
hue,
|
|
11
14
|
}: {
|
|
12
15
|
geolocationApiKey?: string;
|
|
16
|
+
hue: number;
|
|
13
17
|
_internal_domManager?: ChatbotDomManager;
|
|
14
18
|
}) =>
|
|
15
|
-
createPlugin(({ apiClient, logger }) => {
|
|
16
|
-
|
|
17
|
-
const
|
|
19
|
+
createPlugin(({ apiClient, logger, analytics }) => {
|
|
20
|
+
let prepared = false;
|
|
21
|
+
const renderAndPrepare = () => {
|
|
22
|
+
const chatbotElement = dom.getOrCreateChatbotElement();
|
|
23
|
+
// Add styles generated by tailwind
|
|
24
|
+
dom.addStyle(tailwind, 'inploi-chatbot-style');
|
|
25
|
+
// Add dynamic styles generated by the plugin
|
|
26
|
+
dom.addStyle(formatCssVariables(generatePalette(hue)), 'inploi-chatbot-theme');
|
|
27
|
+
render(h(Chatbot, { apiClient, logger, analytics }), chatbotElement);
|
|
28
|
+
prepared = true;
|
|
29
|
+
};
|
|
18
30
|
|
|
19
31
|
return {
|
|
20
|
-
/** Optionally eagerly
|
|
32
|
+
/** Optionally eagerly renders the interface ahead of application requests. */
|
|
21
33
|
prepare: async () => {
|
|
22
34
|
try {
|
|
23
|
-
|
|
35
|
+
if (prepared) return;
|
|
36
|
+
renderAndPrepare();
|
|
24
37
|
logger.info('Chatbot plugin prepared');
|
|
25
38
|
} catch (error) {
|
|
26
39
|
console.error(error);
|
|
@@ -29,41 +42,18 @@ export const chatbotPlugin = ({
|
|
|
29
42
|
},
|
|
30
43
|
startApplication: async ({ jobId }: { jobId: string }) => {
|
|
31
44
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
dom.renderLoading(chatbotElement);
|
|
36
|
-
|
|
37
|
-
/** We concurrently lazy load the module and data */
|
|
38
|
-
const [dataPromise, chatbotPromise] = await Promise.allSettled([
|
|
39
|
-
getApplicationData({ jobId, apiClient }),
|
|
40
|
-
import('~/ui/chatbot').then(m => m.Chatbot),
|
|
41
|
-
]);
|
|
45
|
+
application.cancel();
|
|
46
|
+
getApplicationData({ jobId, apiClient }).then(application.start);
|
|
42
47
|
|
|
43
|
-
if (
|
|
44
|
-
if (chatbotPromise.status === 'rejected') throw chatbotPromise.reason;
|
|
45
|
-
|
|
46
|
-
const Chatbot = chatbotPromise.value;
|
|
47
|
-
const application = dataPromise.value;
|
|
48
|
-
startApplication(application);
|
|
49
|
-
logger.info(`Starting application for job "${application.job.id}" using flow "${application.flow.id}"`);
|
|
50
|
-
chatbotElement.innerHTML = '';
|
|
51
|
-
|
|
52
|
-
render(h(Chatbot, { apiClient, logger }), chatbotElement);
|
|
48
|
+
if (!prepared) renderAndPrepare();
|
|
53
49
|
} catch (error) {
|
|
54
50
|
console.error(error);
|
|
55
51
|
logger.error('Error starting application', error);
|
|
56
52
|
}
|
|
57
53
|
},
|
|
58
54
|
closeApplication: async () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
cancelCurrentApplication();
|
|
62
|
-
},
|
|
63
|
-
/** Resets the user saved data for every application. */
|
|
64
|
-
resetLocalState: async () => {
|
|
65
|
-
resetLocalState();
|
|
66
|
-
cancelCurrentApplication();
|
|
55
|
+
logger.info('Closing application from an external source');
|
|
56
|
+
application.cancel();
|
|
67
57
|
},
|
|
68
58
|
};
|
|
69
59
|
});
|
package/src/chatbot.utils.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FlowNode } from '@inploi/core/flows';
|
|
2
2
|
|
|
3
|
-
import { ApplicationSubmission, KeyToSubmissionMap } from './chatbot.state';
|
|
3
|
+
import { ApplicationSubmission, KeyToSubmissionMap, StartedJobApplication } from './chatbot.state';
|
|
4
4
|
|
|
5
5
|
export type DistributivePick<T, K extends keyof T> = T extends unknown ? Pick<T, K> : never;
|
|
6
6
|
|
|
@@ -10,14 +10,26 @@ export const getHeadOrThrow = (nodes: FlowNode[]) => {
|
|
|
10
10
|
return head;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export const submissionsToPayload = (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
export const submissionsToPayload = ({
|
|
14
|
+
application,
|
|
15
|
+
submissions,
|
|
16
|
+
}: {
|
|
17
|
+
application: StartedJobApplication;
|
|
18
|
+
submissions: KeyToSubmissionMap;
|
|
19
|
+
}) => {
|
|
20
|
+
const payload = {
|
|
21
|
+
flowId: application.flow.id,
|
|
22
|
+
jobId: application.job.id,
|
|
23
|
+
submissions: Object.entries(submissions).reduce(
|
|
24
|
+
(acc, [key, submission]) => {
|
|
25
|
+
acc[key] = submission.value;
|
|
26
|
+
return acc;
|
|
27
|
+
},
|
|
28
|
+
{} as Record<string, ApplicationSubmission['value']>,
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return payload;
|
|
21
33
|
};
|
|
22
34
|
|
|
23
35
|
export const isSubmissionOfType =
|
|
@@ -36,3 +48,9 @@ export function gzip(string: string) {
|
|
|
36
48
|
writer.close();
|
|
37
49
|
return new Response(cs.readable).text();
|
|
38
50
|
}
|
|
51
|
+
|
|
52
|
+
export class AbortedError extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super('Aborted');
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.dev.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** This file is only used for dev mode. The build output is index.ts */
|
|
2
|
-
import {
|
|
2
|
+
import { initialiseSdk, inploiBrandedLogger } from '@inploi/sdk';
|
|
3
3
|
|
|
4
4
|
import { chatbotPlugin } from './chatbot';
|
|
5
5
|
|
|
@@ -19,12 +19,13 @@ async function enableMocking() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
enableMocking().then(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
baseUrl: import.meta.env.VITE_BASE_URL,
|
|
25
|
-
publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
|
|
26
|
-
}),
|
|
22
|
+
const sdk = initialiseSdk({
|
|
23
|
+
env: 'sandbox',
|
|
27
24
|
logger: inploiBrandedLogger,
|
|
25
|
+
publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
|
|
28
26
|
});
|
|
27
|
+
|
|
28
|
+
// inploi’s hue: 265
|
|
29
|
+
window.chatbot = sdk.registerPlugin(chatbotPlugin({ hue: 372 }));
|
|
29
30
|
window.chatbot.prepare();
|
|
30
31
|
});
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { FlowNode, FlowNodeType, IfBlockNode } from '@inploi/core/flows';
|
|
2
2
|
import { P, match } from 'ts-pattern';
|
|
3
|
-
import { getHeadOrThrow } from '~/chatbot.utils';
|
|
3
|
+
import { AbortedError, getHeadOrThrow } from '~/chatbot.utils';
|
|
4
4
|
import { ChatInput } from '~/ui/chat-input/chat-input';
|
|
5
5
|
|
|
6
6
|
import { ApplicationSubmission, ChatMessage, KeyToSubmissionMap } from '../chatbot.state';
|
|
7
7
|
|
|
8
|
-
export type ChatServiceSendParams = { signal?: AbortSignal; message: ChatMessage };
|
|
8
|
+
export type ChatServiceSendParams = { signal?: AbortSignal; groupId?: string; message: ChatMessage };
|
|
9
9
|
export type ChatService = {
|
|
10
10
|
send: (params: ChatServiceSendParams) => Promise<void>;
|
|
11
|
-
input: <TType extends ChatInput['type']>(
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
input: <TType extends ChatInput['type']>(params: {
|
|
12
|
+
input: Extract<ChatInput, { type: TType }>;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}) => Promise<Extract<ApplicationSubmission, { type: TType }>>;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
type ChatbotInterpreterParams = {
|
|
@@ -33,24 +34,31 @@ export const createFlowInterpreter = ({
|
|
|
33
34
|
onInterpret,
|
|
34
35
|
}: ChatbotInterpreterParams) => {
|
|
35
36
|
const controller = new AbortController();
|
|
37
|
+
|
|
36
38
|
const interpretNode = async (node: FlowNode) => {
|
|
37
39
|
const submissions = getSubmissions();
|
|
38
40
|
onInterpret?.(node);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
41
|
+
try {
|
|
42
|
+
await interpret({
|
|
43
|
+
node,
|
|
44
|
+
submissions,
|
|
45
|
+
chat: {
|
|
46
|
+
sendMessage: async message => chatService.send({ groupId: node.id, message, signal: controller.signal }),
|
|
47
|
+
userInput: async input => chatService.input({ input, signal: controller.signal }),
|
|
48
|
+
},
|
|
49
|
+
next: () => {
|
|
50
|
+
const nextNodeId = getNextNodeId(node, getSubmissions());
|
|
51
|
+
const nextNode = flow.find(node => node.id === nextNodeId);
|
|
52
|
+
if (nextNode) {
|
|
53
|
+
return interpretNode(nextNode);
|
|
54
|
+
} else onFlowEnd?.(node);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// we let aborting the flow be silent
|
|
59
|
+
if (e instanceof AbortedError) return;
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
54
62
|
};
|
|
55
63
|
|
|
56
64
|
return {
|
package/src/mocks/browser.ts
CHANGED
|
@@ -1,44 +1,82 @@
|
|
|
1
1
|
import { FlowNode } from '@inploi/core/flows';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
export const automatedTestFlow: FlowNode[] = [
|
|
4
4
|
{
|
|
5
|
-
id: '
|
|
5
|
+
id: '1',
|
|
6
6
|
type: 'text',
|
|
7
7
|
data: {
|
|
8
|
-
text: '
|
|
8
|
+
text: 'Text node',
|
|
9
9
|
},
|
|
10
10
|
isHead: true,
|
|
11
|
-
nextId: 'name',
|
|
12
11
|
},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const textOnly: FlowNode[] = [
|
|
13
15
|
{
|
|
14
|
-
id: '
|
|
15
|
-
|
|
16
|
+
id: '443c2374-13d1-4b02-b2ed-17f148d2a3da',
|
|
17
|
+
isHead: true,
|
|
18
|
+
nextId: '5df04ee2-b786-4df6-874b-9d1c740363d9',
|
|
19
|
+
type: 'question-boolean',
|
|
16
20
|
data: {
|
|
17
|
-
|
|
21
|
+
key: 'like',
|
|
22
|
+
question: 'do u hate cats',
|
|
23
|
+
trueLabel: 'Yes',
|
|
24
|
+
falseLabel: 'No',
|
|
18
25
|
},
|
|
19
|
-
nextId: 'experience',
|
|
20
26
|
},
|
|
21
27
|
{
|
|
22
|
-
id: '
|
|
23
|
-
|
|
28
|
+
id: '5df04ee2-b786-4df6-874b-9d1c740363d9',
|
|
29
|
+
nextId: 'b6060696-ebb5-4b17-9d88-470394ccd1d3',
|
|
30
|
+
type: 'if-block',
|
|
24
31
|
data: {
|
|
25
|
-
|
|
32
|
+
compareKey: 'like',
|
|
33
|
+
compareValue: 'true',
|
|
34
|
+
compare: 'equals',
|
|
26
35
|
},
|
|
27
|
-
|
|
36
|
+
branchId: 'f94662b2-5229-4602-bc4e-d84666a56a25',
|
|
28
37
|
},
|
|
29
38
|
{
|
|
30
|
-
id: '
|
|
39
|
+
id: '801ccc8c-d5db-4864-9b89-1465c014e20e',
|
|
40
|
+
nextId: '042a8657-132d-4eb7-be4e-c1d300ecc538',
|
|
31
41
|
type: 'text',
|
|
32
42
|
data: {
|
|
33
|
-
text: '
|
|
43
|
+
text: 'nice',
|
|
34
44
|
},
|
|
35
|
-
nextId: 'end',
|
|
36
45
|
},
|
|
37
46
|
{
|
|
38
|
-
id: '
|
|
47
|
+
id: 'fec23e69-ef6c-4ae5-b10a-11104d36158c',
|
|
48
|
+
nextId: '801ccc8c-d5db-4864-9b89-1465c014e20e',
|
|
49
|
+
type: 'question-boolean',
|
|
50
|
+
data: {
|
|
51
|
+
key: 'free-will',
|
|
52
|
+
question: 'do you have free will?',
|
|
53
|
+
trueLabel: 'Yes',
|
|
54
|
+
falseLabel: 'No',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'b6060696-ebb5-4b17-9d88-470394ccd1d3',
|
|
59
|
+
nextId: 'fec23e69-ef6c-4ae5-b10a-11104d36158c',
|
|
39
60
|
type: 'text',
|
|
40
61
|
data: {
|
|
41
|
-
text: '
|
|
62
|
+
text: 'nice youre a decent person',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'f94662b2-5229-4602-bc4e-d84666a56a25',
|
|
67
|
+
type: 'abandon-flow',
|
|
68
|
+
data: {
|
|
69
|
+
text: 'you cant hate cats to work here',
|
|
70
|
+
redirectUrl: '',
|
|
71
|
+
cta: '',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: '042a8657-132d-4eb7-be4e-c1d300ecc538',
|
|
76
|
+
type: 'complete-flow',
|
|
77
|
+
data: {
|
|
78
|
+
text: "we'll hire you",
|
|
79
|
+
submitCta: 'submit',
|
|
42
80
|
},
|
|
43
81
|
},
|
|
44
82
|
];
|
|
@@ -160,7 +198,7 @@ const fromAlex: FlowNode[] = [
|
|
|
160
198
|
value: '30+ hours',
|
|
161
199
|
},
|
|
162
200
|
],
|
|
163
|
-
maxSelected:
|
|
201
|
+
maxSelected: 3,
|
|
164
202
|
minSelected: 1,
|
|
165
203
|
},
|
|
166
204
|
},
|
package/src/mocks/handlers.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { invariant } from '@inploi/core/common';
|
|
2
|
-
import { http } from 'msw';
|
|
2
|
+
import { HttpResponse, http } from 'msw';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
3
4
|
|
|
4
|
-
import { exampleFlows } from './example.flows';
|
|
5
|
+
import { automatedTestFlow, exampleFlows } from './example.flows';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// sandbox URL
|
|
8
|
+
const BASE_URL = 'https://preview.api.inploi.com';
|
|
9
|
+
|
|
10
|
+
export const flowHandler = {
|
|
11
|
+
/** Returns a valid flow. */
|
|
12
|
+
success: http.get(`${BASE_URL}/flow/job/:jobId`, ctx => {
|
|
8
13
|
invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
|
|
9
14
|
const mockApplication = {
|
|
10
15
|
job: {
|
|
@@ -16,13 +21,37 @@ export const handlers = [
|
|
|
16
21
|
},
|
|
17
22
|
flow: {
|
|
18
23
|
id: 1,
|
|
19
|
-
nodes: ctx.params.jobId
|
|
24
|
+
nodes: match(ctx.params.jobId)
|
|
25
|
+
.with('1', () => exampleFlows.fromAlex)
|
|
26
|
+
.with('test', () => automatedTestFlow)
|
|
27
|
+
.otherwise(() => exampleFlows.helloWorld),
|
|
20
28
|
version: 1,
|
|
21
29
|
},
|
|
22
30
|
};
|
|
23
|
-
return new
|
|
31
|
+
return new HttpResponse(JSON.stringify(mockApplication));
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
/** Returns an invalid payload with a 200 code.
|
|
35
|
+
* E.g.: if the backend returns something incompatible with the frontend.
|
|
36
|
+
*/
|
|
37
|
+
invalid_payload: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
|
|
38
|
+
return new HttpResponse(JSON.stringify({ foo: 'bar' }), { status: 200 });
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
/** Returns a laravel-structured error */
|
|
42
|
+
exception: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
|
|
43
|
+
return new HttpResponse(JSON.stringify({ exception: 'server_error', message: 'Something bad happened' }), {
|
|
44
|
+
status: 400,
|
|
45
|
+
});
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const browserHandlers = [
|
|
50
|
+
flowHandler.success,
|
|
51
|
+
http.post(`${BASE_URL}/flow/job/:jobId`, () => {
|
|
52
|
+
return new HttpResponse(JSON.stringify({ message: 'Success' }));
|
|
24
53
|
}),
|
|
25
|
-
http.post(`${
|
|
26
|
-
return new
|
|
54
|
+
http.post(`${BASE_URL}/analytics/log`, () => {
|
|
55
|
+
return new HttpResponse(JSON.stringify({ message: 'Success' }));
|
|
27
56
|
}),
|
|
28
57
|
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { PaletteShade, generatePalette } from './palette';
|
|
4
|
+
|
|
5
|
+
test('returns palette when given valid shade and hue', () => {
|
|
6
|
+
const palette = generatePalette(50);
|
|
7
|
+
|
|
8
|
+
for (const shade in palette) {
|
|
9
|
+
expect(palette[shade as PaletteShade]).toMatch(/\d+\.*\d* \d+\.*\d*% \d+\.*\d*%/);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns different palettes with different hues', () => {
|
|
14
|
+
const palette1 = generatePalette(25);
|
|
15
|
+
const palette2 = generatePalette(26);
|
|
16
|
+
|
|
17
|
+
for (const shade in palette1) {
|
|
18
|
+
expect(palette1[shade as PaletteShade]).not.toEqual(palette2[shade as PaletteShade]);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Mode, converter, differenceEuclidean, formatCss, toGamut } from 'culori';
|
|
2
|
+
import { DiffFn } from 'culori/src/difference';
|
|
3
|
+
|
|
4
|
+
declare module 'culori' {
|
|
5
|
+
export function toGamut(from: Mode, to: Mode, distance: DiffFn, tolerance: number): (color: string) => string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const hsl = converter('hsl');
|
|
9
|
+
const oklch = converter('oklch');
|
|
10
|
+
|
|
11
|
+
export type PaletteShade = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12';
|
|
12
|
+
const colorLevels: Record<PaletteShade, true> = {
|
|
13
|
+
'1': true,
|
|
14
|
+
'2': true,
|
|
15
|
+
'3': true,
|
|
16
|
+
'4': true,
|
|
17
|
+
'5': true,
|
|
18
|
+
'6': true,
|
|
19
|
+
'7': true,
|
|
20
|
+
'8': true,
|
|
21
|
+
'9': true,
|
|
22
|
+
'10': true,
|
|
23
|
+
'11': true,
|
|
24
|
+
'12': true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const COLOR_LEVELS = Object.keys(colorLevels) as PaletteShade[];
|
|
28
|
+
const CHROMA_MULTIPLIER = 1.5;
|
|
29
|
+
|
|
30
|
+
const colorPresets: Record<PaletteShade, { l: number; c: number }> = {
|
|
31
|
+
'1': { l: 97.78, c: 0.0108 },
|
|
32
|
+
'2': { l: 93.56, c: 0.0321 },
|
|
33
|
+
'3': { l: 88.11, c: 0.0609 },
|
|
34
|
+
'4': { l: 82.67, c: 0.0908 },
|
|
35
|
+
'5': { l: 74.22, c: 0.1398 },
|
|
36
|
+
'6': { l: 64.78, c: 0.1472 },
|
|
37
|
+
'7': { l: 57.33, c: 0.1299 },
|
|
38
|
+
'8': { l: 46.89, c: 0.1067 },
|
|
39
|
+
'9': { l: 39.44, c: 0.0898 },
|
|
40
|
+
'10': { l: 32, c: 0.0726 },
|
|
41
|
+
'11': { l: 23.78, c: 0.054 },
|
|
42
|
+
'12': { l: 15.56, c: 0.0353 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getPaletteColor = (hueAngle: number) => (i: PaletteShade) => {
|
|
46
|
+
const { c, l } = colorPresets[i];
|
|
47
|
+
const color = 'oklch(' + l + '% ' + c * CHROMA_MULTIPLIER + ' ' + hueAngle + ')';
|
|
48
|
+
const oklchColor = oklch(toGamut('p3', 'oklch', differenceEuclidean('oklch'), 0)(color));
|
|
49
|
+
|
|
50
|
+
return formatCss(hsl(oklchColor)) as unknown as string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const generatePalette = (hueAngle: number) =>
|
|
54
|
+
Object.fromEntries(COLOR_LEVELS.map(level => [level, getPaletteColor(hueAngle)(level)] as const)) as Record<
|
|
55
|
+
PaletteShade,
|
|
56
|
+
string
|
|
57
|
+
>;
|
|
58
|
+
|
|
59
|
+
// gets the hue saturation and lightness from a `hsl()` string. Includes % signs.
|
|
60
|
+
const extractHsl = (color: string) => {
|
|
61
|
+
return color.replace('hsl(', '').replace(')', '');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const formatCssVariables = (palette: Record<PaletteShade, string>) => {
|
|
65
|
+
const cssVariables = Object.entries(palette)
|
|
66
|
+
.map(([level, color]) => `--i-a-${level}: ${extractHsl(color)};`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
return `#isdk {\n${cssVariables}\n}`;
|
|
69
|
+
};
|
package/src/ui/chat-bubble.tsx
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { VariantProps } from 'class-variance-authority';
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { Variants, m } from 'framer-motion';
|
|
3
4
|
import type { ComponentProps } from 'react';
|
|
4
5
|
|
|
5
6
|
const chatBubbleVariants = cva(
|
|
6
|
-
'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-
|
|
7
|
+
'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-md py-2 px-3 rounded-[18px] min-h-[36px] break-words',
|
|
7
8
|
{
|
|
8
9
|
variants: {
|
|
9
10
|
side: {
|
|
10
|
-
left: '
|
|
11
|
-
right: '
|
|
11
|
+
left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
|
|
12
|
+
right: 'ml-auto bg-accent-7 text-lowest rounded-br-md bubble-right',
|
|
12
13
|
},
|
|
13
14
|
transitionState: {
|
|
14
15
|
entering: 'opacity-0 translate-y-8',
|
|
@@ -26,11 +27,25 @@ const chatBubbleVariants = cva(
|
|
|
26
27
|
|
|
27
28
|
type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
|
|
28
29
|
|
|
30
|
+
const motionVariants: Variants = {
|
|
31
|
+
hidden: { y: '100%', scale: 0.75 },
|
|
32
|
+
shown: { y: 0, scale: 1 },
|
|
33
|
+
};
|
|
34
|
+
|
|
29
35
|
type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
|
|
30
36
|
export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
|
|
31
37
|
return (
|
|
32
|
-
<p
|
|
38
|
+
<m.p
|
|
39
|
+
variants={motionVariants}
|
|
40
|
+
initial="hidden"
|
|
41
|
+
animate="shown"
|
|
42
|
+
transition={{ type: 'spring', damping: 25, stiffness: 500 }}
|
|
43
|
+
data-transition={transitionState}
|
|
44
|
+
style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
|
|
45
|
+
class={chatBubbleVariants({ className, side, transitionState })}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
33
48
|
{children}
|
|
34
|
-
</p>
|
|
49
|
+
</m.p>
|
|
35
50
|
);
|
|
36
51
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { P, match } from 'ts-pattern';
|
|
2
|
-
import {
|
|
2
|
+
import { parse, picklist } from 'valibot';
|
|
3
3
|
|
|
4
|
+
import { useFocusOnMount } from '../useFocus';
|
|
4
5
|
import { ChatInputProps } from './chat-input';
|
|
5
6
|
|
|
6
7
|
export type BooleanChoicePayload = {
|
|
@@ -11,12 +12,15 @@ export type BooleanChoicePayload = {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
const options = ['true', 'false'] as const;
|
|
14
|
-
const AnswerSchema =
|
|
15
|
+
const AnswerSchema = picklist(options);
|
|
15
16
|
const FIELD_NAME = 'answer';
|
|
16
17
|
|
|
17
18
|
export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
|
|
19
|
+
const focusRef = useFocusOnMount();
|
|
20
|
+
|
|
18
21
|
return (
|
|
19
22
|
<form
|
|
23
|
+
noValidate
|
|
20
24
|
class="flex gap-2 items-center"
|
|
21
25
|
onSubmit={e => {
|
|
22
26
|
e.preventDefault();
|
|
@@ -36,19 +40,20 @@ export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boo
|
|
|
36
40
|
.otherwise(() => {
|
|
37
41
|
throw new Error('invalid form');
|
|
38
42
|
});
|
|
39
|
-
const answer =
|
|
43
|
+
const answer = parse(AnswerSchema, value);
|
|
40
44
|
onSubmitSuccess(answer);
|
|
41
45
|
}}
|
|
42
46
|
>
|
|
43
|
-
{options.map(value => {
|
|
47
|
+
{options.map((value, i) => {
|
|
44
48
|
return (
|
|
45
49
|
<button
|
|
50
|
+
ref={i === 0 ? focusRef : null}
|
|
46
51
|
type="submit"
|
|
47
52
|
name={FIELD_NAME}
|
|
48
53
|
value={value}
|
|
49
54
|
class="flex-1 overflow-hidden rounded-2xl block px-2.5 py-2.5 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 focus-visible:ring-offset-2 focus-visible:ring-4 focus-visible:ring-accent-7 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-12 active:outline-accent-9 active:bg-accent-4 active:text-accent-11"
|
|
50
55
|
>
|
|
51
|
-
<p class="truncate text-base">{input.config.labels[value]}</p>
|
|
56
|
+
<p class="truncate text-center text-base">{input.config.labels[value]}</p>
|
|
52
57
|
</button>
|
|
53
58
|
);
|
|
54
59
|
})}
|
|
@@ -4,11 +4,12 @@ import { ComponentProps } from 'preact';
|
|
|
4
4
|
import { useState } from 'preact/hooks';
|
|
5
5
|
import { FieldError } from 'react-hook-form';
|
|
6
6
|
import { P, match } from 'ts-pattern';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { application } from '~/chatbot.state';
|
|
8
|
+
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
9
9
|
|
|
10
10
|
import { InputError } from '../input-error';
|
|
11
11
|
import { SendButton } from '../send-button';
|
|
12
|
+
import { useFocusOnMount } from '../useFocus';
|
|
12
13
|
import { ChatInputProps } from './chat-input';
|
|
13
14
|
|
|
14
15
|
export type FileToUpload = { name: string; data: string; sizeKb: number };
|
|
@@ -81,13 +82,13 @@ const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) =>
|
|
|
81
82
|
);
|
|
82
83
|
1;
|
|
83
84
|
|
|
84
|
-
export const ChatInputFile = ({ input, onSubmitSuccess
|
|
85
|
-
const submission =
|
|
85
|
+
export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
|
|
86
|
+
const submission = application.current$.value.application?.data.submissions[input.key];
|
|
86
87
|
const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
|
|
87
88
|
const [error, setError] = useState<FieldError>();
|
|
88
89
|
const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
|
|
89
90
|
const totalSize = addFileSizesKb(files);
|
|
90
|
-
|
|
91
|
+
const focusRef = useFocusOnMount();
|
|
91
92
|
return (
|
|
92
93
|
<form
|
|
93
94
|
class="flex flex-col gap-1"
|
|
@@ -108,6 +109,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
108
109
|
>
|
|
109
110
|
<div class="flex gap-2 items-center">
|
|
110
111
|
<label
|
|
112
|
+
ref={focusRef}
|
|
111
113
|
for="dropzone-file"
|
|
112
114
|
class="p-4 flex flex-col overflow-hidden items-center justify-center w-full h-48 border border-neutral-8 border-dashed rounded-2xl bg-neutral-2 cursor-pointer"
|
|
113
115
|
>
|
|
@@ -182,7 +184,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
|
|
|
182
184
|
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
183
185
|
const filesToUpload = await Promise.allSettled(
|
|
184
186
|
files.map(async file => {
|
|
185
|
-
const data = await toBase64(file)
|
|
187
|
+
const data = await toBase64(file);
|
|
186
188
|
return {
|
|
187
189
|
name: file.name,
|
|
188
190
|
data: data,
|