@magmamath/students-features 0.9.110 → 0.9.111-rc-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 (160) hide show
  1. package/dist/commonjs/features/chatbot/TTS_HIGHLIGHTING.md +109 -0
  2. package/dist/commonjs/features/chatbot/components/Chat/Chat.js +25 -22
  3. package/dist/commonjs/features/chatbot/components/Chat/Chat.js.map +1 -1
  4. package/dist/commonjs/features/chatbot/components/ChatMessage/ChatMessage.js.map +1 -1
  5. package/dist/commonjs/features/chatbot/components/ChatMessage/MessageContent.js +20 -4
  6. package/dist/commonjs/features/chatbot/components/ChatMessage/MessageContent.js.map +1 -1
  7. package/dist/commonjs/features/chatbot/components/ChatMessage/ReceivedMessage.js +6 -2
  8. package/dist/commonjs/features/chatbot/components/ChatMessage/ReceivedMessage.js.map +1 -1
  9. package/dist/commonjs/features/chatbot/components/Chatbot.js +6 -2
  10. package/dist/commonjs/features/chatbot/components/Chatbot.js.map +1 -1
  11. package/dist/commonjs/features/chatbot/helpers/textToSSML.js +22 -0
  12. package/dist/commonjs/features/chatbot/helpers/textToSSML.js.map +1 -0
  13. package/dist/commonjs/features/chatbot/model/ChatBotModel.js +4 -6
  14. package/dist/commonjs/features/chatbot/model/ChatBotModel.js.map +1 -1
  15. package/dist/commonjs/features/chatbot/model/t2s.js +125 -32
  16. package/dist/commonjs/features/chatbot/model/t2s.js.map +1 -1
  17. package/dist/commonjs/features/chatbot/types/t2s.types.js.map +1 -1
  18. package/dist/commonjs/index.js +1 -13
  19. package/dist/commonjs/index.js.map +1 -1
  20. package/dist/commonjs/types/common.types.js +0 -4
  21. package/dist/module/features/chatbot/TTS_HIGHLIGHTING.md +109 -0
  22. package/dist/module/features/chatbot/components/Chat/Chat.js +25 -22
  23. package/dist/module/features/chatbot/components/Chat/Chat.js.map +1 -1
  24. package/dist/module/features/chatbot/components/ChatMessage/ChatMessage.js.map +1 -1
  25. package/dist/module/features/chatbot/components/ChatMessage/MessageContent.js +20 -4
  26. package/dist/module/features/chatbot/components/ChatMessage/MessageContent.js.map +1 -1
  27. package/dist/module/features/chatbot/components/ChatMessage/ReceivedMessage.js +6 -2
  28. package/dist/module/features/chatbot/components/ChatMessage/ReceivedMessage.js.map +1 -1
  29. package/dist/module/features/chatbot/components/Chatbot.js +6 -2
  30. package/dist/module/features/chatbot/components/Chatbot.js.map +1 -1
  31. package/dist/module/features/chatbot/helpers/textToSSML.js +16 -0
  32. package/dist/module/features/chatbot/helpers/textToSSML.js.map +1 -0
  33. package/dist/module/features/chatbot/index.js.map +1 -1
  34. package/dist/module/features/chatbot/model/ChatBotModel.js +4 -6
  35. package/dist/module/features/chatbot/model/ChatBotModel.js.map +1 -1
  36. package/dist/module/features/chatbot/model/t2s.js +126 -33
  37. package/dist/module/features/chatbot/model/t2s.js.map +1 -1
  38. package/dist/module/features/chatbot/types/t2s.types.js.map +1 -1
  39. package/dist/module/index.js +0 -1
  40. package/dist/module/index.js.map +1 -1
  41. package/dist/module/types/common.types.js +0 -2
  42. package/dist/typescript/commonjs/features/chatbot/components/Chat/Chat.d.ts +4 -1
  43. package/dist/typescript/commonjs/features/chatbot/components/Chat/Chat.d.ts.map +1 -1
  44. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/ChatMessage.d.ts +3 -1
  45. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/ChatMessage.d.ts.map +1 -1
  46. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/MessageContent.d.ts +4 -2
  47. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/MessageContent.d.ts.map +1 -1
  48. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/ReceivedMessage.d.ts +1 -1
  49. package/dist/typescript/commonjs/features/chatbot/components/ChatMessage/ReceivedMessage.d.ts.map +1 -1
  50. package/dist/typescript/commonjs/features/chatbot/components/Chatbot.d.ts +4 -1
  51. package/dist/typescript/commonjs/features/chatbot/components/Chatbot.d.ts.map +1 -1
  52. package/dist/typescript/commonjs/features/chatbot/helpers/textToSSML.d.ts +7 -0
  53. package/dist/typescript/commonjs/features/chatbot/helpers/textToSSML.d.ts.map +1 -0
  54. package/dist/typescript/commonjs/features/chatbot/index.d.ts +1 -1
  55. package/dist/typescript/commonjs/features/chatbot/index.d.ts.map +1 -1
  56. package/dist/typescript/commonjs/features/chatbot/model/ChatBotModel.d.ts.map +1 -1
  57. package/dist/typescript/commonjs/features/chatbot/model/t2s.d.ts +20 -9
  58. package/dist/typescript/commonjs/features/chatbot/model/t2s.d.ts.map +1 -1
  59. package/dist/typescript/commonjs/features/chatbot/types/api.types.d.ts +2 -1
  60. package/dist/typescript/commonjs/features/chatbot/types/api.types.d.ts.map +1 -1
  61. package/dist/typescript/commonjs/features/chatbot/types/t2s.types.d.ts +10 -0
  62. package/dist/typescript/commonjs/features/chatbot/types/t2s.types.d.ts.map +1 -1
  63. package/dist/typescript/commonjs/index.d.ts +0 -1
  64. package/dist/typescript/commonjs/index.d.ts.map +1 -1
  65. package/dist/typescript/commonjs/types/common.types.d.ts +0 -2
  66. package/dist/typescript/commonjs/types/common.types.d.ts.map +1 -1
  67. package/dist/typescript/module/features/chatbot/components/Chat/Chat.d.ts +4 -1
  68. package/dist/typescript/module/features/chatbot/components/Chat/Chat.d.ts.map +1 -1
  69. package/dist/typescript/module/features/chatbot/components/ChatMessage/ChatMessage.d.ts +3 -1
  70. package/dist/typescript/module/features/chatbot/components/ChatMessage/ChatMessage.d.ts.map +1 -1
  71. package/dist/typescript/module/features/chatbot/components/ChatMessage/MessageContent.d.ts +4 -2
  72. package/dist/typescript/module/features/chatbot/components/ChatMessage/MessageContent.d.ts.map +1 -1
  73. package/dist/typescript/module/features/chatbot/components/ChatMessage/ReceivedMessage.d.ts +1 -1
  74. package/dist/typescript/module/features/chatbot/components/ChatMessage/ReceivedMessage.d.ts.map +1 -1
  75. package/dist/typescript/module/features/chatbot/components/Chatbot.d.ts +4 -1
  76. package/dist/typescript/module/features/chatbot/components/Chatbot.d.ts.map +1 -1
  77. package/dist/typescript/module/features/chatbot/helpers/textToSSML.d.ts +7 -0
  78. package/dist/typescript/module/features/chatbot/helpers/textToSSML.d.ts.map +1 -0
  79. package/dist/typescript/module/features/chatbot/index.d.ts +1 -1
  80. package/dist/typescript/module/features/chatbot/index.d.ts.map +1 -1
  81. package/dist/typescript/module/features/chatbot/model/ChatBotModel.d.ts.map +1 -1
  82. package/dist/typescript/module/features/chatbot/model/t2s.d.ts +20 -9
  83. package/dist/typescript/module/features/chatbot/model/t2s.d.ts.map +1 -1
  84. package/dist/typescript/module/features/chatbot/types/api.types.d.ts +2 -1
  85. package/dist/typescript/module/features/chatbot/types/api.types.d.ts.map +1 -1
  86. package/dist/typescript/module/features/chatbot/types/t2s.types.d.ts +10 -0
  87. package/dist/typescript/module/features/chatbot/types/t2s.types.d.ts.map +1 -1
  88. package/dist/typescript/module/index.d.ts +0 -1
  89. package/dist/typescript/module/index.d.ts.map +1 -1
  90. package/dist/typescript/module/types/common.types.d.ts +0 -2
  91. package/dist/typescript/module/types/common.types.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/features/chatbot/TTS_HIGHLIGHTING.md +109 -0
  94. package/src/features/chatbot/components/Chat/Chat.tsx +28 -19
  95. package/src/features/chatbot/components/ChatMessage/ChatMessage.tsx +3 -1
  96. package/src/features/chatbot/components/ChatMessage/MessageContent.tsx +30 -3
  97. package/src/features/chatbot/components/ChatMessage/ReceivedMessage.tsx +4 -0
  98. package/src/features/chatbot/components/Chatbot.tsx +7 -0
  99. package/src/features/chatbot/helpers/textToSSML.ts +15 -0
  100. package/src/features/chatbot/index.ts +1 -1
  101. package/src/features/chatbot/model/ChatBotModel.ts +3 -7
  102. package/src/features/chatbot/model/t2s.ts +147 -32
  103. package/src/features/chatbot/types/api.types.ts +2 -1
  104. package/src/features/chatbot/types/t2s.types.ts +7 -0
  105. package/src/index.ts +0 -1
  106. package/src/types/common.types.ts +0 -4
  107. package/dist/commonjs/features/openEnded/components/OpenEndedAttemptsLimit.js +0 -34
  108. package/dist/commonjs/features/openEnded/components/OpenEndedAttemptsLimit.js.map +0 -1
  109. package/dist/commonjs/features/openEnded/components/OpenEndedScoreLabel.js +0 -68
  110. package/dist/commonjs/features/openEnded/components/OpenEndedScoreLabel.js.map +0 -1
  111. package/dist/commonjs/features/openEnded/index.js +0 -48
  112. package/dist/commonjs/features/openEnded/index.js.map +0 -1
  113. package/dist/commonjs/features/openEnded/oe.helpers.js +0 -26
  114. package/dist/commonjs/features/openEnded/oe.helpers.js.map +0 -1
  115. package/dist/commonjs/features/openEnded/oe.types.js +0 -14
  116. package/dist/commonjs/features/openEnded/oe.types.js.map +0 -1
  117. package/dist/commonjs/shared/common.constants.js +0 -9
  118. package/dist/commonjs/shared/common.constants.js.map +0 -1
  119. package/dist/module/features/openEnded/components/OpenEndedAttemptsLimit.js +0 -28
  120. package/dist/module/features/openEnded/components/OpenEndedAttemptsLimit.js.map +0 -1
  121. package/dist/module/features/openEnded/components/OpenEndedScoreLabel.js +0 -62
  122. package/dist/module/features/openEnded/components/OpenEndedScoreLabel.js.map +0 -1
  123. package/dist/module/features/openEnded/index.js +0 -7
  124. package/dist/module/features/openEnded/index.js.map +0 -1
  125. package/dist/module/features/openEnded/oe.helpers.js +0 -21
  126. package/dist/module/features/openEnded/oe.helpers.js.map +0 -1
  127. package/dist/module/features/openEnded/oe.types.js +0 -10
  128. package/dist/module/features/openEnded/oe.types.js.map +0 -1
  129. package/dist/module/shared/common.constants.js +0 -5
  130. package/dist/module/shared/common.constants.js.map +0 -1
  131. package/dist/typescript/commonjs/features/openEnded/components/OpenEndedAttemptsLimit.d.ts +0 -7
  132. package/dist/typescript/commonjs/features/openEnded/components/OpenEndedAttemptsLimit.d.ts.map +0 -1
  133. package/dist/typescript/commonjs/features/openEnded/components/OpenEndedScoreLabel.d.ts +0 -12
  134. package/dist/typescript/commonjs/features/openEnded/components/OpenEndedScoreLabel.d.ts.map +0 -1
  135. package/dist/typescript/commonjs/features/openEnded/index.d.ts +0 -5
  136. package/dist/typescript/commonjs/features/openEnded/index.d.ts.map +0 -1
  137. package/dist/typescript/commonjs/features/openEnded/oe.helpers.d.ts +0 -13
  138. package/dist/typescript/commonjs/features/openEnded/oe.helpers.d.ts.map +0 -1
  139. package/dist/typescript/commonjs/features/openEnded/oe.types.d.ts +0 -7
  140. package/dist/typescript/commonjs/features/openEnded/oe.types.d.ts.map +0 -1
  141. package/dist/typescript/commonjs/shared/common.constants.d.ts +0 -2
  142. package/dist/typescript/commonjs/shared/common.constants.d.ts.map +0 -1
  143. package/dist/typescript/module/features/openEnded/components/OpenEndedAttemptsLimit.d.ts +0 -7
  144. package/dist/typescript/module/features/openEnded/components/OpenEndedAttemptsLimit.d.ts.map +0 -1
  145. package/dist/typescript/module/features/openEnded/components/OpenEndedScoreLabel.d.ts +0 -12
  146. package/dist/typescript/module/features/openEnded/components/OpenEndedScoreLabel.d.ts.map +0 -1
  147. package/dist/typescript/module/features/openEnded/index.d.ts +0 -5
  148. package/dist/typescript/module/features/openEnded/index.d.ts.map +0 -1
  149. package/dist/typescript/module/features/openEnded/oe.helpers.d.ts +0 -13
  150. package/dist/typescript/module/features/openEnded/oe.helpers.d.ts.map +0 -1
  151. package/dist/typescript/module/features/openEnded/oe.types.d.ts +0 -7
  152. package/dist/typescript/module/features/openEnded/oe.types.d.ts.map +0 -1
  153. package/dist/typescript/module/shared/common.constants.d.ts +0 -2
  154. package/dist/typescript/module/shared/common.constants.d.ts.map +0 -1
  155. package/src/features/openEnded/components/OpenEndedAttemptsLimit.tsx +0 -26
  156. package/src/features/openEnded/components/OpenEndedScoreLabel.tsx +0 -68
  157. package/src/features/openEnded/index.ts +0 -4
  158. package/src/features/openEnded/oe.helpers.ts +0 -31
  159. package/src/features/openEnded/oe.types.ts +0 -6
  160. package/src/shared/common.constants.ts +0 -3
