更新至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 }));