From 96e8d44516a8fa29b8e2ffe5565771191cab646b Mon Sep 17 00:00:00 2001 From: Pyae Sone Date: Wed, 2 Sep 2026 15:49:45 +0630 Subject: [PATCH 1/5] done : stt and tts --- src/AppRoutes.ts | 3 + src/assets/index.ts | 8 + src/helpers/index.ts | 1 + src/helpers/socket.helpers.ts | 108 ++++ src/hooks/useSocket.ts | 58 +- src/locales/app_locales.ts | 13 + src/locales/en.json | 13 + src/locales/my.json | 13 + .../admin/components/AdminSidebarNav.tsx | 12 - src/modules/admin/index.ts | 5 - src/modules/ai/ai.controller.ts | 53 +- src/modules/ai/constants.ts | 3 + src/modules/ai/pages/AiPage.tsx | 337 +++++++++-- src/modules/ai/types.ts | 20 + src/modules/speech/constants.ts | 28 + src/modules/speech/hooks/useLiveSpeech.ts | 50 ++ src/modules/speech/index.ts | 5 + src/modules/speech/pcm-processor.js | 48 ++ src/modules/speech/pcm.ts | 98 ++++ src/modules/speech/speech.service.ts | 540 ++++++++++++++++++ src/modules/speech/transcript.ts | 33 ++ src/modules/speech/types.ts | 31 + src/routes/RouteManager.tsx | 6 +- src/services/index.ts | 1 + src/services/socket.service.ts | 205 +++++-- tsconfig.app.tsbuildinfo | 2 +- 26 files changed, 1550 insertions(+), 144 deletions(-) create mode 100644 src/helpers/socket.helpers.ts create mode 100644 src/modules/speech/constants.ts create mode 100644 src/modules/speech/hooks/useLiveSpeech.ts create mode 100644 src/modules/speech/index.ts create mode 100644 src/modules/speech/pcm-processor.js create mode 100644 src/modules/speech/pcm.ts create mode 100644 src/modules/speech/speech.service.ts create mode 100644 src/modules/speech/transcript.ts create mode 100644 src/modules/speech/types.ts diff --git a/src/AppRoutes.ts b/src/AppRoutes.ts index 58b5722b..57d486da 100644 --- a/src/AppRoutes.ts +++ b/src/AppRoutes.ts @@ -134,6 +134,9 @@ class AppRoutes { AI_TRANSLATE: AppRoutes.api("/ai/translate"), // POST AI_ANALYZE: AppRoutes.api("/ai/analyze"), // POST + // Speech + SPEECH_TTS: AppRoutes.api("/speech/tts"), // POST + // Feedback FEEDBACKS: AppRoutes.api("/feedbacks"), // GET diff --git a/src/assets/index.ts b/src/assets/index.ts index 4d54f87e..abb23e85 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -75,6 +75,10 @@ import { ChartBarIcon, ShieldCheckIcon, ClockIcon, + MicrophoneIcon, + StopIcon, + SpeakerWaveIcon, + PlayIcon, } from "@heroicons/react/24/outline"; // Videos @@ -198,4 +202,8 @@ export const iconsLib = { chartBar: ChartBarIcon, shieldCheck: ShieldCheckIcon, clock: ClockIcon, + microphone: MicrophoneIcon, + stop: StopIcon, + speaker: SpeakerWaveIcon, + play: PlayIcon, } as const; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 70a217f4..d8fa9b32 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,2 +1,3 @@ export * from "./jwt.helper.ts"; +export * from "./socket.helpers.ts"; export * from "../design/helpers"; diff --git a/src/helpers/socket.helpers.ts b/src/helpers/socket.helpers.ts new file mode 100644 index 00000000..366d89ae --- /dev/null +++ b/src/helpers/socket.helpers.ts @@ -0,0 +1,108 @@ +export type ISocketMessage = { + type: string; + message?: string; + data?: Record; + created_at?: string; + channel?: string; +}; + +export const SOCKET_CHANNELS = { + NOTIFICATION: "NotificationChannel", + SPEECH_LIVE: "SpeechLiveChannel", +} as const; + +export const SPEECH_EVENT_TYPES = { + PARTIAL: "partial", + FINAL: "final", + ERROR: "error", +} as const; + +export const SOCKET_SUBSCRIBE_TIMEOUT_MS = 10_000; +export const SOCKET_CONNECT_WAIT_MS = 5_000; + +export const NOTIFICATION_TOAST_SUCCESS_TYPES = [ + "payment_success", + "subscription_created", + "subscription_resumed", + "welcome", + "ai_response_ready", + "tts_ready", +] as const; + +export const NOTIFICATION_TOAST_ERROR_TYPES = [ + "payment_failed", + "subscription_canceled", + "ai_response_failed", + "tts_failed", +] as const; + +export type TSocketToastKind = "success" | "error" | "info" | "none"; + +export function parseCableChannel(identifier: unknown): string { + if (identifier && typeof identifier === "object") { + const channel = (identifier as { channel?: unknown }).channel; + return typeof channel === "string" ? channel : ""; + } + + if (typeof identifier !== "string" || identifier.trim() === "") { + return ""; + } + + try { + const parsed = JSON.parse(identifier) as { channel?: unknown }; + return typeof parsed.channel === "string" ? parsed.channel : ""; + } catch { + return ""; + } +} + +export function getSpeechEventType(data: ISocketMessage): string { + return typeof data.data?.type === "string" ? data.data.type : ""; +} + +export function isSpeechLiveMessage(data: ISocketMessage): boolean { + if (data.channel === SOCKET_CHANNELS.SPEECH_LIVE) { + return true; + } + + const eventType = getSpeechEventType(data); + return ( + eventType === SPEECH_EVENT_TYPES.PARTIAL || + eventType === SPEECH_EVENT_TYPES.FINAL || + eventType === SPEECH_EVENT_TYPES.ERROR + ); +} + +export function getSocketToast(data: ISocketMessage): { + kind: TSocketToastKind; + message: string; +} { + const message = data.message?.trim() ?? ""; + + if (isSpeechLiveMessage(data)) { + if (getSpeechEventType(data) === SPEECH_EVENT_TYPES.ERROR && message) { + return { kind: "error", message }; + } + return { kind: "none", message: "" }; + } + + if (data.type !== "notification" || !message) { + return { kind: "none", message: "" }; + } + + const eventType = getSpeechEventType(data); + + if ( + (NOTIFICATION_TOAST_SUCCESS_TYPES as readonly string[]).includes(eventType) + ) { + return { kind: "success", message }; + } + + if ( + (NOTIFICATION_TOAST_ERROR_TYPES as readonly string[]).includes(eventType) + ) { + return { kind: "error", message }; + } + + return { kind: "info", message }; +} diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index 09be4276..e4dcb07c 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useToast } from "../contexts/ToastContext"; +import { getSocketToast } from "../helpers/socket.helpers"; import SocketService, { ISocketMessage } from "../services/socket.service"; export interface INotification { @@ -23,44 +24,33 @@ export const useSocket = () => { } const handleNotification = (data: ISocketMessage) => { - if (data.type === "notification") { - if (!data.message) { - return; - } + const toast = getSocketToast(data); + if (toast.kind === "none") { + return; + } - const notif: INotification = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - message: data.message, - data: data.data || {}, - created_at: data.created_at || new Date().toISOString(), - }; - setNotifications((prev) => { - // Prevent duplicates - if (prev.some((n) => n.id === notif.id)) return prev; - return [notif, ...prev]; - }); + const notif: INotification = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 5), + message: toast.message, + data: data.data || {}, + created_at: data.created_at || new Date().toISOString(), + }; + setNotifications((prev) => { + if (prev.some((n) => n.id === notif.id)) return prev; + return [notif, ...prev]; + }); - const type = typeof notif.data.type === "string" ? notif.data.type : "general"; + if (toast.kind === "success") { + success(toast.message); + return; + } - switch (type) { - case "payment_success": - case "subscription_created": - case "subscription_resumed": - case "welcome": - case "ai_response_ready": - success(notif.message); - break; - case "payment_failed": - case "subscription_canceled": - case "ai_response_failed": - error(notif.message); - break; - case "sign_in_alert": - default: - info(notif.message); - break; - } + if (toast.kind === "error") { + error(toast.message); + return; } + + info(toast.message); }; SocketService.addListener(handleNotification); diff --git a/src/locales/app_locales.ts b/src/locales/app_locales.ts index 95a4bd57..ec6ee39b 100644 --- a/src/locales/app_locales.ts +++ b/src/locales/app_locales.ts @@ -212,10 +212,23 @@ export const AppLocales = { Title: "ai.title", Rooms: "ai.rooms", NewChat: "ai.new_chat", + DefaultGreeting: "ai.default_greeting", TypeMessage: "ai.type_message", Send: "ai.send", Processing: "ai.processing", ClearHistory: "ai.clear_history", + ClearHistoryConfirm: "ai.clear_history_confirm", + Listen: "ai.listen", + Thinking: "ai.thinking", + CancelListening: "ai.cancel_listening", + MicPermissionTitle: "ai.mic_permission_title", + MicPermissionMessage: "ai.mic_permission_message", + OpenSettings: "ai.open_settings", + StartRecordingFailed: "ai.ai_start_recording_failed", + TranscriptionFailed: "ai.ai_transcription_failed", + SendMessageFailed: "ai.ai_send_message_failed", + TtsFailed: "ai.ai_tts_failed", + TtsEmpty: "ai.ai_tts_empty", Errors: { Unexpected: "ai.errors.unexpected", LoadRooms: "ai.errors.load_rooms", diff --git a/src/locales/en.json b/src/locales/en.json index b5086fa1..7a0bb1cb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -51,6 +51,19 @@ "send": "Send", "processing": "AI is thinking...", "clear_history": "Clear History", + "clear_history_confirm": "All messages in this conversation will be permanently deleted. Are you sure you want to clear chat history?", + "default_greeting": "Hello! I'm your AI assistant. How can I help you today?", + "listen": "Listen", + "thinking": "AI is thinking", + "cancel_listening": "Cancel listening", + "mic_permission_title": "Microphone access required", + "mic_permission_message": "Voice input needs microphone access. Open Settings to enable it for this app.", + "open_settings": "Open Settings", + "ai_start_recording_failed": "Failed to start recording", + "ai_transcription_failed": "Failed to transcribe audio", + "ai_send_message_failed": "Failed to send message", + "ai_tts_failed": "Failed to play speech", + "ai_tts_empty": "Nothing to speak", "errors": { "unexpected": "An error occurred. Please try again.", "load_rooms": "Failed to load rooms", diff --git a/src/locales/my.json b/src/locales/my.json index 5c66fe0a..f7383bd7 100644 --- a/src/locales/my.json +++ b/src/locales/my.json @@ -51,6 +51,19 @@ "send": "ပေးပို့မည်", "processing": "AI စဉ်းစားနေပါသည်...", "clear_history": "မှတ်တမ်း ရှင်းလင်းမည်", + "clear_history_confirm": "ဤစကားဝိုင်းရှိ မက်ဆေ့ဂျ်အားလုံးကို အပြီးအပိုင် ဖျက်မည်။ မှတ်တမ်းကို ရှင်းလင်းရန် သေချာပါသလား။", + "default_greeting": "မင်္ဂလာပါ! ကျွန်တော်သည် သင်၏ AI လက်ထောက် ဖြစ်ပါသည်။ ဘာများ ကူညီပေးရမလဲ?", + "listen": "နားထောင်မည်", + "thinking": "AI စဉ်းစားနေသည်", + "cancel_listening": "နားထောင်ခြင်း ရပ်တန့်မည်", + "mic_permission_title": "မိုက်ခရိုဖုန်း ခွင့်ပြုချက် လိုအပ်သည်", + "mic_permission_message": "အသံဖြင့် ရိုက်ထည့်ရန် မိုက်ခရိုဖုန်း ခွင့်ပြုချက် လိုအပ်ပါသည်။ Settings မှ ဖွင့်ပေးပါ။", + "open_settings": "Settings ဖွင့်ရန်", + "ai_start_recording_failed": "အသံဖမ်းခြင်း စတင်၍ မရပါ", + "ai_transcription_failed": "အသံကို စာသားမပြောင်း၍ ရပါ", + "ai_send_message_failed": "မက်ဆေ့ဂျ် ပို့၍ မရပါ", + "ai_tts_failed": "အသံဖွင့်၍ မရပါ", + "ai_tts_empty": "ဖွင့်ရန် စာသားမရှိပါ", "errors": { "unexpected": "အမှားဖြစ်ပွားခဲ့ပါသည်။ ထပ်မံကြိုးစားပါ။", "load_rooms": "စကားပြောခန်းများကို ရယူ၍ မရပါ", diff --git a/src/modules/admin/components/AdminSidebarNav.tsx b/src/modules/admin/components/AdminSidebarNav.tsx index 929d3b68..250d51f5 100644 --- a/src/modules/admin/components/AdminSidebarNav.tsx +++ b/src/modules/admin/components/AdminSidebarNav.tsx @@ -120,18 +120,6 @@ const navSections: IAdminNavSection[] = [ }, ], }, - { - id: "observability", - label: ADMIN_NAV_SECTION_LABELS.OBSERVABILITY, - items: [ - { - label: ADMIN_NAV_LABELS.LOGS, - to: AppRoutes.client.protected.admin.LOGS, - resource: ADMIN_RESOURCES.CLIENTS, - icon: iconsLib.document, - }, - ], - }, ]; export const AdminSidebarNav: React.FC = ({ diff --git a/src/modules/admin/index.ts b/src/modules/admin/index.ts index 63d4c665..127e3ae9 100644 --- a/src/modules/admin/index.ts +++ b/src/modules/admin/index.ts @@ -9,8 +9,6 @@ import AdminAccessesController from "./accesses/accesses.controller"; import AdminAccessesService from "./accesses/accesses.service"; import AdminFeedbackController from "./feedback/feedback.controller"; import AdminFeedbackService from "./feedback/feedback.service"; -import AdminLogsController from "./logs/logs.controller"; -import AdminLogsService from "./logs/logs.service"; export * from "./analytics"; export * from "./chat"; @@ -18,7 +16,6 @@ export * from "./notifications"; export * from "./products"; export * from "./accesses"; export * from "./feedback"; -export * from "./logs"; export * from "./roles"; export * from "./users"; export * from "./components"; @@ -42,6 +39,4 @@ export const Admin = { AdminAccessesService, AdminFeedbackController, AdminFeedbackService, - AdminLogsController, - AdminLogsService, }; diff --git a/src/modules/ai/ai.controller.ts b/src/modules/ai/ai.controller.ts index 758af6d7..98a5672b 100644 --- a/src/modules/ai/ai.controller.ts +++ b/src/modules/ai/ai.controller.ts @@ -5,11 +5,18 @@ import { IMessage, IRoom } from "./types"; import SocketService, { ISocketMessage } from "../../services/socket.service"; import { IApiPagination } from "../../models"; import { AppLocales, translate } from "../../locales"; -import { AI_DEFAULTS, AI_MESSAGE_STATUS, AI_SOCKET_EVENTS } from "./constants"; - -const AI_RESPONSE_EVENT_TYPES: readonly string[] = [ +import { + AI_DEFAULTS, + AI_MESSAGE_STATUS, + AI_SOCKET_EVENTS, +} from "./constants"; +import SpeechService from "../speech/speech.service"; + +const AI_SOCKET_EVENT_TYPES: readonly string[] = [ AI_SOCKET_EVENTS.RESPONSE_READY, AI_SOCKET_EVENTS.RESPONSE_FAILED, + AI_SOCKET_EVENTS.TTS_READY, + AI_SOCKET_EVENTS.TTS_FAILED, ]; const AI_PROCESSING_MESSAGE_STATUSES: readonly string[] = [ @@ -29,26 +36,54 @@ class AiController { return this.currentRoomId; } - subscribeToAiMessages(callback: () => void): () => void { + subscribeToAiMessages( + callback: (eventType: string) => void, + ): () => void { const handleAiMessage = (event: ISocketMessage) => { - const eventType = typeof event.data?.type === "string" ? event.data.type : ""; - const roomId = typeof event.data?.room_id === "string" ? event.data.room_id : ""; + const eventType = + typeof event.data?.type === "string" ? event.data.type : ""; + const roomId = + typeof event.data?.room_id === "string" ? event.data.room_id : ""; if ( event.type !== "notification" || - !AI_RESPONSE_EVENT_TYPES.includes(eventType) || - roomId !== this.currentRoomId + !AI_SOCKET_EVENT_TYPES.includes(eventType) ) { return; } - callback(); + if (roomId && this.currentRoomId && roomId !== this.currentRoomId) { + return; + } + + callback(eventType); }; SocketService.addListener(handleAiMessage); return () => SocketService.removeListener(handleAiMessage); } + async queueTextToSpeech(messageId: string): Promise<{ + success: boolean; + message?: string; + error?: string; + }> { + const result = await SpeechService.textToSpeech(messageId); + + if (result.success) { + return { + success: true, + message: result.message, + }; + } + + return { + success: false, + error: + result.error || translate(AppLocales.Ai.TtsFailed), + }; + } + async getRooms(params?: { page?: number; limit?: number }): Promise<{ success: boolean; rooms: IRoom[]; diff --git a/src/modules/ai/constants.ts b/src/modules/ai/constants.ts index f0fdbb56..342b4200 100644 --- a/src/modules/ai/constants.ts +++ b/src/modules/ai/constants.ts @@ -22,8 +22,11 @@ export type TAiMessageStatus = export const AI_SOCKET_EVENTS = { RESPONSE_READY: "ai_response_ready", RESPONSE_FAILED: "ai_response_failed", + TTS_READY: "tts_ready", + TTS_FAILED: "tts_failed", } as const; + export const AI_DEFAULTS = { TEMPERATURE: 0.7, MAX_TOKENS: 2000, diff --git a/src/modules/ai/pages/AiPage.tsx b/src/modules/ai/pages/AiPage.tsx index 744356df..20774089 100644 --- a/src/modules/ai/pages/AiPage.tsx +++ b/src/modules/ai/pages/AiPage.tsx @@ -1,19 +1,41 @@ -// src/design/pages/AiPage.tsx - import React, { useState, useRef, useEffect, useCallback } from "react"; import { Button, TextArea, ConfirmDialog } from "../../../design/components"; import { useToast, useLoading } from "../../../contexts"; +import { AppLocales, useTranslate } from "../../../locales"; +import { iconsLib } from "../../../assets"; import AiController from "../ai.controller"; -import { IMessage } from ".."; +import { AI_SOCKET_EVENTS, getMessageAudioUrl, IMessage } from ".."; +import { + SPEECH_LISTEN_RESULTS, + useLiveSpeech, +} from "../../speech"; export const AiPage: React.FC = () => { - const { success, error } = useToast(); + const t = useTranslate(); + const { success, error, info } = useToast(); const { isLoading, setLoading } = useLoading(); + const { + liveText, + voiceLevel, + isListenSessionActive, + isPlaying, + startListening, + stopListening, + playUrl, + stopPlayback, + } = useLiveSpeech(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isProcessing, setIsProcessing] = useState(false); const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); + const [isMicDialogOpen, setIsMicDialogOpen] = useState(false); + const [activeTtsMessageId, setActiveTtsMessageId] = useState( + null, + ); + const [playingMessageId, setPlayingMessageId] = useState(null); + const textBeforeListenRef = useRef(""); const messagesEndRef = useRef(null); + const greeting = t(AppLocales.Ai.DefaultGreeting); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -28,7 +50,7 @@ export const AiPage: React.FC = () => { { id: "welcome", role: "assistant", - content: "Hello! I'm your AI assistant. How can I help you today?", + content: greeting, created_at: new Date().toISOString(), }, ]); @@ -42,13 +64,13 @@ export const AiPage: React.FC = () => { { id: "welcome", role: "assistant", - content: "Hello! I'm your AI assistant. How can I help you today?", + content: greeting, created_at: new Date().toISOString(), }, ]); setIsProcessing(false); } - }, []); + }, [greeting]); useEffect(() => { scrollToBottom(); @@ -63,14 +85,172 @@ export const AiPage: React.FC = () => { }, [loadHistory]); useEffect(() => { - const unsubscribe = AiController.subscribeToAiMessages(() => { - loadHistory(); + const unsubscribe = AiController.subscribeToAiMessages((eventType) => { + void loadHistory(); + if ( + eventType === AI_SOCKET_EVENTS.TTS_READY || + eventType === AI_SOCKET_EVENTS.TTS_FAILED + ) { + setActiveTtsMessageId(null); + } }); return unsubscribe; }, [loadHistory]); + useEffect(() => { + if (!isPlaying) { + setPlayingMessageId(null); + } + }, [isPlaying]); + + useEffect(() => { + if (isListenSessionActive) { + setInput(liveText); + } + }, [isListenSessionActive, liveText]); + + const handleListenResult = (result: string) => { + if ( + result === SPEECH_LISTEN_RESULTS.STARTED || + result === SPEECH_LISTEN_RESULTS.ALREADY_LISTENING + ) { + return; + } + + if (result === SPEECH_LISTEN_RESULTS.PERMISSION_DENIED) { + setIsMicDialogOpen(true); + return; + } + + if (result === SPEECH_LISTEN_RESULTS.DISCONNECTED) { + error(t(AppLocales.Ai.TranscriptionFailed)); + return; + } + + error(t(AppLocales.Ai.StartRecordingFailed)); + }; + + const handleStartListening = async () => { + if (isLoading || isProcessing || isListenSessionActive) { + return; + } + + textBeforeListenRef.current = input; + const result = await startListening({ seed: input }); + handleListenResult(result); + }; + + const handleStopListening = async () => { + await stopListening(); + }; + + const handleCancelListening = async () => { + await stopListening(); + setInput(textBeforeListenRef.current); + }; + + const handleMicPermissionConfirm = async () => { + setIsMicDialogOpen(false); + const result = await startListening({ seed: textBeforeListenRef.current }); + handleListenResult(result); + }; + + const isTtsDisabledForMessage = (messageId: string) => { + if (isListenSessionActive) { + return true; + } + + return Boolean( + activeTtsMessageId && activeTtsMessageId !== messageId, + ); + }; + + const handleTtsTap = async (message: IMessage) => { + if (isTtsDisabledForMessage(message.id)) { + return; + } + + if (playingMessageId === message.id && isPlaying) { + stopPlayback(); + setPlayingMessageId(null); + return; + } + + stopPlayback(); + setPlayingMessageId(null); + + const audioUrl = getMessageAudioUrl(message); + if (audioUrl) { + try { + await playUrl(audioUrl); + setPlayingMessageId(message.id); + } catch { + error(t(AppLocales.Ai.TtsFailed)); + } + return; + } + + if (!message.content.trim()) { + error(t(AppLocales.Ai.TtsEmpty)); + return; + } + + setActiveTtsMessageId(message.id); + const result = await AiController.queueTextToSpeech(message.id); + + if (result.success) { + if (result.message) { + info(result.message); + } + return; + } + + setActiveTtsMessageId(null); + error(result.error || t(AppLocales.Ai.TtsFailed)); + }; + + const renderTtsControl = (message: IMessage) => { + if (message.role !== "assistant" || !message.content.trim()) { + return null; + } + + const isQueued = activeTtsMessageId === message.id; + const isThisPlaying = playingMessageId === message.id && isPlaying; + const hasAudio = Boolean(getMessageAudioUrl(message)); + const disabled = isTtsDisabledForMessage(message.id); + + if (isQueued) { + return ( + + ); + } + + let Icon = iconsLib.speaker; + if (isThisPlaying) { + Icon = iconsLib.stop; + } else if (hasAudio) { + Icon = iconsLib.play; + } + + return ( + + ); + }; + const handleSend = async () => { - if (!input.trim() || isLoading || isProcessing) return; + if (!input.trim() || isLoading || isProcessing || isListenSessionActive) { + return; + } const content = input.trim(); setInput(""); @@ -84,22 +264,27 @@ export const AiPage: React.FC = () => { result.message!, ]); setIsProcessing(true); - success("Message sent. AI is thinking while you continue browsing."); + success(t(AppLocales.Ai.Processing)); } else { setInput(content); - error(result.error || "Failed to get AI response"); + error(result.error || t(AppLocales.Ai.SendMessageFailed)); } setLoading(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (isListenSessionActive) { + e.preventDefault(); + return; + } + if ( (e.key === "Enter" && !e.shiftKey) || ((e.ctrlKey || e.metaKey) && e.key === "Enter") ) { e.preventDefault(); - handleSend(); + void handleSend(); } }; @@ -108,31 +293,29 @@ export const AiPage: React.FC = () => { const result = await AiController.clearHistory(null); if (result.success) { - success("Chat history cleared"); + success(t(AppLocales.Ai.ClearHistory)); setMessages([ { id: "welcome", role: "assistant", - content: "Hello! I'm your AI assistant. How can I help you today?", + content: greeting, created_at: new Date().toISOString(), }, ]); } else { - error(result.error || "Failed to clear history"); + error(result.error || t(AppLocales.Ai.Errors.ClearHistory)); } }; + const canListen = !isLoading && !isProcessing && !isListenSessionActive; + return ( <>
- {/* Header */}
-

🤖 AI Assistant

-

- Powered by DeepSeek AI -

+

{t(AppLocales.Ai.Title)}

{messages.length > 1 && ( @@ -141,13 +324,12 @@ export const AiPage: React.FC = () => { size="sm" onClick={() => setIsClearDialogOpen(true)} > - Clear + {t(AppLocales.Ai.ClearHistory)} )}
- {/* Messages */}
{messages.map((message) => (
{ {message.metadata?.status === "failed" && ( - AI could not complete this message. You can try again. + {t(AppLocales.Ai.Errors.GetResponse)} )} + {renderTtsControl(message)}
))} @@ -181,7 +364,7 @@ export const AiPage: React.FC = () => { {isProcessing && (
-

AI is thinking…

+

{t(AppLocales.Ai.Thinking)}

@@ -190,7 +373,6 @@ export const AiPage: React.FC = () => {
- {/* Input */}