// --- MOCK API and DATA ---
// This section simulates the AI model's responses so the app can run without an API key.
const MOCK_DELAY = 800; // Simulate network delay in milliseconds
const mockData = {
vocabulary: {
text: JSON.stringify([
{ word: 'Algorithm', definition: 'A set of rules for solving a problem in a finite number of steps.', example: 'Search engines use a complex algorithm to rank pages.', pronunciation: 'ˈælɡərɪðəm', translation: 'Algoritma' },
{ word: 'Cloud Computing', definition: 'Using a network of remote servers hosted on the internet to store, manage, and process data.', example: 'We moved our data to the cloud for better accessibility.', pronunciation: 'klaʊd kəmˈpjuːtɪŋ', translation: 'Komputasi awan' },
{ word: 'Encryption', definition: 'The process of converting information into a code to prevent unauthorized access.', example: 'End-to-end encryption keeps your messages private.', pronunciation: 'ɪnˈkrɪpʃn', translation: 'Enkripsi' },
{ word: 'Firewall', definition: 'A network security system that monitors and controls incoming and outgoing network traffic.', example: 'The company\'s firewall blocked the malicious website.', pronunciation: 'ˈfaɪərwɔːl', translation: 'Dinding api' },
{ word: 'User Interface', definition: 'The point of human-computer interaction and communication in a device.', example: 'The application has a clean and intuitive user interface.', pronunciation: 'ˈjuːzər ˈɪntərfeɪs', translation: 'Antarmuka pengguna' },
{ word: 'Bandwidth', definition: 'The maximum rate of data transfer across a given path.', example: 'Streaming high-definition video requires a lot of bandwidth.', pronunciation: 'ˈbændwɪdθ', translation: 'Lebar pita' },
{ word: 'Database', definition: 'An organized collection of structured information, or data, typically stored electronically.', example: 'All customer information is stored in a secure database.', pronunciation: 'ˈdeɪtəbeɪs', translation: 'Basis data' },
{ word: 'Malware', definition: 'Software designed to disrupt, damage, or gain unauthorized access to a computer system.', example: 'Antivirus software helps protect against malware.', pronunciation: 'ˈmælweər', translation: 'Perangkat perusak' },
{ word: 'Software', definition: 'Programs and other operating information used by a computer.', example: 'You need to update your software to the latest version.', pronunciation: 'ˈsɒftweər',translation: 'Perangkat lunak' },
{ word: 'Hardware', definition: 'The physical components of a computer system.', example: 'He is upgrading his computer hardware for better performance.', pronunciation: 'ˈhɑːrdweər', translation: 'Perangkat keras' }
])
},
grammar: {
text: JSON.stringify({
original: "He go to school yesterday.",
corrected: "He went to school yesterday.",
explanation: "Gunakan bentuk lampau (past tense) 'went' karena kalimat ini merujuk pada kejadian di masa lalu ('yesterday'). 'Go' adalah bentuk sekarang (present tense)."
})
},
story: {
text: JSON.stringify({
story: "A curious squirrel named Squeaky lived in a large oak tree. One day, he found a shiny, round object on the ground. It was a lost monocle from a traveler. Squeaky put it over his eye and suddenly, the world looked huge and clear! He could see the tiny veins on a leaf and the small legs of an ant.\nHe used his new 'super eye' to find the best acorns in the forest. All the other squirrels were amazed. They asked him for his secret. Squeaky, being a kind squirrel, shared his monocle. But it was too big for them. Squeaky realized his advantage wasn't just the monocle, but his curiosity to try new things. From that day on, he taught his friends to be more observant and curious about the world around them.",
vocabulary: [
{ word: 'Curious', translation: 'Penasaran' },
{ word: 'Monocle', translation: 'Kacamata berlensa tunggal' },
{ word: 'Veins', translation: 'Urat (daun)' },
{ word: 'Acorns', translation: 'Biji pohon ek' },
{ word: 'Amazed', translation: 'Kagum / Takjub' },
{ word: 'Observant', translation: 'Penuh perhatian' }
]
})
}
};
async function* mockChatStream(message) {
const responses = {
'hello': "Hi there! I'm SobatLingo. What's on your mind today?",
'how are you': "I'm doing great, thank you for asking! Ready to practice some English?",
'default': "That's an interesting point. Could you tell me more about it?"
};
const key = message.toLowerCase().trim();
const responseText = responses[key] || responses['default'];
// Simulate streaming by splitting the response into words
for (const word of responseText.split(' ')) {
await new Promise(resolve => setTimeout(resolve, 60));
yield { text: `${word} ` };
}
}
// A short, silent, base64 encoded WAV file. This prevents errors in the audio decoding logic.
const SILENT_AUDIO_B64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';
const ai = {
models: {
generateContent: (config) => new Promise(resolve => {
setTimeout(() => {
const prompt = config.contents;
if (prompt.includes('vocabulary')) resolve(mockData.vocabulary);
else if (prompt.includes('grammar')) resolve(mockData.grammar);
else if (prompt.includes('story')) resolve(mockData.story);
else resolve({ text: '{}' });
}, MOCK_DELAY);
}),
},
chats: {
create: () => ({
sendMessageStream: ({ message }) => mockChatStream(message),
}),
},
live: {
connect: (config) => new Promise(resolve => {
const mockSession = {
sendRealtimeInput: () => { /* Mock does nothing with input */ },
close: () => {
console.log("Mock session closed.");
config.callbacks.onclose({}); // Pass an empty event object
}
};
setTimeout(() => {
config.callbacks.onopen();
// Simulate a short, pre-scripted conversation
setTimeout(() => {
config.callbacks.onmessage({ serverContent: { inputTranscription: { text: "Hello, can you hear me?" } } });
config.callbacks.onmessage({ serverContent: { turnComplete: true } });
}, 2500);
setTimeout(() => {
config.callbacks.onmessage({ serverContent: { modelTurn: { parts: [{ inlineData: { data: SILENT_AUDIO_B64 } }] } } });
config.callbacks.onmessage({ serverContent: { outputTranscription: { text: "Yes, I can hear you loud and clear! How are you today?" } } });
config.callbacks.onmessage({ serverContent: { turnComplete: true } });
}, 5000);
}, MOCK_DELAY);
resolve(mockSession);
}),
},
};
// --- COMMON ELEMENTS & UTILITIES ---
const createSpinner = () => {
const spinner = document.createElement('div');
spinner.className = "flex justify-center items-center";
spinner.innerHTML = `
`;
return spinner;
};
const displayError = (element, message) => {
element.textContent = message || 'Terjadi kesalahan.';
element.classList.remove('hidden');
};
const hideError = (element) => {
element.classList.add('hidden');
};
// --- HISTORY SERVICE ---
const HISTORY_KEY = 'sobatlingo_conversation_history';
const getHistory = () => {
try {
const historyJson = localStorage.getItem(HISTORY_KEY);
if (!historyJson) return [];
const history = JSON.parse(historyJson);
return history.sort((a, b) => b.id - a.id);
} catch (error) {
console.error("Failed to parse conversation history:", error);
return [];
}
};
const saveSession = (session) => {
try {
if (session.messages.length < 2) return;
const history = getHistory();
const newHistory = [session, ...history.filter(s => s.id !== session.id)];
localStorage.setItem(HISTORY_KEY, JSON.stringify(newHistory));
} catch (error) {
console.error("Failed to save conversation session:", error);
}
};
const clearHistory = () => {
try {
localStorage.removeItem(HISTORY_KEY);
} catch (error) {
console.error("Failed to clear conversation history:", error);
}
};
// --- INITIALIZATION ON DOM LOAD ---
document.addEventListener('DOMContentLoaded', () => {
initTabs();
initVocabularyBuilder();
initGrammarChecker();
initStoryGenerator();
initConversationPractice();
initVoiceChat();
});
// --- TAB MANAGEMENT ---
function initTabs() {
const tabsContainer = document.getElementById('tabs');
const tabButtons = tabsContainer.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabsContainer.addEventListener('click', (event) => {
// Fix for: Property 'closest' does not exist on type 'EventTarget'.
const target = (event.target as HTMLElement).closest('button');
if (!target) return;
const tabId = target.dataset.tab;
tabButtons.forEach(button => {
button.classList.remove('bg-indigo-600', 'text-white', 'shadow-md');
button.classList.add('text-slate-600', 'hover:bg-indigo-100');
});
target.classList.add('bg-indigo-600', 'text-white', 'shadow-md');
target.classList.remove('text-slate-600', 'hover:bg-indigo-100');
tabContents.forEach(content => {
content.classList.add('hidden');
});
document.getElementById(tabId).classList.remove('hidden');
if (tabId === 'riwayat') {
initHistoryViewer();
}
});
}
// --- VOCABULARY BUILDER ---
function initVocabularyBuilder() {
// Fix: Cast elements to their specific types to avoid property errors.
const topicInput = document.getElementById('vocab-topic') as HTMLInputElement;
const generateBtn = document.getElementById('vocab-generate-btn') as HTMLButtonElement;
const resultsContainer = document.getElementById('vocab-results');
const spinner = document.getElementById('vocab-spinner');
const errorContainer = document.getElementById('vocab-error');
const countBadge = document.getElementById('vocab-count');
const speakWord = (word) => {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = 'en-US';
window.speechSynthesis.speak(utterance);
} else {
alert("Maaf, browser Anda tidak mendukung fitur text-to-speech.");
}
};
generateBtn.addEventListener('click', async () => {
const topic = topicInput.value.trim();
if (!topic) {
displayError(errorContainer, 'Silakan masukkan topik.');
return;
}
hideError(errorContainer);
resultsContainer.innerHTML = '';
spinner.innerHTML = '';
spinner.appendChild(createSpinner());
spinner.classList.remove('hidden');
generateBtn.disabled = true;
generateBtn.textContent = 'Membuat...';
try {
const response = await ai.models.generateContent({
contents: `Generate vocabulary for "${topic}"`, // Prompt for mock
});
// Fix: Cast response to access 'text' property.
const vocabulary = JSON.parse((response as { text: string }).text.trim());
countBadge.textContent = `${vocabulary.length} kata ditemukan`;
countBadge.classList.remove('hidden');
vocabulary.forEach(item => {
const card = document.createElement('div');
card.className = "bg-white rounded-xl shadow-md p-6 transition-all duration-300 hover:shadow-lg flex flex-col";
card.innerHTML = `
${item.word}
/${item.pronunciation}/
${item.definition}
(${item.translation})
"${item.example}"
`;
resultsContainer.appendChild(card);
});
// Fix for: Property 'dataset' does not exist on type 'Element'.
resultsContainer.querySelectorAll('.speak-btn').forEach(btn => {
btn.addEventListener('click', () => speakWord(btn.dataset.word));
});
} catch (err) {
displayError(errorContainer, err.message || 'Gagal membuat kosakata.');
} finally {
spinner.classList.add('hidden');
generateBtn.disabled = false;
generateBtn.textContent = 'Buat';
}
});
}
// --- GRAMMAR CHECKER ---
function initGrammarChecker() {
// Fix: Cast elements to their specific types to avoid property errors.
const textInput = document.getElementById('grammar-text') as HTMLTextAreaElement;
const checkBtn = document.getElementById('grammar-check-btn') as HTMLButtonElement;
const resultContainer = document.getElementById('grammar-result');
const spinner = document.getElementById('grammar-spinner');
const errorContainer = document.getElementById('grammar-error');
const countBadge = document.getElementById('grammar-count');
let correctionCount = 0;
checkBtn.addEventListener('click', async () => {
const text = textInput.value.trim();
if (!text) {
displayError(errorContainer, 'Silakan masukkan teks untuk diperiksa.');
return;
}
hideError(errorContainer);
resultContainer.classList.add('hidden');
spinner.innerHTML = '';
spinner.appendChild(createSpinner());
spinner.classList.remove('hidden');
checkBtn.disabled = true;
checkBtn.textContent = 'Memeriksa...';
try {
const response = await ai.models.generateContent({
contents: `Check grammar for: "${text}"`, // Prompt for mock
});
// Fix: Cast response to access 'text' property.
const result = JSON.parse((response as { text: string }).text.trim());
if (result.corrected.toLowerCase() !== result.original.toLowerCase()) {
correctionCount++;
countBadge.textContent = `Total Koreksi: ${correctionCount}`;
countBadge.classList.remove('hidden');
}
resultContainer.innerHTML = `
Teks Asli
${result.original}
Teks yang Diperbaiki
${result.corrected}
Penjelasan
${result.explanation}
`;
resultContainer.classList.remove('hidden');
} catch (err) {
displayError(errorContainer, err.message || 'Gagal memeriksa tata bahasa.');
} finally {
spinner.classList.add('hidden');
checkBtn.disabled = false;
checkBtn.textContent = 'Periksa Tata Bahasa';
}
});
}
// --- STORY GENERATOR ---
function initStoryGenerator() {
// Fix: Cast elements to their specific types to avoid property errors.
const promptInput = document.getElementById('story-prompt') as HTMLInputElement;
const levelSelect = document.getElementById('story-level') as HTMLSelectElement;
const generateBtn = document.getElementById('story-generate-btn') as HTMLButtonElement;
const resultContainer = document.getElementById('story-result');
const spinner = document.getElementById('story-spinner');
const errorContainer = document.getElementById('story-error');
const countBadge = document.getElementById('story-count');
let storyCount = 0;
generateBtn.addEventListener('click', async () => {
const prompt = promptInput.value.trim();
if (!prompt) {
displayError(errorContainer, 'Silakan masukkan ide untuk cerita.');
return;
}
const level = levelSelect.value;
hideError(errorContainer);
resultContainer.classList.add('hidden');
spinner.innerHTML = '';
spinner.appendChild(createSpinner());
spinner.classList.remove('hidden');
generateBtn.disabled = true;
generateBtn.textContent = 'Menulis...';
try {
const response = await ai.models.generateContent({
contents: `Generate story for prompt "${prompt}" at level ${level}`, // Prompt for mock
});
// Fix: Cast response to access 'text' property.
const result = JSON.parse((response as { text: string }).text.trim());
storyCount++;
countBadge.textContent = `Cerita Dibuat: ${storyCount}`;
countBadge.classList.remove('hidden');
const storyParagraphs = result.story.split('\n').map(p => `