Paramjit Singh commited on
Commit
cb1048d
·
unverified ·
2 Parent(s): 5eaf1b5c3e5824

Merge pull request #307 from A-adilajaleel/feat/speech-synthesis

Browse files
frontend/src/components/chat/MessageBubble.tsx CHANGED
@@ -1,12 +1,12 @@
1
  "use client";
2
 
3
- import { useState, useRef } from "react";
4
  import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
  import { api } from "@/lib/api";
9
- import { Brain, User, Copy, Check, Share2, Link2, X } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
@@ -41,7 +41,6 @@ const markdownComponents: Components = {
41
  ),
42
  code: ({ className, children, ...props }) => {
43
  const language = /language-(\w+)/.exec(className ?? "")?.[1];
44
-
45
  return (
46
  <code className={className} data-language={language} {...props}>
47
  {children}
@@ -55,8 +54,19 @@ export default function MessageBubble({ message }: Props) {
55
  const [copied, setCopied] = useState(false);
56
  const [shared, setShared] = useState(false);
57
  const [shareFailed, setShareFailed] = useState(false);
 
58
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
  const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
 
 
 
 
 
 
 
 
60
 
61
  const handleCopy = async () => {
62
  if (!message.content) return;
@@ -72,7 +82,6 @@ export default function MessageBubble({ message }: Props) {
72
 
73
  const handleShare = async () => {
74
  if (!message.content || message.isStreaming) return;
75
-
76
  try {
77
  const data = await api.post<{ message_id: string; share_url: string }>(
78
  `/api/v1/chat/share/${message.id}`
@@ -95,6 +104,34 @@ export default function MessageBubble({ message }: Props) {
95
  }
96
  };
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return (
99
  <div
100
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
@@ -118,6 +155,7 @@ export default function MessageBubble({ message }: Props) {
118
  <>
119
  {message.content && (
120
  <>
 
121
  {!message.isStreaming && (
122
  <Button
123
  type="button"
@@ -140,6 +178,8 @@ export default function MessageBubble({ message }: Props) {
140
  )}
141
  </Button>
142
  )}
 
 
143
  <Button
144
  type="button"
145
  variant="ghost"
@@ -169,7 +209,8 @@ export default function MessageBubble({ message }: Props) {
169
  )}
170
  </>
171
  )}
172
- <div className={`prose-chat text-sm ${message.content ? "pr-14" : ""}`}>
 
173
  {message.content ? (
174
  <ReactMarkdown
175
  remarkPlugins={[remarkGfm]}
@@ -200,4 +241,4 @@ export default function MessageBubble({ message }: Props) {
200
  )}
201
  </div>
202
  );
203
- }
 
1
  "use client";
2
 
3
+ import { useState, useRef, useEffect } from "react";
4
  import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
  import { api } from "@/lib/api";
9
+ import { Brain, User, Copy, Check, Share2, Link2, X, Play, Pause } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
 
41
  ),
42
  code: ({ className, children, ...props }) => {
43
  const language = /language-(\w+)/.exec(className ?? "")?.[1];
 
44
  return (
45
  <code className={className} data-language={language} {...props}>
46
  {children}
 
54
  const [copied, setCopied] = useState(false);
55
  const [shared, setShared] = useState(false);
56
  const [shareFailed, setShareFailed] = useState(false);
57
+ const [isSpeaking, setIsSpeaking] = useState(false);
58
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
  const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+ const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
61
+
62
+ // Component unmount ആകുമ്പോൾ speech cancel ചെയ്യും
63
+ useEffect(() => {
64
+ return () => {
65
+ if (utteranceRef.current) {
66
+ window.speechSynthesis.cancel();
67
+ }
68
+ };
69
+ }, []);
70
 
71
  const handleCopy = async () => {
72
  if (!message.content) return;
 
82
 
83
  const handleShare = async () => {
84
  if (!message.content || message.isStreaming) return;
 
85
  try {
86
  const data = await api.post<{ message_id: string; share_url: string }>(
87
  `/api/v1/chat/share/${message.id}`
 
104
  }
105
  };
106
 
107
+ const handleSpeech = () => {
108
+ if (!message.content || message.isStreaming) return;
109
+
110
+ // Already speaking — cancel ചെയ്യും
111
+ if (isSpeaking) {
112
+ window.speechSynthesis.cancel();
113
+ setIsSpeaking(false);
114
+ utteranceRef.current = null;
115
+ return;
116
+ }
117
+
118
+ const utterance = new SpeechSynthesisUtterance(message.content);
119
+ utteranceRef.current = utterance;
120
+
121
+ utterance.onend = () => {
122
+ setIsSpeaking(false);
123
+ utteranceRef.current = null;
124
+ };
125
+ utterance.onerror = () => {
126
+ setIsSpeaking(false);
127
+ utteranceRef.current = null;
128
+ };
129
+
130
+ // Chrome bug fix: onstart reliable അല്ല, speak() മുൻപ് set ചെയ്യണം
131
+ setIsSpeaking(true);
132
+ window.speechSynthesis.speak(utterance);
133
+ };
134
+
135
  return (
136
  <div
137
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
 
155
  <>
156
  {message.content && (
157
  <>
158
+ {/* Share button */}
159
  {!message.isStreaming && (
160
  <Button
161
  type="button"
 
178
  )}
179
  </Button>
180
  )}
181
+
182
+ {/* Copy button */}
183
  <Button
184
  type="button"
185
  variant="ghost"
 
209
  )}
210
  </>
211
  )}
212
+
213
+ <div className={`prose-chat text-sm ${message.content ? "pr-20" : ""}`}>
214
  {message.content ? (
215
  <ReactMarkdown
216
  remarkPlugins={[remarkGfm]}
 
241
  )}
242
  </div>
243
  );
244
+ }