更新至0.5.0_20251123版本

This commit is contained in:
2025-11-23 22:01:49 +08:00
parent 7487be2bb5
commit b53183e931
5 changed files with 179 additions and 85 deletions

150
App.tsx
View File

@@ -27,31 +27,44 @@ const STORAGE_KEYS = {
HAS_SEEN_ONBOARDING: 'sakura_has_seen_onboarding'
};
// Robust helper for safe JSON parsing
const safeJSONParse = <T,>(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>(AppMode.CHAT);
// Default to 'zh' (Chinese)
const [language, setLanguage] = useState<Language>(() => (localStorage.getItem(STORAGE_KEYS.LANGUAGE) as Language) || 'zh');
const [chatSessions, setChatSessions] = useState<ChatSession[]>(() => {
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<Language>(() => {
// 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<string>(() => localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || '');
const [translationHistory, setTranslationHistory] = useState<TranslationRecord[]>(() => {
const s = localStorage.getItem(STORAGE_KEYS.TRANSLATION_HISTORY); return s ? JSON.parse(s) : [];
});
const [readingHistory, setReadingHistory] = useState<ReadingLessonRecord[]>(() => {
const s = localStorage.getItem(STORAGE_KEYS.READING_HISTORY); return s ? JSON.parse(s) : [];
});
const [listeningHistory, setListeningHistory] = useState<ListeningLessonRecord[]>(() => {
const s = localStorage.getItem(STORAGE_KEYS.LISTENING_HISTORY); return s ? JSON.parse(s) : [];
});
const [ocrHistory, setOcrHistory] = useState<OCRRecord[]>(() => {
const s = localStorage.getItem(STORAGE_KEYS.OCR_HISTORY); return s ? JSON.parse(s) : [];
});
const [selectedModel, setSelectedModel] = useState<string>(() => localStorage.getItem(STORAGE_KEYS.SELECTED_MODEL) || AVAILABLE_CHAT_MODELS[0].id);
// Safe History Initialization
const [chatSessions, setChatSessions] = useState<ChatSession[]>(() => safeJSONParse(STORAGE_KEYS.CHAT_SESSIONS, []));
const [activeSessionId, setActiveSessionId] = useState<string>(() => safeJSONParse(STORAGE_KEYS.ACTIVE_SESSION, ''));
const [translationHistory, setTranslationHistory] = useState<TranslationRecord[]>(() => safeJSONParse(STORAGE_KEYS.TRANSLATION_HISTORY, []));
const [readingHistory, setReadingHistory] = useState<ReadingLessonRecord[]>(() => safeJSONParse(STORAGE_KEYS.READING_HISTORY, []));
const [listeningHistory, setListeningHistory] = useState<ListeningLessonRecord[]>(() => safeJSONParse(STORAGE_KEYS.LISTENING_HISTORY, []));
const [ocrHistory, setOcrHistory] = useState<OCRRecord[]>(() => safeJSONParse(STORAGE_KEYS.OCR_HISTORY, []));
const [selectedModel, setSelectedModel] = useState<string>(() => 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 }));

View File

@@ -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<Props, State> {
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 (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-50 p-6 text-center animate-fade-in">
<div className="bg-white p-8 rounded-3xl shadow-xl max-w-md w-full border border-slate-100 flex flex-col items-center">
<div className="w-20 h-20 bg-red-50 text-red-500 rounded-full flex items-center justify-center mb-6 shadow-sm">
<AlertCircle size={40} />
</div>
<h2 className="text-2xl font-extrabold text-slate-800 mb-2">Something went wrong</h2>
<p className="text-slate-500 mb-8 text-sm leading-relaxed bg-slate-50 p-4 rounded-xl border border-slate-100 w-full font-mono break-all">
{this.state.error?.message || "An unexpected error occurred."}
</p>
<div className="space-y-3 w-full">
<button
onClick={() => window.location.reload()}
className="w-full py-3.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 transition-all active:scale-95 shadow-lg shadow-indigo-200"
>
<RefreshCw size={18} /> Reload App
</button>
<button
onClick={() => {
localStorage.clear();
window.location.reload();
}}
className="w-full py-3.5 bg-white border-2 border-red-100 text-red-500 hover:bg-red-50 hover:border-red-200 rounded-xl font-bold flex items-center justify-center gap-2 transition-all active:scale-95"
>
<Trash2 size={18} /> Clear Data & Reset
</button>
</div>
<p className="text-[10px] text-slate-400 mt-6 font-medium">
* If reloading doesn't work, try clearing data.
</p>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -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(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

Binary file not shown.

View File

@@ -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();
export const geminiService = new GeminiService();