@asgard-js/react 0.0.29 → 0.0.32-canary.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.
Files changed (138) hide show
  1. package/.babelrc +12 -0
  2. package/README.md +13 -13
  3. package/dist/components/chatbot/chatbot-header/chatbot-header.d.ts +1 -1
  4. package/dist/components/chatbot/chatbot-header/chatbot-header.d.ts.map +1 -1
  5. package/dist/components/chatbot/chatbot.d.ts +2 -1
  6. package/dist/components/chatbot/chatbot.d.ts.map +1 -1
  7. package/dist/components/templates/button-template/card.d.ts.map +1 -1
  8. package/dist/components/templates/text-template/{use-markdown-renderer.d.ts → use-react-markdown-renderer.d.ts} +3 -1
  9. package/dist/components/templates/text-template/use-react-markdown-renderer.d.ts.map +1 -0
  10. package/dist/context/asgard-template-context.d.ts +2 -0
  11. package/dist/context/asgard-template-context.d.ts.map +1 -1
  12. package/dist/hooks/index.d.ts +1 -1
  13. package/dist/hooks/index.d.ts.map +1 -1
  14. package/dist/hooks/use-channel.d.ts +1 -1
  15. package/dist/hooks/use-channel.d.ts.map +1 -1
  16. package/dist/index.js +74570 -54207
  17. package/dist/style.css +1 -1
  18. package/dist/utils/uri-validation.d.ts +20 -0
  19. package/dist/utils/uri-validation.d.ts.map +1 -0
  20. package/eslint.config.cjs +12 -0
  21. package/package.json +15 -9
  22. package/src/components/chatbot/chatbot-body/chatbot-body.module.scss +13 -0
  23. package/src/components/chatbot/chatbot-body/chatbot-body.tsx +45 -0
  24. package/src/components/chatbot/chatbot-body/conversation-message-renderer.tsx +55 -0
  25. package/src/components/chatbot/chatbot-body/index.ts +1 -0
  26. package/src/components/chatbot/chatbot-container/chatbot-container.module.scss +41 -0
  27. package/src/components/chatbot/chatbot-container/chatbot-container.tsx +49 -0
  28. package/src/components/chatbot/chatbot-container/chatbot-full-screen-container.tsx +54 -0
  29. package/src/components/chatbot/chatbot-footer/chatbot-footer.module.scss +67 -0
  30. package/src/components/chatbot/chatbot-footer/chatbot-footer.tsx +140 -0
  31. package/src/components/chatbot/chatbot-footer/index.ts +1 -0
  32. package/src/components/chatbot/chatbot-footer/speech-input-button.tsx +132 -0
  33. package/src/components/chatbot/chatbot-header/chatbot-header.module.scss +48 -0
  34. package/src/components/chatbot/chatbot-header/chatbot-header.tsx +98 -0
  35. package/src/components/chatbot/chatbot-header/index.ts +1 -0
  36. package/src/components/chatbot/chatbot.spec.tsx +8 -0
  37. package/src/components/chatbot/chatbot.tsx +118 -0
  38. package/src/components/chatbot/profile-icon.tsx +26 -0
  39. package/src/components/index.ts +2 -0
  40. package/src/components/templates/avatar/avatar.module.scss +6 -0
  41. package/src/components/templates/avatar/avatar.tsx +28 -0
  42. package/src/components/templates/avatar/index.ts +1 -0
  43. package/src/components/templates/button-template/button-template.module.scss +0 -0
  44. package/src/components/templates/button-template/button-template.tsx +45 -0
  45. package/src/components/templates/button-template/card.module.scss +58 -0
  46. package/src/components/templates/button-template/card.spec.tsx +213 -0
  47. package/src/components/templates/button-template/card.tsx +123 -0
  48. package/src/components/templates/button-template/index.ts +1 -0
  49. package/src/components/templates/carousel-template/carousel-template.module.scss +15 -0
  50. package/src/components/templates/carousel-template/carousel-template.tsx +48 -0
  51. package/src/components/templates/carousel-template/index.ts +1 -0
  52. package/src/components/templates/chart-template/chart-template.module.scss +52 -0
  53. package/src/components/templates/chart-template/chart-template.tsx +76 -0
  54. package/src/components/templates/chart-template/index.ts +1 -0
  55. package/src/components/templates/hint-template/hint-template.module.scss +39 -0
  56. package/src/components/templates/hint-template/hint-template.tsx +71 -0
  57. package/src/components/templates/hint-template/index.ts +1 -0
  58. package/src/components/templates/image-template/image-template.module.scss +67 -0
  59. package/src/components/templates/image-template/image-template.tsx +58 -0
  60. package/src/components/templates/image-template/index.ts +1 -0
  61. package/src/components/templates/index.ts +10 -0
  62. package/src/components/templates/quick-replies/index.ts +1 -0
  63. package/src/components/templates/quick-replies/quick-replies.module.scss +16 -0
  64. package/src/components/templates/quick-replies/quick-replies.tsx +44 -0
  65. package/src/components/templates/template-box/index.ts +2 -0
  66. package/src/components/templates/template-box/template-box-content.module.scss +13 -0
  67. package/src/components/templates/template-box/template-box-content.tsx +30 -0
  68. package/src/components/templates/template-box/template-box.module.scss +19 -0
  69. package/src/components/templates/template-box/template-box.tsx +48 -0
  70. package/src/components/templates/text-template/bot-typing-box.tsx +81 -0
  71. package/src/components/templates/text-template/bot-typing-placeholder.tsx +28 -0
  72. package/src/components/templates/text-template/index.ts +3 -0
  73. package/src/components/templates/text-template/text-template.module.scss +131 -0
  74. package/src/components/templates/text-template/text-template.tsx +90 -0
  75. package/src/components/templates/text-template/use-react-markdown-renderer.spec.tsx +758 -0
  76. package/src/components/templates/text-template/use-react-markdown-renderer.tsx +264 -0
  77. package/src/components/templates/time/index.ts +1 -0
  78. package/src/components/templates/time/time.module.scss +6 -0
  79. package/src/components/templates/time/time.tsx +34 -0
  80. package/src/context/asgard-app-initialization-context.tsx +154 -0
  81. package/src/context/asgard-service-context.tsx +139 -0
  82. package/src/context/asgard-template-context.tsx +83 -0
  83. package/src/context/asgard-theme-context.tsx +401 -0
  84. package/src/context/index.ts +4 -0
  85. package/src/hooks/index.ts +11 -0
  86. package/src/hooks/use-asgard-service-client.ts +68 -0
  87. package/src/hooks/use-channel.ts +154 -0
  88. package/src/hooks/use-debounce.ts +18 -0
  89. package/src/hooks/use-deep-compare-memo.ts +19 -0
  90. package/src/hooks/use-is-on-screen-keyboard-open.ts +43 -0
  91. package/src/hooks/use-on-screen-keyboard-scroll-fix.ts +15 -0
  92. package/src/hooks/use-prevent-over-scrolling.ts +77 -0
  93. package/src/hooks/use-resize-observer.tsx +27 -0
  94. package/src/hooks/use-update-vh.ts +30 -0
  95. package/src/hooks/use-viewport-size.ts +51 -0
  96. package/src/icons/add_a_photo.svg +3 -0
  97. package/src/icons/bot.svg +14 -0
  98. package/src/icons/close.svg +3 -0
  99. package/src/icons/distance.svg +3 -0
  100. package/src/icons/mic.svg +3 -0
  101. package/src/icons/photo_library.svg +3 -0
  102. package/src/icons/profile.svg +28 -0
  103. package/src/icons/refresh.svg +3 -0
  104. package/src/icons/send.svg +3 -0
  105. package/src/icons/stop.svg +22 -0
  106. package/src/icons/volume_up.svg +3 -0
  107. package/src/index.ts +4 -0
  108. package/src/models/bot-provider.ts +108 -0
  109. package/src/styles/_index.scss +1 -0
  110. package/src/styles/_styles.scss +11 -0
  111. package/src/styles/colors/_colors.scss +10 -0
  112. package/src/styles/colors/_index.scss +1 -0
  113. package/src/styles/colors/_variables.scss +72 -0
  114. package/src/styles/palette/_index.scss +1 -0
  115. package/src/styles/palette/_palette.scss +42 -0
  116. package/src/styles/palette/_variables.scss +40 -0
  117. package/src/styles/radius/_index.scss +1 -0
  118. package/src/styles/radius/_radius.scss +8 -0
  119. package/src/styles/radius/_variables.scss +12 -0
  120. package/src/styles/spacing/_index.scss +1 -0
  121. package/src/styles/spacing/_spacing.scss +8 -0
  122. package/src/styles/spacing/_variables.scss +13 -0
  123. package/src/styles/utils/_index.scss +1 -0
  124. package/src/styles/utils/_map.scss +22 -0
  125. package/src/test-setup.ts +1 -0
  126. package/src/utils/deep-merge.ts +23 -0
  127. package/src/utils/extractors.ts +20 -0
  128. package/src/utils/format-time.ts +8 -0
  129. package/src/utils/index.ts +1 -0
  130. package/src/utils/is.ts +72 -0
  131. package/src/utils/selectors.ts +7 -0
  132. package/src/utils/uri-validation.spec.ts +208 -0
  133. package/src/utils/uri-validation.ts +103 -0
  134. package/tsconfig.json +16 -0
  135. package/tsconfig.lib.json +62 -0
  136. package/tsconfig.spec.json +36 -0
  137. package/vite.config.ts +63 -0
  138. package/dist/components/templates/text-template/use-markdown-renderer.d.ts.map +0 -1
