diff --git a/App.tsx b/App.tsx index cbf1b92..20e0cbd 100644 --- a/App.tsx +++ b/App.tsx @@ -27,31 +27,44 @@ const STORAGE_KEYS = { HAS_SEEN_ONBOARDING: 'sakura_has_seen_onboarding' }; +// Robust helper for safe JSON parsing +const safeJSONParse = (key: string, fallback: T): T => { + try { + const item = localStorage.getItem(key); + if (!item) return fallback; + const parsed = JSON.parse(item); + + // Validate Array type consistency if fallback is an array + if (Array.isArray(fallback)) { + return Array.isArray(parsed) ? (parsed as unknown as T) : fallback; + } + return parsed as T; + } catch (e) { + console.warn(`Failed to load ${key}, falling back to default.`, e); + return fallback; + } +}; + const App: React.FC = () => { const [currentView, setCurrentView] = useState(AppMode.CHAT); - // Default to 'zh' (Chinese) - const [language, setLanguage] = useState(() => (localStorage.getItem(STORAGE_KEYS.LANGUAGE) as Language) || 'zh'); - const [chatSessions, setChatSessions] = useState(() => { - try { - const stored = localStorage.getItem(STORAGE_KEYS.CHAT_SESSIONS); - if (stored) return JSON.parse(stored); - } catch (e) { console.error("Failed to load chat sessions", e); } - return []; + + // Safe Language Initialization + const [language, setLanguage] = useState(() => { + // Cast to string to prevent TS from inferring the literal type 'zh' from fallback + const lang = safeJSONParse(STORAGE_KEYS.LANGUAGE, 'zh') as string; + if (lang === 'en' || lang === 'ja' || lang === 'zh') return lang as Language; + return 'zh'; }); - const [activeSessionId, setActiveSessionId] = useState(() => localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || ''); - const [translationHistory, setTranslationHistory] = useState(() => { - const s = localStorage.getItem(STORAGE_KEYS.TRANSLATION_HISTORY); return s ? JSON.parse(s) : []; - }); - const [readingHistory, setReadingHistory] = useState(() => { - const s = localStorage.getItem(STORAGE_KEYS.READING_HISTORY); return s ? JSON.parse(s) : []; - }); - const [listeningHistory, setListeningHistory] = useState(() => { - const s = localStorage.getItem(STORAGE_KEYS.LISTENING_HISTORY); return s ? JSON.parse(s) : []; - }); - const [ocrHistory, setOcrHistory] = useState(() => { - const s = localStorage.getItem(STORAGE_KEYS.OCR_HISTORY); return s ? JSON.parse(s) : []; - }); - const [selectedModel, setSelectedModel] = useState(() => localStorage.getItem(STORAGE_KEYS.SELECTED_MODEL) || AVAILABLE_CHAT_MODELS[0].id); + + // Safe History Initialization + const [chatSessions, setChatSessions] = useState(() => safeJSONParse(STORAGE_KEYS.CHAT_SESSIONS, [])); + const [activeSessionId, setActiveSessionId] = useState(() => safeJSONParse(STORAGE_KEYS.ACTIVE_SESSION, '')); + const [translationHistory, setTranslationHistory] = useState(() => safeJSONParse(STORAGE_KEYS.TRANSLATION_HISTORY, [])); + const [readingHistory, setReadingHistory] = useState(() => safeJSONParse(STORAGE_KEYS.READING_HISTORY, [])); + const [listeningHistory, setListeningHistory] = useState(() => safeJSONParse(STORAGE_KEYS.LISTENING_HISTORY, [])); + const [ocrHistory, setOcrHistory] = useState(() => safeJSONParse(STORAGE_KEYS.OCR_HISTORY, [])); + + const [selectedModel, setSelectedModel] = useState(() => safeJSONParse(STORAGE_KEYS.SELECTED_MODEL, AVAILABLE_CHAT_MODELS[0].id)); const [hasSeenOnboarding, setHasSeenOnboarding] = useState(() => !!localStorage.getItem(STORAGE_KEYS.HAS_SEEN_ONBOARDING)); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -65,7 +78,8 @@ const App: React.FC = () => { isOpen: false, title: '', message: '', onConfirm: () => {} }); - const t = translations[language]; + // Ensure 't' is always valid even if language switch glitches + const t = translations[language] || translations['zh']; // Safe Storage Helper to prevent white screen on QuotaExceededError const saveToStorage = (key: string, data: any) => { @@ -73,45 +87,30 @@ const App: React.FC = () => { const json = JSON.stringify(data); localStorage.setItem(key, json); } catch (e: any) { - // Check for QuotaExceededError if (e.name === 'QuotaExceededError' || e.message?.includes('exceeded the quota')) { console.warn(`Storage quota exceeded for key: ${key}`); - addToast('error', translations[language].common.storageFull); + addToast('error', t.common.storageFull); - // Attempt to recover by stripping heavy data (e.g., audioUrl from chats, base64 images from OCR) + // Strategy: Try to strip audioUrl/imagePreview from bulky items if (key === STORAGE_KEYS.CHAT_SESSIONS && Array.isArray(data)) { try { - // Create a lightweight version by removing audioUrl base64 strings const leanData = (data as ChatSession[]).map(s => ({ ...s, messages: s.messages.map(m => ({ ...m, - metadata: { - ...m.metadata, - audioUrl: undefined // Remove cached audio to save space - } + metadata: { ...m.metadata, audioUrl: undefined } // Remove audio cache })) })); localStorage.setItem(key, JSON.stringify(leanData)); - addToast('info', translations[language].common.storageOptimized); - } catch (retryError) { - console.error("Failed to save even lean chat data", retryError); - } + addToast('info', t.common.storageOptimized); + } catch (err) {} } else if (key === STORAGE_KEYS.OCR_HISTORY && Array.isArray(data)) { try { - // Create lightweight version by removing imagePreview - const leanData = (data as OCRRecord[]).map(r => ({ - ...r, - imagePreview: '' // Remove heavy base64 image - })); + const leanData = (data as OCRRecord[]).map(r => ({ ...r, imagePreview: '' })); // Remove image localStorage.setItem(key, JSON.stringify(leanData)); - addToast('info', translations[language].common.storageOptimized); - } catch (retryError) { - console.error("Failed to save even lean OCR data", retryError); - } + addToast('info', t.common.storageOptimized); + } catch (err) {} } - } else { - console.error("LocalStorage save failed", e); } } }; @@ -126,11 +125,14 @@ const App: React.FC = () => { useEffect(() => { saveToStorage(STORAGE_KEYS.SELECTED_MODEL, selectedModel); }, [selectedModel]); useEffect(() => { - const activeSession = chatSessions.find(s => s.id === activeSessionId); - if (activeSession && activeSession.messages.length === 1 && activeSession.messages[0].role === Role.MODEL) { - const newWelcome = translations[language].chat.welcome; - const newTitle = translations[language].chat.newChat; - setChatSessions(prev => prev.map(s => s.id === activeSessionId ? { ...s, title: newTitle, messages: [{ ...s.messages[0], content: newWelcome }] } : s)); + // Only update welcome message if session is empty/default + if (activeSessionId) { + const activeSession = chatSessions.find(s => s.id === activeSessionId); + if (activeSession && activeSession.messages.length === 1 && activeSession.messages[0].role === Role.MODEL) { + const newWelcome = t.chat.welcome; + const newTitle = t.chat.newChat; + setChatSessions(prev => prev.map(s => s.id === activeSessionId ? { ...s, title: newTitle, messages: [{ ...s.messages[0], content: newWelcome }] } : s)); + } } }, [language]); @@ -149,7 +151,7 @@ const App: React.FC = () => { if (!storedKey && (!envKey || envKey.length === 0)) { setIsSettingsOpen(true); - setTimeout(() => addToast('info', translations[language].settings.apiKeyMissing), 500); + setTimeout(() => addToast('info', t.settings.apiKeyMissing), 500); } hasInitialized.current = true; } @@ -157,8 +159,8 @@ const App: React.FC = () => { const createNewSession = () => { const newId = Date.now().toString(); - const welcomeMsg: ChatMessage = { id: 'welcome', role: Role.MODEL, type: MessageType.TEXT, content: translations[language].chat.welcome, timestamp: Date.now() }; - setChatSessions(prev => [{ id: newId, title: translations[language].chat.newChat, messages: [welcomeMsg], createdAt: Date.now(), updatedAt: Date.now() }, ...prev]); + const welcomeMsg: ChatMessage = { id: 'welcome', role: Role.MODEL, type: MessageType.TEXT, content: t.chat.welcome, timestamp: Date.now() }; + setChatSessions(prev => [{ id: newId, title: t.chat.newChat, messages: [welcomeMsg], createdAt: Date.now(), updatedAt: Date.now() }, ...prev]); setActiveSessionId(newId); }; @@ -187,8 +189,8 @@ const App: React.FC = () => { const deleteSession = (sessionId: string) => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].chat.deleteConfirm, + title: t.common.confirm, + message: t.chat.deleteConfirm, onConfirm: () => { const remaining = chatSessions.filter(s => s.id !== sessionId); setChatSessions(remaining); @@ -204,8 +206,8 @@ const App: React.FC = () => { const clearAllChatSessions = () => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.clearHistoryConfirm, + title: t.common.confirm, + message: t.common.clearHistoryConfirm, onConfirm: () => { setChatSessions([]); createNewSession(); @@ -217,8 +219,8 @@ const App: React.FC = () => { const deleteReadingLesson = (id: string) => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.deleteItemConfirm, + title: t.common.confirm, + message: t.common.deleteItemConfirm, onConfirm: () => { setReadingHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -229,8 +231,8 @@ const App: React.FC = () => { const clearReadingHistory = () => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.clearHistoryConfirm, + title: t.common.confirm, + message: t.common.clearHistoryConfirm, onConfirm: () => { setReadingHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -241,8 +243,8 @@ const App: React.FC = () => { const deleteListeningLesson = (id: string) => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.deleteItemConfirm, + title: t.common.confirm, + message: t.common.deleteItemConfirm, onConfirm: () => { setListeningHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -253,8 +255,8 @@ const App: React.FC = () => { const clearListeningHistory = () => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.clearHistoryConfirm, + title: t.common.confirm, + message: t.common.clearHistoryConfirm, onConfirm: () => { setListeningHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -265,8 +267,8 @@ const App: React.FC = () => { const deleteOCRRecord = (id: string) => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.deleteItemConfirm, + title: t.common.confirm, + message: t.common.deleteItemConfirm, onConfirm: () => { setOcrHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -277,8 +279,8 @@ const App: React.FC = () => { const clearOCRHistory = () => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.clearHistoryConfirm, + title: t.common.confirm, + message: t.common.clearHistoryConfirm, onConfirm: () => { setOcrHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -289,8 +291,8 @@ const App: React.FC = () => { const deleteTranslationRecord = (id: string) => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.deleteItemConfirm, + title: t.common.confirm, + message: t.common.deleteItemConfirm, onConfirm: () => { setTranslationHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); @@ -301,8 +303,8 @@ const App: React.FC = () => { const clearTranslationHistory = () => { setConfirmState({ isOpen: true, - title: translations[language].common.confirm, - message: translations[language].common.clearHistoryConfirm, + title: t.common.confirm, + message: t.common.clearHistoryConfirm, onConfirm: () => { setTranslationHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1830c7b --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,70 @@ + +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { AlertCircle, RefreshCw, Trash2 } from 'lucide-react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+