@@ -0,0 +1,109 @@
1
+ # TTS Word-by-Word Highlighting
2
+
3
+ ## Overview
4
+
5
+ Word-by-word text highlighting synchronized with text-to-speech playback in the chatbot. The feature is optional and enabled via props.
6
+
7
+ ## How It Works
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────────┐
11
+ │ 1. User clicks TTS button on a message │
12
+ │ ↓ │
13
+ │ 2. Text is converted to SSML with word marks │
14
+ │ "Hello world" → <speak><mark name="w_0"/>Hello │
15
+ │ <mark name="w_1"/>world</speak> │
16
+ │ ↓ │
17
+ │ 3. Google TTS API returns audio + timepoints │
18
+ │ [{ markName: "w_0", timeSeconds: 0.0 }, │
19
+ │ { markName: "w_1", timeSeconds: 0.5 }] │
20
+ │ ↓ │
21
+ │ 4. Audio plays, ontimeupdate fires (~4x/sec) │
22
+ │ ↓ │
23
+ │ 5. Current time → find matching timepoint → word index │
24
+ │ ↓ │
25
+ │ 6. $highlightedWordIndex updates → UI re-renders highlighted │
26
+ └─────────────────────────────────────────────────────────────────┘
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```tsx
32
+ // In students-web model setup
33
+ chatModel.bot.setUpT2S({
34
+ audioProvider: createT2SAudioProvider(),
35
+ textSanitizer: removeKatexPartFromTextToSpeech,
36
+ isHighlightEnabled: true, // Enable highlighting
37
+ })
38
+
39
+ // In component
40
+ <Chatbot
41
+ model={chatModel.bot}
42
+ isHighlightEnabled={true}
43
+ highlightConfig={{
44
+ MessageRenderer: ChatbotHighlightedMessage,
45
+ }}
46
+ />
47
+ ```
48
+
49
+ ## Files Modified
50
+
51
+ ### students-features
52
+
53
+ | File | Changes |
54
+ |------|---------|
55
+ | `types/t2s.types.ts` | Added `onTimeUpdate`, `getCurrentTime` to AudioProvider; added `HighlightConfig` type |
56
+ | `types/api.types.ts` | Added `ssml` option to GoogleText2SpeechPayload input |
57
+ | `helpers/textToSSML.ts` | NEW - Converts text to SSML with word marks |
58
+ | `model/t2s.ts` | Added `$highlightedWordIndex`, `$timepoints`, `$time` stores; SSML/timepoint handling |
59
+ | `components/Chatbot.tsx` | Added `isHighlightEnabled`, `highlightConfig` props |
60
+ | `components/Chat/Chat.tsx` | Passes highlight state to ChatMessage |
61
+ | `components/ChatMessage/*` | Passes highlight props through component chain |
62
+ | `components/ChatMessage/MessageContent.tsx` | Renders custom MessageRenderer when provided |
63
+
64
+ ### students-web
65
+
66
+ | File | Changes |
67
+ |------|---------|
68
+ | `chatbot/helpers.ts` | Added `onTimeUpdate`, `getCurrentTime` to AudioProvider |
69
+ | `chatbot/components/ChatbotHighlightedMessage.tsx` | NEW - Renders words with highlight styling |
70
+ | `chatbot/components/ChatbotHighlightedMessage.module.scss` | NEW - Yellow highlight styles |
71
+ | `chatbot/model/model.ts` | Enabled `isHighlightEnabled: true` |
72
+ | `PracticeMode/chatbot/PracticeChatbot.tsx` | Passes highlightConfig with MessageRenderer |
73
+
74
+ ## Key Components
75
+
76
+ ### textToSSML (students-features)
77
+ ```ts
78
+ textToSSML("Hello world")
79
+ // → '<speak><mark name="w_0"/>Hello <mark name="w_1"/>world</speak>'
80
+ ```
81
+
82
+ ### $highlightedWordIndex (students-features)
83
+ Effector store that computes current word index from timepoints + playback time.
84
+
85
+ ### ChatbotHighlightedMessage (students-web)
86
+ ```tsx
87
+ // Splits text, highlights word at highlightedIndex
88
+ <span className={isHighlighted ? styles.Highlighted : undefined}>
89
+ {word}
90
+ </span>
91
+ ```
92
+
93
+ ## Data Flow
94
+
95
+ ```
96
+ AudioProvider.onTimeUpdate(time)
97
+
98
+ t2s.setTime(time)
99
+
100
+ $highlightedWordIndex = findCurrentMark(timepoints, time) → parse index
101
+
102
+ Chat.tsx: useUnit(model.t2s.$highlightedWordIndex)
103
+
104
+ ChatMessage → ReceivedMessage → MessageContent
105
+
106
+ MessageRenderer({ text, highlightedIndex })
107
+
108
+ ChatbotHighlightedMessage renders highlighted word
109
+ ```
@@ -9,6 +9,7 @@ import { ChatInput } from '../ChatInput/ChatInput'
9
9
  import { MessageVariant } from '../../types/model.types'