@@ -0,0 +1,20 @@
1
+ /**
2
+ * URI validation utilities for preventing XSS attacks via malicious URIs
3
+ */
4
+ /**
5
+ * Validates if a URI is safe to open with window.open()
6
+ *
7
+ * @param uri - The URI to validate
8
+ * @returns true if the URI is safe to open, false otherwise
9
+ */
10
+ export declare function isValidUri(uri: string | null | undefined): boolean;
11
+ /**
12
+ * Safely opens a URI after validation
13
+ *
14
+ * @param uri - The URI to open
15
+ * @param target - The window target (same as window.open target parameter)
16
+ * @param features - Window features (same as window.open features parameter)
17
+ * @returns The opened window reference or null if URI was blocked
18
+ */
19
+ export declare function safeWindowOpen(uri: string | null | undefined, target?: string, features?: string): Window | null;
20
+ //# sourceMappingURL=uri-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uri-validation.d.ts","sourceRoot":"","sources":["../../src/utils/uri-validation.ts"],"names":[],"mappings":"AAAA;;GAEG;AAuBH;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAgDlE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9B,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CASf"}
@@ -0,0 +1,12 @@
1
+ const nx = require('@nx/eslint-plugin');
2
+ const baseConfig = require('../../eslint.config.cjs');
3
+
4
+ module.exports = [
5
+ ...baseConfig,
6
+ ...nx.configs['flat/react'],
7
+ {
8
+ files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
9
+ // Override or add rules here
10
+ rules: {},
11
+ },
12
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asgard-js/react",
3
- "version": "0.0.29",
3
+ "version": "0.0.32-canary.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -18,7 +18,8 @@
18
18
  "./style": "./dist/style.css",
19
19
  ".": {
20
20
  "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.js"
22
23
  }
23
24
  },
