更新至0.5.0_20251123版本
This commit is contained in:
150
App.tsx
150
App.tsx
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user