Something went wrong

+

+ {this.state.error?.message || "An unexpected error occurred."} +

+ +
+ + + +
+ +

+ * If reloading doesn't work, try clearing data. +

+
+
+ ); + } + + return this.props.children; + } +} diff --git a/index.tsx b/index.tsx index cbffc8d..4e1d92d 100644 --- a/index.tsx +++ b/index.tsx @@ -1,6 +1,8 @@ + import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { ErrorBoundary } from './components/ErrorBoundary'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -10,7 +12,9 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + ); diff --git a/releases/HTY1024-APP-SKR-0.5.0_20251123.zip b/releases/HTY1024-APP-SKR-0.5.0_20251123.zip new file mode 100644 index 0000000..0bddf35 Binary files /dev/null and b/releases/HTY1024-APP-SKR-0.5.0_20251123.zip differ diff --git a/services/geminiService.ts b/services/geminiService.ts index 47fc995..f196ec6 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,5 +1,4 @@ - import { GoogleGenAI, Modality, Type } from "@google/genai"; import { PronunciationFeedback, Language, ReadingLesson, ReadingDifficulty, OCRAnalysis, ListeningLesson } from "../types"; import { base64ToUint8Array, uint8ArrayToBase64 } from "../utils/audioUtils"; @@ -63,7 +62,7 @@ const LANGUAGE_MAP = { class GeminiService { private getAi() { const userKey = localStorage.getItem(USER_API_KEY_STORAGE); - const userBaseUrl = localStorage.getItem(USER_BASE_URL_STORAGE); + let userBaseUrl = localStorage.getItem(USER_BASE_URL_STORAGE); const envKey = process.env.API_KEY; const keyToUse = (userKey && userKey.trim().length > 0) ? userKey : envKey; @@ -73,8 +72,11 @@ class GeminiService { } const config: any = { apiKey: keyToUse }; + if (userBaseUrl && userBaseUrl.trim().length > 0) { - config.baseUrl = userBaseUrl.trim(); + // Sanitize Base URL: remove quotes and trailing slashes + let cleanUrl = userBaseUrl.trim().replace(/['"]/g, '').replace(/\/+$/, ''); + config.baseUrl = cleanUrl; } return new GoogleGenAI(config); @@ -92,11 +94,19 @@ class GeminiService { try { return await operation(); } catch (error: any) { + const errorMsg = error?.message || ''; + + // Check for Network/CORS/Proxy errors specifically + if (errorMsg.includes('Failed to fetch') || errorMsg.includes('NetworkError')) { + console.error("Network Error Detected:", error); + throw new Error("Network connection failed. Please check your Base URL (Proxy) settings or internet connection."); + } + const isOverloaded = error?.status === 503 || error?.response?.status === 503 || - error?.message?.includes('503') || - error?.message?.includes('overloaded'); + errorMsg.includes('503') || + errorMsg.includes('overloaded'); if (isOverloaded && retries > 0) { console.warn(`Model overloaded (503). Retrying...`); @@ -118,10 +128,14 @@ class GeminiService { ): Promise<{ text: string, model: string }> { const ai = this.getAi(); + // Ensure model name is clean let modelName = useThinking ? 'gemini-3-pro-preview' : (imageBase64 ? 'gemini-3-pro-preview' : (modelOverride || 'gemini-2.5-flash')); + // Extra safety: strip quotes just in case + modelName = modelName.replace(/['"]/g, ''); + const targetLangName = LANGUAGE_MAP[language]; const parts: any[] = []; @@ -191,7 +205,7 @@ class GeminiService { return response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data || null; } catch (e) { console.error("TTS Chunk Error", e); - return null; + throw e; // Throw to retryOperation to handle network errors } }); } @@ -203,7 +217,11 @@ class GeminiService { // If text is short, process directly if (text.length <= MAX_CHUNK_LENGTH) { - return this._generateSpeechChunk(text); + try { + return await this._generateSpeechChunk(text); + } catch (e) { + return null; + } } // Split text into chunks by sentence to avoid breaking words @@ -284,7 +302,7 @@ class GeminiService { return bytes ? `data:image/jpeg;base64,${bytes}` : null; } catch (e) { console.error("Image Gen Error", e); - return null; + throw e; } }); } @@ -310,7 +328,7 @@ class GeminiService { return null; } catch (e) { console.error("Image Edit Error", e); - return null; + throw e; } }); } @@ -540,4 +558,4 @@ class GeminiService { } } -export const geminiService = new GeminiService(); \ No newline at end of file +export const geminiService = new GeminiService();