10
10
  import { ColorScheme } from '../../types/style.types'
11
11
  import { ConditionalWrapper } from '../../../../lib/components/ConditionalWrapper'
12
+ import { HighlightConfig } from '../../types/t2s.types'
12
13
 
13
14
  const TOOLBAR_HEIGHT = 140
14
15
  const BOTTOM_OFFSET = Platform.select({
@@ -23,6 +24,8 @@ type ChatProps = {
23
24
  isInputDisabled?: boolean
24
25
  colorScheme?: ColorScheme
25
26
  MessageWrapper?: ComponentType<PropsWithChildren>
27
+ isHighlightEnabled?: boolean
28
+ highlightConfig?: HighlightConfig
26
29
  }
27
30
 
28
31
  export const Chat = ({
@@ -32,6 +35,8 @@ export const Chat = ({
32
35
  isInputDisabled,
33
36
  colorScheme = ColorScheme.Blue,
34
37
  MessageWrapper,
38
+ isHighlightEnabled = false,
39
+ highlightConfig,
35
40
  }: ChatProps) => {
36
41
  const dimensions = useWindowDimensions()
37
42
  const ref = useRef<ScrollView>(null)
@@ -44,6 +49,7 @@ export const Chat = ({
44
49
  const isTranslated = useUnit(model.translation.$isTranslated)
45
50
  const audioStatus = useUnit(model.t2s.$status)
46
51
  const errorSpottingEnabled = useUnit(model.errorSpotting.$isEnabled)
52
+ const highlightedWordIndex = useUnit(model.t2s.$highlightedWordIndex)
47
53
 
48
54
  const messages = useStoreMap(model.$currentChatData, ({ messages }) => messages)
49
55
  const lastMessageId = messages.at(-1)?.id
@@ -68,25 +74,28 @@ export const Chat = ({
68
74
  {isOpen && (
69
75
  <View style={[styles.gap, isInputDisabled && styles.messagesWithoutInput]}>
70
76
  {messages.map((message) => (
71
- <React.Fragment key={message.id}>
72
- <ConditionalWrapper withWrapper={shouldWrapMessage} Wrapper={MessageWrapper}>
73
- <ChatMessage
74
- isTranslated={isTranslated}
75
- withIcon={!message.isOwnMessage && message.id === lastMessageId}
76
- message={message}
77
- variant={message.isOwnMessage ? MessageVariant.SENT : MessageVariant.RECEIVED}
78
- colorScheme={colorScheme}
79
- withErrorSpotting={errorSpottingEnabled && !message.isOwnMessage}
80
- t2sState={{
81
- isEnabled: isTextToSpeechEnabled && !message.isOwnMessage,
82
- isActive: message.id === currentPlayingId,
83
- isLoading: isTextToSpeechPending,
84
- status: audioStatus,
85
- disabled: isTextToSpeechPending,
86
- }}
87
- />
88
- </ConditionalWrapper>
89
- </React.Fragment>
77
+ <ConditionalWrapper withWrapper={shouldWrapMessage} Wrapper={MessageWrapper}>
78
+ <ChatMessage
79
+ key={message.id}
80
+ isTranslated={isTranslated}
81
+ withIcon={!message.isOwnMessage && message.id === lastMessageId}
82
+ message={message}
83
+ variant={message.isOwnMessage ? MessageVariant.SENT : MessageVariant.RECEIVED}
84
+ colorScheme={colorScheme}
85
+ withErrorSpotting={errorSpottingEnabled && !message.isOwnMessage}
86
+ t2sState={{
87
+ isEnabled: isTextToSpeechEnabled && !message.isOwnMessage,
88
+ isActive: message.id === currentPlayingId,
89
+ isLoading: isTextToSpeechPending,
90
+ status: audioStatus,
91
+ disabled: isTextToSpeechPending,
92
+ }}
93
+ highlightedWordIndex={
94
+ isHighlightEnabled && message.id === currentPlayingId ? highlightedWordIndex : null
95
+ }
96
+ highlightConfig={highlightConfig}
97
+ />
98
+ </ConditionalWrapper>
90
99
  ))}
91
100
 
92
101
  {isHelpRequestPending && <MessageLoader colorScheme={colorScheme} />}
@@ -2,7 +2,7 @@ import React, { ComponentType } from 'react'
2
2
 
3
3
  import { ReceivedMessage } from './ReceivedMessage'
4
4
  import { SentMessage } from './SentMessage'
5
- import { T2SState } from '../../types/t2s.types'
5
+ import { HighlightConfig, T2SState } from '../../types/t2s.types'
6
6
  import { MessageVariant } from '../../types/model.types'
7
7
  import { ConversationMessage } from '../../types/units.types'
8
8
  import { ColorScheme } from '../../types/style.types'
@@ -15,6 +15,8 @@ type ChatMessageProps = {
15
15
  withErrorSpotting?: boolean
16
16
  withIcon?: boolean
17
17
  colorScheme?: ColorScheme
18
+ highlightedWordIndex?: number | null
19
+ highlightConfig?: HighlightConfig
18
20
  }
19
21
 
20
22
  export type VariantProps = Omit<ChatMessageProps, 'variant'>
@@ -2,7 +2,7 @@ import React from 'react'
2
2
  import { Platform, StyleSheet, Text, View } from 'react-native'
3
3
 
4
4
  import { ConversationMessage } from '../../types/units.types'
5
- import { T2SState } from '../../types/t2s.types'
5
+ import { HighlightConfig, T2SState } from '../../types/t2s.types'
6
6
  import { ColorScheme } from '../../types/style.types'
7
7
 
8
8
  import { MessageButtonsBlock } from './MessageButtonsBlock'
@@ -16,6 +16,8 @@ type MessageContentProps = {
16
16
  variant: MessageVariant
17
17
  colorScheme?: ColorScheme
18
18
  withErrorSpotting?: boolean
19
+ highlightedWordIndex?: number | null
20
+ highlightConfig?: HighlightConfig
19
21
  }
20
22
 
21
23
  const MessageContentWeb = ({
@@ -25,10 +27,23 @@ const MessageContentWeb = ({
25
27
  variant,
26
28
  colorScheme = ColorScheme.Blue,
27
29
  withErrorSpotting,
30
+ highlightedWordIndex,
31
+ highlightConfig,
28
32
  }: MessageContentProps) => {
33
+ const MessageRenderer = highlightConfig?.MessageRenderer
34
+ // console.log('[TTS Debug] MessageContent - hasRenderer:', !!MessageRenderer, 'highlightedWordIndex:', highlightedWordIndex)
35
+
29
36
  return (
30
37
  <Text style={styles.messageText}>
31
- {text}
38
+ {MessageRenderer ? (
39
+ <MessageRenderer
40
+ text={text}
41
+ highlightedIndex={highlightedWordIndex ?? null}
42
+ colorScheme={colorScheme}
43
+ />
44
+ ) : (
45
+ text
46
+ )}
32
47
  <MessageButtonsBlock
33
48
  message={message}
34
49
  t2sState={t2sState}
@@ -47,11 +62,23 @@ const MessageContentMobile = ({
47
62
  variant,
48
63
  colorScheme = ColorScheme.Blue,
49
64
  withErrorSpotting,
65
+ highlightedWordIndex,
66
+ highlightConfig,
50
67
  }: MessageContentProps) => {
68
+ const MessageRenderer = highlightConfig?.MessageRenderer
69
+
51
70
  return (
52
71
  <>
53
72
  <Text style={[styles.messageText, styles.mobileMessageText]}>
54
- {text}
73
+ {MessageRenderer ? (
74
+ <MessageRenderer
75
+ text={text}
76
+ highlightedIndex={highlightedWordIndex ?? null}
77
+ colorScheme={colorScheme}
78
+ />
79
+ ) : (
80
+ text
81
+ )}
55
82
  <View style={{ width: getSpacerSize(message, t2sState) }} />
56
83
  </Text>
57
84
  <MessageButtonsBlock
@@ -14,6 +14,8 @@ export const ReceivedMessage = ({
14
14
  withIcon,
15
15
  colorScheme = ColorScheme.Blue,
16
16
  withErrorSpotting,
17
+ highlightedWordIndex,
18
+ highlightConfig,
17
19
  }: VariantProps) => {
18
20
  const text = isTranslated ? message.translatedMessage : message.message
19
21
 
@@ -33,6 +35,8 @@ export const ReceivedMessage = ({
33
35
  text={text ?? ''}
34
36
  colorScheme={colorScheme}
35
37
  withErrorSpotting={withErrorSpotting}
38
+ highlightedWordIndex={highlightedWordIndex}
39
+ highlightConfig={highlightConfig}
36
40
  />
37
41
  </View>
38
42
  </View>
@@ -7,6 +7,7 @@ import { ChatControls } from './ChatControls/ChatControls'
7
7
  import { AUDIO_CONFIG, DEFAULT_VOICE } from '../constants'
8
8
  import { ChatBotModelContext } from '../context/ChatBotModelContext'
9
9
  import { ColorScheme } from '../types/style.types'
10
+ import { HighlightConfig } from '../types/t2s.types'
10
11
 
11
12
  type ChatbotProps = {
12
13
  model: ChatbotModel
@@ -20,6 +21,8 @@ type ChatbotProps = {
20
21
  iconSize?: { width: number; height: number } // TODO: use number instead
21
22
  isInputDisabled?: boolean
22
23
  colorScheme?: ColorScheme
24
+ isHighlightEnabled?: boolean
25
+ highlightConfig?: HighlightConfig
23
26
  }
24
27
 
25
28
  export const Chatbot = ({
@@ -31,6 +34,8 @@ export const Chatbot = ({
31
34
  ButtonWrapper,
32
35
  iconSize,
33
36
  colorScheme = ColorScheme.Blue,
37
+ isHighlightEnabled = false,
38
+ highlightConfig,
34
39
  }: ChatbotProps) => {
35
40
  const isChatAllowed = useUnit(model.$chatAccess)
36
41
  const isAvailable = useStoreMap(model.$currentChatData, ({ isAvailable }) => isAvailable)
@@ -60,6 +65,8 @@ export const Chatbot = ({
60
65
  isTextToSpeechEnabled={isTextToSpeechEnabled}
61
66
  isInputDisabled={isInputDisabled}
62
67
  MessageWrapper={ButtonWrapper}
68
+ isHighlightEnabled={isHighlightEnabled}
69
+ highlightConfig={highlightConfig}
63
70
  />
64
71
 
65
72
  <ChatControls
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Converts plain text to SSML with word marks for TTS highlighting.
3
+ * "Hello world" -> '<speak><mark name="w_0"/>Hello <mark name="w_1"/>world</speak>'
4
+ */
5
+ export const textToSSML = (text: string): string => {
6
+ const words = text.split(/\s+/).filter(Boolean)
7
+ if (words.length === 0) return '<speak></speak>'
8
+
9
+ const ssmlParts = words.map((word, i) => `<mark name="w_${i}"/>${word}`)
10
+ return `<speak>${ssmlParts.join(' ')}</speak>`
11
+ }
12
+
13
+ export const getWordCount = (text: string): number => {
14
+ return text.split(/\s+/).filter(Boolean).length
15
+ }
@@ -1,6 +1,6 @@
1
1
  export { Chatbot } from './components/Chatbot'
2
2
  export { ChatbotModel } from './model/ChatBotModel'
3
- export { AudioStatus, type AudioProvider } from './types/t2s.types'
3
+ export { AudioStatus, type AudioProvider, type HighlightConfig } from './types/t2s.types'
4
4
  export {
5
5
  MessageVariant,
6
6
  type ChatbotModelProps,
@@ -86,7 +86,6 @@ export class ChatbotModel {
86
86
  this.api = new ChatbotAPI({ ...api, postMessage: this.threadManager.postMessage })
87
87
 
88
88
  this.$chatAccess = chatAccess
89
- if ($key) this.bindKey($key)
90
89
 
91
90
  this.threadManager.setApi({ getHint: api.requestHint, sendMessage: api.sendMessage })
92
91
  if (threadMessagesLimit) {
@@ -98,6 +97,7 @@ export class ChatbotModel {
98
97
  .on(this.setAlternatives, (_, payload) => transformAlternatives(payload))
99
98
  .reset(this.resetAlternatives)
100
99
 
100
+ this.t2s = new ChatTextToSpeechModel({ api: this.api })
101
101
  this.translation = new ChatbotTranslation({
102
102
  $context: this.$context,
103
103
  defaultMessages: this.defaultMessages,
@@ -110,16 +110,12 @@ export class ChatbotModel {
110
110
  chats: this.cache,
111
111
  })
112
112
 
113
- this.t2s = new ChatTextToSpeechModel({
114
- api: this.api,
115
- $currentChatData: this.$currentChatData,
116
- $isTranslated: this.translation.$isTranslated,
117
- })
118
-
119
113
  this.errorSpotting = new ErrorSpottingModel({
120
114
  $currentChatData: this.$currentChatData,
121
115
  })
122
116
 
117
+ if ($key) this.bindKey($key)
118
+
123
119
  sample({
124
120
  source: this.$isOpen.updates,
125
121
  filter: (isOpen) => !isOpen,
@@ -1,18 +1,29 @@
1
- import { restore, createEvent, createEffect, Store } from 'effector'
1
+ import { restore, createEvent, createEffect, combine } from 'effector'
2
2
  import { AUDIO_CONFIG, DEFAULT_VOICE } from '../constants'
3
3
  import { AudioProvider, AudioStatus, PlayTextToSpeechProps } from '../types/t2s.types'
4
4
  import { ChatbotAPI } from './ChatbotApi'
5
- import { ChatItem } from '../types/units.types'
5
+ import { Timepoint } from '../types/api.types'
6
+ import { textToSSML } from '../helpers/textToSSML'
7
+
8
+ const WORD_MARK_REGEX = /^w_(\d+)$/
9
+
10
+ const findCurrentMark = (timepoints: Timepoint[], time: number): string | undefined => {
11
+ for (let i = timepoints.length - 1; i >= 0; i--) {
12
+ if (timepoints[i].timeSeconds <= time) {
13
+ return timepoints[i].markName
14
+ }
15
+ }
16
+ return undefined
17
+ }
6
18
 
7
19
  type ChatTextToSpeechModelProps = {
8
20
  api: ChatbotAPI
9
- $currentChatData: Store<ChatItem>
10
- $isTranslated: Store<boolean>
11
21
  }
12
22
 
13
23
  export type T2SConfig = {
14
24
  audioProvider?: AudioProvider
15
25
  textSanitizer?: (speech: string) => string
26
+ isHighlightEnabled?: boolean
16
27
  }
17
28
 
18
29
  type AudioConfig = {
@@ -20,34 +31,70 @@ type AudioConfig = {
20
31
  audioConfig?: typeof AUDIO_CONFIG
21
32
  }
22
33
 
34
+ type HighlightCacheEntry = {
35
+ audioPath: string
36
+ timepoints: Timepoint[]
37
+ }
38
+
23
39
  export class ChatTextToSpeechModel {
24
40
  private readonly api: ChatbotAPI
25
41
  private readonly audioCache: Record<string, string> = {}
26
- private readonly $currentChatData
27
- private readonly $isTranslated
42
+ private readonly highlightCache: Record<string, HighlightCacheEntry> = {}
28
43
  private audioProvider: AudioProvider | null = null
44
+ private isHighlightEnabled = false
45
+ private lastHighlightedIndex: number | null = null
29
46
 
30
47
  readonly setCurrentPlayingId = createEvent<string | null>()
31
48
  readonly $currentPlayingId = restore(this.setCurrentPlayingId, null)
32
49
  readonly setStatus = createEvent<AudioStatus>()
33
50
  readonly $status = restore(this.setStatus, AudioStatus.STOPPED)
34
51
 
52
+ readonly setTimepoints = createEvent<Timepoint[]>()
53
+ readonly $timepoints = restore(this.setTimepoints, [])
54
+
55
+ readonly setTime = createEvent<number>()
56
+ readonly $time = restore(this.setTime, 0)
57
+
58
+ readonly $highlightedWordIndex = combine(
59
+ this.$timepoints,
60
+ this.$time,
61
+ this.$status,
62
+ (timepoints, time, status) => {
63
+ if (!timepoints.length || status !== AudioStatus.PLAYING) {
64
+ return null
65
+ }
66
+
67
+ const currentMark = findCurrentMark(timepoints, time)
68
+ if (!currentMark) return null
69
+
70
+ const match = currentMark.match(WORD_MARK_REGEX)
71
+ const index = match ? parseInt(match[1], 10) : null
72
+ // console.log('[TTS Debug] Computed highlightedWordIndex:', index, '(time:', time, ', mark:', currentMark, ')')
73
+ return index
74
+ },
75
+ )
76
+
77
+ readonly $highlightState = combine({
78
+ highlightedWordIndex: this.$highlightedWordIndex,
79
+ playingId: this.$currentPlayingId,
80
+ status: this.$status,
81
+ })
82
+
35
83
  private audioConfig: AudioConfig | null = null
36
84
  private textSanitizer = (value: string) => value
37
85
 
38
- constructor({ api, $currentChatData, $isTranslated }: ChatTextToSpeechModelProps) {
86
+ constructor({ api }: ChatTextToSpeechModelProps) {
39
87
  this.api = api
40
- this.$currentChatData = $currentChatData
41
- this.$isTranslated = $isTranslated
42
88
  }
43
89
 
44
90
  public setAudioConfig(config: AudioConfig | null) {
45
91
  this.audioConfig = config
46
92
  }
47
93
 
48
- public setConfig({ audioProvider, textSanitizer }: T2SConfig) {
94
+ public setConfig({ audioProvider, textSanitizer, isHighlightEnabled }: T2SConfig) {
49
95
  if (audioProvider) this.audioProvider = audioProvider
50
96
  if (textSanitizer) this.textSanitizer = textSanitizer
97
+ if (isHighlightEnabled !== undefined) this.isHighlightEnabled = isHighlightEnabled
51
98
  }
52
99
 
53
100
  private getCacheKey = (messageId: string, isTranslated: boolean) => {
@@ -81,35 +128,70 @@ export class ChatTextToSpeechModel {
81
128
 
82
129
  if (messageId && !isCurrentMessage) {
83
130
  this.audioProvider.stop()
131
+ this.resetHighlight()
84
132
  }
85
133
 
86
134
  const cacheKey = this.getCacheKey(messageId, isTranslated)
87
- let audioPath = this.audioCache[cacheKey]
135
+ const useHighlighting = this.isHighlightEnabled && !!this.audioProvider.onTimeUpdate
136
+ // console.log('[TTS Debug] useHighlighting:', useHighlighting, '(isHighlightEnabled:', this.isHighlightEnabled, ', hasOnTimeUpdate:', !!this.audioProvider.onTimeUpdate, ')')
137
+
138
+ let audioPath: string
139
+ let timepoints: Timepoint[] = []
140
+
88
141
  this.setCurrentPlayingId(messageId)
89
142
 
90
- if (!audioPath) {
91
- const config = this.audioConfig
92
- const response = await this.api.textToSpeechTextFx({
93
- input: { text },
94
- voice: config?.voice ?? DEFAULT_VOICE,
95
- audioConfig: config?.audioConfig ?? AUDIO_CONFIG,
96
- enableTimePointing: [],
97
- })
98
-
99
- // Response might be different type based on provided AUDIO_CONFIG
100
- if (typeof response.audioContent === 'string') {
101
- audioPath = `data:audio/mpeg;base64,${response.audioContent}`
143
+ if (useHighlighting) {
144
+ const cached = this.highlightCache[cacheKey]
145
+ if (cached) {
146
+ audioPath = cached.audioPath
147
+ timepoints = cached.timepoints
102
148
  } else {
103
- audioPath = response?.audioContent ?? ''
149
+ const config = this.audioConfig
150
+ const ssml = textToSSML(text)
151
+ const response = await this.api.textToSpeechTextFx({
152
+ input: { ssml },
153
+ voice: config?.voice ?? DEFAULT_VOICE,
154
+ audioConfig: config?.audioConfig ?? AUDIO_CONFIG,
155
+ enableTimePointing: ['SSML_MARK'],
156
+ })
157
+
158
+ if (typeof response.audioContent === 'string') {
159
+ audioPath = `data:audio/mpeg;base64,${response.audioContent}`
160
+ } else {
161
+ audioPath = response?.audioContent ?? ''
162
+ }
163
+
164
+ timepoints = response.timepoints ?? []
165
+ this.highlightCache[cacheKey] = { audioPath, timepoints }
104
166
  }
105
167
 
106
- this.audioCache[cacheKey] = audioPath
168
+ this.setTimepoints(timepoints)
169
+ // console.log('[TTS Debug] Timepoints set:', timepoints.length, timepoints)
170
+ this.setupTimeTracking()
171
+ } else {
172
+ audioPath = this.audioCache[cacheKey]
173
+ if (!audioPath) {
174
+ const config = this.audioConfig
175
+ const response = await this.api.textToSpeechTextFx({
176
+ input: { text },
177
+ voice: config?.voice ?? DEFAULT_VOICE,
178
+ audioConfig: config?.audioConfig ?? AUDIO_CONFIG,
179
+ enableTimePointing: [],
180
+ })
181
+
182
+ if (typeof response.audioContent === 'string') {
183
+ audioPath = `data:audio/mpeg;base64,${response.audioContent}`
184
+ } else {
185
+ audioPath = response?.audioContent ?? ''
186
+ }
187
+
188
+ this.audioCache[cacheKey] = audioPath
189
+ }
107
190
  }
108
191
 
109
192
  this.setStatus(AudioStatus.PLAYING)
110
193
  await this.audioProvider.play(audioPath, () => {
111
- this.setStatus(AudioStatus.STOPPED)
112
- this.setCurrentPlayingId(null)
194
+ this.onPlaybackComplete()
113
195
  })
114
196
  } catch (error) {
115
197
  this.reset()
@@ -117,12 +199,43 @@ export class ChatTextToSpeechModel {
117
199
  }
118
200
  }
119
201
 
120
- public async playLatest() {
121
- const lastMessage = this.$currentChatData.getState().messages.at(-1)
122
- if (!lastMessage) return
202
+ private setupTimeTracking() {
203
+ if (!this.audioProvider?.onTimeUpdate) {
204
+ // console.log('[TTS Debug] No onTimeUpdate on audioProvider!')
205
+ return
206
+ }
207
+
208
+ // console.log('[TTS Debug] Setting up time tracking')
209
+ this.lastHighlightedIndex = null
210
+
211
+ this.audioProvider.onTimeUpdate((time: number) => {
212
+ const timepoints = this.$timepoints.getState()
213
+ const currentMark = findCurrentMark(timepoints, time)
214
+ if (!currentMark) return
215
+
216
+ const match = currentMark.match(WORD_MARK_REGEX)
217
+ const newIndex = match ? parseInt(match[1], 10) : null
218
+
219
+ // Only update if word index changed
220
+ if (newIndex !== this.lastHighlightedIndex) {
221
+ // console.log('[TTS Debug] Word changed:', this.lastHighlightedIndex, '→', newIndex)
222
+ this.lastHighlightedIndex = newIndex
223
+ this.setTime(time)
224
+ }
225
+ })
226
+ }
227
+
228
+ private onPlaybackComplete = () => {
229
+ this.setStatus(AudioStatus.STOPPED)
230
+ this.setCurrentPlayingId(null)
231
+ this.resetHighlight()
232
+ this.audioProvider?.onTimeUpdate?.(null)
233
+ }
123
234
 
124
- const isTranslated = this.$isTranslated.getState()
125
- return this.play({ message: lastMessage, isTranslated })
235
+ private resetHighlight() {
236
+ this.setTimepoints([])
237
+ this.setTime(0)
238
+ this.lastHighlightedIndex = null
126
239
  }
127
240
 
128
241
  public stop() {
@@ -134,5 +247,7 @@ export class ChatTextToSpeechModel {
134
247
  this.stop()
135
248
  this.setStatus(AudioStatus.STOPPED)
136
249
  this.setCurrentPlayingId(null)
250
+ this.resetHighlight()
251
+ this.audioProvider?.onTimeUpdate?.(null)
137
252
  })
138
253
  }
@@ -71,7 +71,8 @@ export type GoogleTranslateResponse = {
71
71
 
72
72
  export type GoogleText2SpeechPayload = {
73
73
  input: {
74
- text: string
74
+ text?: string
75
+ ssml?: string
75
76
  }
76
77
  voice: {
77
78
  languageCode: string
@@ -1,3 +1,4 @@
1
+ import React from 'react'
1
2
  import { ConversationMessage } from './units.types'
2
3
 
3
4
  export enum AudioStatus {
@@ -11,6 +12,12 @@ export type AudioProvider = {
11
12
  pause: () => void
12
13
  resume: () => void
13
14
  stop: () => void
15
+ onTimeUpdate?: (callback: ((time: number) => void) | null) => void
16
+ getCurrentTime?: () => number
17
+ }
18
+
19
+ export type HighlightConfig = {
20
+ MessageRenderer?: React.ComponentType<{ text: string; highlightedIndex: number | null; colorScheme?: string }>
14
21
  }
15
22
 
16
23
  export type PlayTextToSpeechProps = {
package/src/index.ts CHANGED
@@ -9,5 +9,4 @@ export * from './features/problemSelector'
9
9
  export * from './features/gifCelebrations'
10
10
  export * from './features/exampleSolution'
11
11
  export * from './features/keyboard'
12
- export * from './features/openEnded'
13
12
  export * from './shared/icons'