24
25
  "types": "./dist/index.d.ts",
@@ -29,9 +30,16 @@
29
30
  "clsx": "^2.1.1",
30
31
  "dompurify": "^3.2.5",
31
32
  "highlight.js": "^11.11.1",
32
- "marked": "^15.0.8",
33
- "marked-highlight": "^2.2.1",
34
- "react-vega": "^7.6.0"
33
+ "katex": "^0.16.22",
34
+ "react-markdown": "^10.1.0",
35
+ "react-vega": "^7.6.0",
36
+ "rehype-highlight": "^7.0.2",
37
+ "rehype-katex": "^7.0.1",
38
+ "remark-gfm": "^4.0.1",
39
+ "remark-math": "^6.0.0",
40
+ "vega": "^6.1.2",
41
+ "vega-embed": "^6.0.0",
42
+ "vega-lite": "^6.1.0"
35
43
  },
36
44
  "devDependencies": {
37
45
  "@testing-library/jest-dom": "^6.4.2",
@@ -46,11 +54,9 @@
46
54
  "vitest": "^1.6.0"
47
55
  },
48
56
  "peerDependencies": {
49
- "@asgard-js/core": "^0.0.29",
57
+ "@asgard-js/core": "^0.0.32-canary.0",
50
58
  "react": "^18.0.0",
51
- "react-dom": "^18.0.0",
52
- "vega": "^6.1.2",
53
- "vega-lite": "^6.1.0"
59
+ "react-dom": "^18.0.0"
54
60
  },
55
61
  "scripts": {
56
62
  "test": "vitest run",
@@ -0,0 +1,13 @@
1
+ .chatbot_body {
2
+ overflow-x: hidden;
3
+ overflow-y: scroll;
4
+
5
+ .chatbot_body__content {
6
+ margin: 0 auto;
7
+ display: flex;
8
+ flex-direction: column;
9
+ padding: 16px;
10
+ gap: 16px;
11
+ max-width: 1200px;
12
+ }
13
+ }
@@ -0,0 +1,45 @@
1
+ import { ReactNode, useEffect, useMemo } from 'react';
2
+ import { useAsgardContext } from 'src/context/asgard-service-context';
3
+ import styles from './chatbot-body.module.scss';
4
+ import { ConversationMessageRenderer } from './conversation-message-renderer';
5
+ import { BotTypingPlaceholder } from '../../templates';
6
+ import { useAsgardThemeContext } from 'src/context/asgard-theme-context';
7
+ import clsx from 'clsx';
8
+
9
+ export function ChatbotBody(): ReactNode {
10
+ const { chatbot } = useAsgardThemeContext();
11
+
12
+ const { messages, messageBoxBottomRef, botTypingPlaceholder } =
13
+ useAsgardContext();
14
+
15
+ useEffect(() => {
16
+ messageBoxBottomRef.current?.scrollIntoView({ behavior: 'smooth' });
17
+ }, [messages, messageBoxBottomRef]);
18
+
19
+ const contentStyles = useMemo(
20
+ () => ({
21
+ maxWidth: chatbot?.contentMaxWidth ?? '1200px',
22
+ }),
23
+ [chatbot]
24
+ );
25
+
26
+ return (
27
+ <div
28
+ className={clsx('asgard-chatbot-body', styles.chatbot_body)}
29
+ style={chatbot?.body?.style}
30
+ >
31
+ <div className={styles.chatbot_body__content} style={contentStyles}>
32
+ {Array.from(messages?.values() ?? []).map((message) => (
33
+ <ConversationMessageRenderer
34
+ key={message.messageId}
35
+ message={message}
36
+ />
37
+ ))}
38
+ <BotTypingPlaceholder
39
+ placeholder={botTypingPlaceholder ?? '正在輸入訊息'}
40
+ />
41
+ <div ref={messageBoxBottomRef} />
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,55 @@
1
+ import { ReactNode } from 'react';
2
+ import { ConversationMessage, MessageTemplateType } from '@asgard-js/core';
3
+ import {
4
+ BotTypingBox,
5
+ ButtonTemplate,
6
+ CarouselTemplate,
7
+ HintTemplate,
8
+ TextTemplate,
9
+ ChartTemplate,
10
+ ImageTemplate,
11
+ } from '../../templates';
12
+
13
+ interface ConversationMessageRendererProps {
14
+ message: ConversationMessage;
15
+ }
16
+
17
+ export function ConversationMessageRenderer(
18
+ props: ConversationMessageRendererProps
19
+ ): ReactNode {
20
+ const { message } = props;
21
+
22
+ if (message.type === 'user') {
23
+ return <TextTemplate message={message} />;
24
+ }
25
+
26
+ if (message.type === 'error') {
27
+ return <HintTemplate message={message} />;
28
+ }
29
+
30
+ if (message.isTyping) {
31
+ return (
32
+ <BotTypingBox
33
+ isTyping={message.isTyping}
34
+ typingText={message.typingText}
35
+ />
36
+ );
37
+ }
38
+
39
+ switch (message.message.template?.type) {
40
+ case MessageTemplateType.TEXT:
41
+ return <TextTemplate message={message} />;
42
+ case MessageTemplateType.HINT:
43
+ return <HintTemplate message={message} />;
44
+ case MessageTemplateType.BUTTON:
45
+ return <ButtonTemplate message={message} />;
46
+ case MessageTemplateType.CAROUSEL:
47
+ return <CarouselTemplate message={message} />;
48
+ case MessageTemplateType.CHART:
49
+ return <ChartTemplate message={message} />;
50
+ case MessageTemplateType.IMAGE:
51
+ return <ImageTemplate message={message} />;
52
+ default:
53
+ return <div />;
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export * from './chatbot-body';
@@ -0,0 +1,41 @@
1
+ @use '../../../styles';
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@100..900&family=Space+Grotesk:wght@300..700&display=swap');
4
+
5
+ .chatbot_root {
6
+ @include styles.generate();
7
+
8
+ font-family: 'Space Grotesk', 'Noto Sans TC', sans-serif;
9
+
10
+ * {
11
+ line-height: 1.5;
12
+ box-sizing: border-box;
13
+ }
14
+ }
15
+
16
+ .full_screen {
17
+ position: fixed;
18
+ top: 0;
19
+ left: 0;
20
+ width: 100%;
21
+ height: calc(var(--vh, 1vh) * 100);
22
+ background-color: var(--asg-color-bg);
23
+
24
+ .chatbot_container {
25
+ height: 100%;
26
+ transition: all 0.1s;
27
+ padding: env(safe-area-inset-top) env(safe-area-inset-right)
28
+ env(safe-area-inset-bottom) env(safe-area-inset-left);
29
+
30
+ &.screen_keyboard_open {
31
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
32
+ env(safe-area-inset-left);
33
+ }
34
+ }
35
+ }
36
+
37
+ .chatbot_container {
38
+ display: grid;
39
+ grid-template-rows: max-content auto max-content;
40
+ background-color: var(--asg-color-bg);
41
+ }
@@ -0,0 +1,49 @@
1
+ import { PropsWithChildren, ReactNode, useRef, CSSProperties } from 'react';
2
+ import { useUpdateVh } from 'src/hooks';
3
+ import { ChatbotFullScreenContainer } from './chatbot-full-screen-container';
4
+ import classes from './chatbot-container.module.scss';
5
+ import { useAsgardThemeContext } from 'src/context/asgard-theme-context';
6
+ import clsx from 'clsx';
7
+
8
+ interface ChatbotContainerProps extends PropsWithChildren {
9
+ className?: string;
10
+ style?: CSSProperties;
11
+ fullScreen?: boolean;
12
+ }
13
+
14
+ export function ChatbotContainer(props: ChatbotContainerProps): ReactNode {
15
+ const { fullScreen, children, className, style = {} } = props;
16
+
17
+ const rootRef = useRef<HTMLDivElement>(null);
18
+
19
+ useUpdateVh(rootRef);
20
+
21
+ const {
22
+ chatbot: {
23
+ style: rootStyle,
24
+ header,
25
+ body,
26
+ footer,
27
+ ...chatbotInnerContainerStyle
28
+ },
29
+ } = useAsgardThemeContext();
30
+
31
+ return (
32
+ <div
33
+ ref={rootRef}
34
+ className={clsx(classes.chatbot_root, className)}
35
+ style={Object.assign({}, rootStyle, style)}
36
+ >
37
+ {fullScreen ? (
38
+ <ChatbotFullScreenContainer>{children}</ChatbotFullScreenContainer>
39
+ ) : (
40
+ <div
41
+ className={classes.chatbot_container}
42
+ style={chatbotInnerContainerStyle}
43
+ >
44
+ {children}
45
+ </div>
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,54 @@
1
+ import { PropsWithChildren, ReactNode, useMemo, useRef } from 'react';
2
+ import clsx from 'clsx';
3
+ import {
4
+ useIsOnScreenKeyboardOpen,
5
+ useOnScreenKeyboardScrollFix,
6
+ usePreventOverScrolling,
7
+ useViewportSize,
8
+ } from 'src/hooks';
9
+ import classes from './chatbot-container.module.scss';
10
+ import { useAsgardThemeContext } from 'src/context/asgard-theme-context';
11
+
12
+ export function ChatbotFullScreenContainer(
13
+ props: PropsWithChildren
14
+ ): ReactNode {
15
+ const { children } = props;
16
+
17
+ const containerRef = useRef<HTMLDivElement>(null);
18
+
19
+ const theme = useAsgardThemeContext();
20
+
21
+ usePreventOverScrolling(containerRef);
22
+
23
+ useOnScreenKeyboardScrollFix();
24
+
25
+ const [, height] = useViewportSize() ?? [];
26
+
27
+ const isOnScreenKeyboardOpen = useIsOnScreenKeyboardOpen();
28
+
29
+ const styles = useMemo(() => {
30
+ return Object.assign(
31
+ theme?.chatbot?.backgroundColor
32
+ ? {
33
+ backgroundColor: theme.chatbot?.backgroundColor,
34
+ }
35
+ : {},
36
+ isOnScreenKeyboardOpen ? { height } : {}
37
+ );
38
+ }, [height, isOnScreenKeyboardOpen, theme]);
39
+
40
+ return (
41
+ <div className={classes.full_screen}>
42
+ <div
43
+ ref={containerRef}
44
+ className={clsx(
45
+ classes.chatbot_container,
46
+ isOnScreenKeyboardOpen && classes.screen_keyboard_open
47
+ )}
48
+ style={styles}
49
+ >
50
+ {children}
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,67 @@
1
+ .chatbot_footer {
2
+ border-top: 1px solid #434343;
3
+
4
+ .chatbot_footer__content {
5
+ display: grid;
6
+ align-items: flex-end;
7
+ grid-template-columns: auto 24px;
8
+ margin: 0 auto;
9
+ max-width: 1200px;
10
+ padding: var(--asg-spacing-3) var(--asg-spacing-4);
11
+ gap: var(--asg-spacing-2);
12
+ }
13
+ }
14
+
15
+ .chatbot_textarea {
16
+ font: inherit;
17
+ font-size: 16px;
18
+ height: 36px;
19
+ max-height: 240px;
20
+ padding: 8px;
21
+ padding-left: 12px;
22
+ background: rgba(31, 31, 31, 1);
23
+ border: 1px solid rgba(67, 67, 67, 1);
24
+ border-radius: 2px;
25
+ color: rgba(140, 140, 140, 1);
26
+ resize: none;
27
+ overflow: hidden;
28
+
29
+ &::placeholder {
30
+ color: var(--asg-color-text-placeholder);
31
+ }
32
+ }
33
+
34
+ .chatbot_submit_button {
35
+ font: inherit;
36
+ width: 24px;
37
+ height: 100%;
38
+ max-height: 38px;
39
+ display: flex;
40
+ justify-content: center;
41
+ align-items: center;
42
+ font-size: 16px;
43
+ border: none;
44
+ background: transparent;
45
+ border-radius: 50%;
46
+ color: white;
47
+ cursor: pointer;
48
+
49
+ > svg {
50
+ -webkit-touch-callout: none;
51
+ -webkit-user-select: none;
52
+ user-select: none;
53
+ width: auto;
54
+ min-width: 16px;
55
+ height: 60%;
56
+ max-height: 38px;
57
+ min-height: 16px;
58
+ }
59
+
60
+ &.chatbot_submit_button__disabled {
61
+ > svg {
62
+ > path {
63
+ fill: rgba(140, 140, 140, 1);
64
+ }
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ ChangeEventHandler,
3
+ KeyboardEventHandler,
4
+ ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { useAsgardContext } from 'src/context/asgard-service-context';
12
+ import styles from './chatbot-footer.module.scss';
13
+ import SendSvg from 'src/icons/send.svg?react';
14
+ import { SpeechInputButton } from './speech-input-button';
15
+ import clsx from 'clsx';
16
+ import { useAsgardThemeContext } from 'src/context/asgard-theme-context';
17
+
18
+ export function ChatbotFooter(): ReactNode {
19
+ const { sendMessage, isConnecting } = useAsgardContext();
20
+
21
+ const { chatbot } = useAsgardThemeContext();
22
+
23
+ const [value, setValue] = useState('');
24
+ const [isComposing, setIsComposing] = useState(false);
25
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
26
+
27
+ const disabled = useMemo(
28
+ () => isConnecting || !value.trim(),
29
+ [isConnecting, value]
30
+ );
31
+
32
+ const contentStyles = useMemo(
33
+ () => ({
34
+ maxWidth: chatbot?.contentMaxWidth ?? '1200px',
35
+ borderTopColor: chatbot?.borderColor,
36
+ }),
37
+ [chatbot]
38
+ );
39
+
40
+ const onChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
41
+ (event) => {
42
+ const element = event.target as HTMLTextAreaElement;
43
+ const value = element.value;
44
+
45
+ element.style.height = '36px';
46
+
47
+ if (value) {
48
+ element.style.height = `${element.scrollHeight}px`;
49
+ }
50
+
51
+ setValue(event.target.value);
52
+ },
53
+ []
54
+ );
55
+
56
+ const onSubmit = useCallback(() => {
57
+ if (!isComposing && !isConnecting) {
58
+ sendMessage?.({ text: value });
59
+ setValue('');
60
+
61
+ if (textareaRef.current) {
62
+ textareaRef.current.style.height = '36px';
63
+ }
64
+ }
65
+ }, [isComposing, isConnecting, sendMessage, value]);
66
+
67
+ const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
68
+ (event) => {
69
+ if (
70
+ event.key === 'Enter' &&
71
+ !isComposing &&
72
+ !isConnecting &&
73
+ value.trim()
74
+ ) {
75
+ sendMessage?.({ text: value });
76
+ setValue('');
77
+
78
+ const element = event.target as HTMLTextAreaElement;
79
+
80
+ element.style.height = '36px';
81
+ }
82
+ },
83
+ [isComposing, isConnecting, sendMessage, value]
84
+ );
85
+
86
+ useEffect(() => {
87
+ if (textareaRef.current) {
88
+ textareaRef.current.style.setProperty(
89
+ '--asg-color-text-placeholder',
90
+ chatbot.footer?.textArea?.['::placeholder']?.color ??
91
+ 'var(--asg-color-text-placeholder)'
92
+ );
93
+ }
94
+ }, [chatbot.footer?.textArea]);
95
+
96
+ return (
97
+ <div
98
+ className={clsx('asgard-chatbot-footer', styles.chatbot_footer)}
99
+ style={chatbot.footer?.style}
100
+ >
101
+ <div className={styles.chatbot_footer__content} style={contentStyles}>
102
+ <textarea
103
+ ref={textareaRef}
104
+ className={styles.chatbot_textarea}
105
+ style={chatbot.footer?.textArea?.style}
106
+ disabled={isConnecting}
107
+ cols={40}
108
+ value={value}
109
+ placeholder="Enter message"
110
+ onChange={onChange}
111
+ onKeyDown={onKeyDown}
112
+ onCompositionStart={() => setIsComposing(true)}
113
+ onCompositionEnd={() => setIsComposing(false)}
114
+ />
115
+ {value ? (
116
+ <button
117
+ className={clsx(
118
+ styles.chatbot_submit_button,
119
+ disabled && styles.chatbot_submit_button__disabled
120
+ )}
121
+ style={chatbot.footer?.submitButton?.style}
122
+ disabled={disabled}
123
+ onClick={onSubmit}
124
+ >
125
+ <SendSvg />
126
+ </button>
127
+ ) : (
128
+ <SpeechInputButton
129
+ setValue={setValue}
130
+ className={clsx(
131
+ styles.chatbot_submit_button,
132
+ isConnecting && styles.chatbot_submit_button__disabled
133
+ )}
134
+ style={chatbot.footer?.speechInputButton?.style}
135
+ />
136
+ )}
137
+ </div>
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1 @@
1
+ export * from './chatbot-footer';
@@ -0,0 +1,132 @@
1
+ import {
2
+ Dispatch,
3
+ MouseEventHandler,
4
+ ReactNode,
5
+ SetStateAction,
6
+ TouchEventHandler,
7
+ useCallback,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ CSSProperties,
12
+ } from 'react';
13
+ import MicSvg from 'src/icons/mic.svg?react';
14
+ import StopSvg from 'src/icons/stop.svg?react';
15
+
16
+ interface SpeechInputButtonProps {
17
+ setValue: Dispatch<SetStateAction<string>>;
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ export function SpeechInputButton(props: SpeechInputButtonProps): ReactNode {
23
+ const { setValue, className, style } = props;
24
+
25
+ const [listening, setListening] = useState(false);
26
+ const recognitionRef = useRef<SpeechRecognition | null>(null);
27
+
28
+ useEffect(() => {
29
+ const SpeechRecognition =
30
+ window.SpeechRecognition || window.webkitSpeechRecognition;
31
+
32
+ if (!SpeechRecognition) return;
33
+
34
+ const recognition = new SpeechRecognition();
35
+ recognition.lang = 'zh-TW';
36
+ recognition.continuous = true;
37
+ recognition.interimResults = true;
38
+
39
+ recognition.onresult = (event: SpeechRecognitionEvent): void => {
40
+ for (let i = event.resultIndex; i < event.results.length; i++) {
41
+ if (event.results[i].isFinal) {
42
+ setValue((prev) => prev + event.results[i][0].transcript);
43
+ }
44
+ }
45
+ };
46
+
47
+ recognition.onerror = (event: SpeechRecognitionErrorEvent): void => {
48
+ alert(`語音識別錯誤: ${JSON.stringify(event.error)}`);
49
+ };
50
+
51
+ recognition.onend = (): void => {
52
+ setListening(false);
53
+ };
54
+
55
+ recognitionRef.current = recognition;
56
+ }, [setValue]);
57
+
58
+ const startListening = useCallback(() => {
59
+ if (!recognitionRef.current) {
60
+ alert('無法開始辨識: 語音識別器未初始化,請檢查是否有授權使用麥克風');
61
+
62
+ return;
63
+ }
64
+
65
+ try {
66
+ recognitionRef.current.start();
67
+ setListening(true);
68
+ } catch (error) {
69
+ alert(`無法開始辨識: ${JSON.stringify(error)}`);
70
+ }
71
+ }, []);
72
+
73
+ const stopListening = useCallback(() => {
74
+ if (!recognitionRef.current) return;
75
+
76
+ recognitionRef.current.stop();
77
+ setListening(false);
78
+ }, []);
79
+
80
+ const onMouseDown = useCallback<MouseEventHandler<HTMLDivElement>>(
81
+ (event) => {
82
+ if (!listening) {
83
+ event.preventDefault();
84
+ startListening();
85
+ }
86
+ },
87
+ [listening, startListening]
88
+ );
89
+
90
+ const onMouseUp = useCallback<MouseEventHandler<HTMLDivElement>>(
91
+ (event) => {
92
+ if (listening) {
93
+ event.preventDefault();
94
+ stopListening();
95
+ }
96
+ },
97
+ [listening, stopListening]
98
+ );
99
+
100
+ const onTouchStart = useCallback<TouchEventHandler<HTMLDivElement>>(
101
+ (event) => {
102
+ if (!listening) {
103
+ event.preventDefault();
104
+ startListening();
105
+ }
106
+ },
107
+ [listening, startListening]
108
+ );
109
+
110
+ const onTouchEnd = useCallback<TouchEventHandler<HTMLDivElement>>(
111
+ (event) => {
112
+ if (listening) {
113
+ event.preventDefault();
114
+ stopListening();
115
+ }
116
+ },
117
+ [listening, stopListening]
118
+ );
119
+
120
+ return (
121
+ <div
122
+ className={className}
123
+ style={style}
124
+ onMouseDown={onMouseDown}
125
+ onMouseUp={onMouseUp}
126
+ onTouchStart={onTouchStart}
127
+ onTouchEnd={onTouchEnd}
128
+ >
129
+ {listening ? <StopSvg /> : <MicSvg />}
130
+ </div>
131
+ );
132
+ }