// --- SVG Icons --- const IconMic = ({size=24, className=""}) => ; const IconSquare = ({size=24, className=""}) => ; const IconFileText = ({size=24, className=""}) => ; const IconLoader2 = ({size=24, className=""}) => ; const IconRefreshCw = ({size=24, className=""}) => ; const IconAlertCircle = ({size=24, className=""}) => ; const IconCopy = ({size=24, className=""}) => ; const IconCheck = ({size=24, className=""}) => ; const IconBookOpen = ({size=24, className=""}) => ; const IconActivity = ({size=24, className=""}) => ; // ========================================== // 部署配置區塊 // ========================================== const BACKEND_DOMAIN = "presnetation.onrender.com"; const BACKEND_REST_URL = `https://${BACKEND_DOMAIN}`; const BACKEND_WS_URL = `wss://${BACKEND_DOMAIN}/ws`; // ========================================== function bufferToBase64(buffer) { let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return window.btoa(binary); } const fetchWithRetry = async (url, options, retries = 5) => { let delay = 1000; for (let i = 0; i < retries; i++) { try { const response = await fetch(url, options); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); } catch (error) { if (i === retries - 1) throw error; await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; } } }; function App() { const [isLocalFileWarning, setIsLocalFileWarning] = useState(false); const [error, setError] = useState(null); const [recordingMode, setRecordingMode] = useState('local'); // 只有 'local' 模式 const [localLang, setLocalLang] = useState('zh-HK'); const [localInterim, setLocalInterim] = useState(''); const recognitionRef = useRef(null); const isRecordingRef = useRef(false); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [segments, setSegments] = useState([]); const [summary, setSummary] = useState(''); const [isSummarizing, setIsSummarizing] = useState(false); // 新增文章參考功能 const [articleTitle, setArticleTitle] = useState(''); const [articleContent, setArticleContent] = useState(''); const [showArticleInput, setShowArticleInput] = useState(false); const [copiedOriginal, setCopiedOriginal] = useState(false); const [copiedTrans, setCopiedTrans] = useState(false); const [copiedSummary, setCopiedSummary] = useState(false); const origEndRef = useRef(null); const transEndRef = useRef(null); useEffect(() => { if (window.location.protocol === 'file:') setIsLocalFileWarning(true); }, []); useEffect(() => { if (origEndRef.current) origEndRef.current.scrollIntoView({ behavior: 'smooth' }); if (transEndRef.current) transEndRef.current.scrollIntoView({ behavior: 'smooth' }); }, [segments, localInterim]); useEffect(() => { let interval; if (isRecording) { interval = setInterval(() => setRecordingTime((prev) => prev + 1), 1000); } else { clearInterval(interval); } return () => clearInterval(interval); }, [isRecording]); const formatTime = (seconds) => { const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; const copyToClipboard = (text, type) => { if (!text) return; const textArea = document.createElement("textarea"); textArea.value = text; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); if (type === 'orig') { setCopiedOriginal(true); setTimeout(() => setCopiedOriginal(false), 2000); } else if (type === 'trans') { setCopiedTrans(true); setTimeout(() => setCopiedTrans(false), 2000); } else { setCopiedSummary(true); setTimeout(() => setCopiedSummary(false), 2000); } } catch (err) { setError('複製失敗。'); } document.body.removeChild(textArea); }; // ========================================== // 模式 1:本地瀏覽器語音轉文字 (免 API) // ========================================== const startLocalRecording = () => { setError(null); const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { setError("您的瀏覽器不支援內建語音辨識。建議您使用 Chrome 瀏覽器,或切換回「AI 雲端連線」模式。"); return; } if (segments.length === 0) setSummary(''); setLocalInterim(''); const recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = localLang; recognition.onstart = () => { setIsRecording(true); isRecordingRef.current = true; if (segments.length === 0) setRecordingTime(0); }; recognition.onresult = async (event) => { let interimTrans = ''; for (let i = event.resultIndex; i < event.results.length; ++i) { if (event.results[i].isFinal) { const text = event.results[i][0].transcript + ','; // 自動進行書面語轉譯 try { const translatedText = await translateText(text); setSegments(prev => [...prev, { id: Date.now() + i, orig: text, trans: translatedText }]); } catch (error) { console.error('轉譯失敗:', error); setSegments(prev => [...prev, { id: Date.now() + i, orig: text, trans: "(轉譯失敗)" }]); } } else { interimTrans += event.results[i][0].transcript; } } setLocalInterim(interimTrans); }; recognition.onerror = (event) => { if (event.error !== 'no-speech') { console.error('Local recognition error:', event.error); if (event.error === 'not-allowed') { setError("麥克風權限被拒絕,請允許瀏覽器存取麥克風。"); } else { setError(`語音辨識發生錯誤: ${event.error}`); } } }; recognition.onend = () => { if (isRecordingRef.current) { try { recognition.start(); } catch(e) {} } }; recognitionRef.current = recognition; try { recognition.start(); } catch (e) { setError("無法啟動語音辨識引擎。"); } }; // 總開關 const startRecording = () => { startLocalRecording(); }; try { audioCtx = new AudioContextClass({ sampleRate: 16000 }); } catch (e) { audioCtx = new AudioContextClass(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } audioCtxRef.current = audioCtx; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); streamRef.current = stream; if (audioCtx.state === 'suspended') { await audioCtx.resume(); } const ws = new WebSocket(BACKEND_WS_URL); wsRef.current = ws; ws.onopen = () => { setWsStatus('connected'); setIsRecording(true); if (segments.length === 0) setRecordingTime(0); ws.send(JSON.stringify({ setup: { generationConfig: { responseModalities: ["TEXT"] }, systemInstruction: { parts: [{ text: "你是一個即時聽寫與翻譯助理。請仔細聆聽使用者的語音(可能是粵語或普通話)。每次使用者說完一段話停頓時,請你嚴格按照以下格式輸出:\n[原音逐字稿]\n|||\n[規範現代漢語翻譯]\n\n請務必使用 ||| 作為分隔符。絕對不要有任何問候語、解釋或其他廢話。" }] } } })); const source = audioCtxRef.current.createMediaStreamSource(stream); const processor = audioCtxRef.current.createScriptProcessor(4096, 1, 1); processorRef.current = processor; processor.onaudioprocess = (e) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const inputData = e.inputBuffer.getChannelData(0); const pcm16 = new Int16Array(inputData.length); for (let i = 0; i < inputData.length; i++) { let s = Math.max(-1, Math.min(1, inputData[i])); pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } const base64 = bufferToBase64(pcm16.buffer); ws.send(JSON.stringify({ realtimeInput: { mediaChunks: [{ mimeType: "audio/pcm;rate=16000", data: base64 }] } })); } }; source.connect(processor); processor.connect(audioCtxRef.current.destination); }; ws.onmessage = (e) => { const res = JSON.parse(e.data); // 🔥 新增:攔截後端傳來的詳細錯誤診斷 if (res.serverError) { setError(`【連線失敗詳細診斷】\n${res.serverError}`); setWsStatus('disconnected'); setIsRecording(false); if (wsRef.current) wsRef.current.close(); return; } if (res.serverContent && res.serverContent.modelTurn) { const text = res.serverContent.modelTurn.parts.map(p => p.text).join(''); currentLiveTextRef.current += text; setLiveText(currentLiveTextRef.current); } if (res.serverContent && res.serverContent.turnComplete) { processTurn(currentLiveTextRef.current); currentLiveTextRef.current = ''; setLiveText(''); } }; ws.onerror = (e) => { console.error('WebSocket Error:', e); }; ws.onclose = (event) => { // 如果錯誤已經由 serverError 捕捉,就不需要重複顯示 if (error && error.includes('【連線失敗詳細診斷】')) return; const isInstantCrash = (Date.now() - connectStartTime.current) < 3000; setWsStatus('disconnected'); setIsRecording(false); if (processorRef.current) processorRef.current.disconnect(); if (streamRef.current) streamRef.current.getTracks().forEach(track => track.stop()); if (isInstantCrash) { setError(`與後端瞬間斷線。\n請檢查後端日誌(Logs)或確認模型與金鑰設定。`); } else if (event.code !== 1000 && event.code !== 1005) { setError(`與後端連線中斷 (代碼: ${event.code})。`); } }; } catch (err) { console.error("啟動錄音失敗:", err); setWsStatus('disconnected'); setIsRecording(false); if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { setError("請允許瀏覽器使用麥克風權限 (iPhone 請務必使用 Safari 瀏覽器)。"); } else { setError(`無法存取設備或啟動音訊: ${err.name} - ${err.message}`); } } }; // 總開關 const startRecording = () => { startLocalRecording(); }; const stopRecording = async () => { if (!isRecording) return; setIsRecording(false); isRecordingRef.current = false; if (recognitionRef.current) { recognitionRef.current.stop(); } if (localInterim) { try { const translatedText = await translateText(localInterim + ','); setSegments(prev => [...prev, { id: Date.now(), orig: localInterim + ',', trans: translatedText }]); } catch (error) { console.error('轉譯失敗:', error); setSegments(prev => [...prev, { id: Date.now(), orig: localInterim + ',', trans: "(轉譯失敗)" }]); } setLocalInterim(''); } // 結束後自動產生摘要 const fullOrigText = segments.map(s => s.orig).join('') + localInterim; if (fullOrigText.trim().length > 0) { generateSummary(fullOrigText); } }; const fullOrigText = segments.map(s => s.orig).join('') + (currentLiveTextRef.current.split('|||')[0] || ''); if (fullOrigText.trim().length > 0) { generateSummary(fullOrigText); } } }; const clearData = () => { setSegments([]); setLocalInterim(''); setSummary(''); setError(null); setRecordingTime(0); setArticleTitle(''); setArticleContent(''); }; const translateText = async (text) => { try { const url = `${BACKEND_REST_URL}/api/process-text`; const payload = { text: text, articleTitle: articleTitle, articleContent: articleContent, mode: 'translate' }; const result = await fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (result.error) throw new Error(result.error); const translatedText = result.candidates?.[0]?.content?.parts?.[0]?.text; return translatedText || text; } catch (err) { console.error('轉譯 API 錯誤:', err); return text; // 如果轉譯失敗,返回原文 } }; const generateSummary = async (textToSummarize) => { setIsSummarizing(true); setSummary(''); setError(null); try { const url = `${BACKEND_REST_URL}/api/process-text`; const payload = { text: textToSummarize, articleTitle: articleTitle, articleContent: articleContent, mode: 'summary' }; const result = await fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (result.error) throw new Error(result.error); const generatedText = result.candidates?.[0]?.content?.parts?.[0]?.text; if (generatedText) setSummary(generatedText); else throw new Error("API 未回傳有效內容"); } catch (err) { setError(`生成摘要錯誤: ${err.message}`); } finally { setIsSummarizing(false); } }; const fullOriginalText = segments.map(s => s.orig).join(','); const fullTransText = segments.map(s => s.trans).join(','); return (

語音摘錄

使用瀏覽器內建語音辨識,結合 AI 智慧轉譯,輸出正式書面語與摘要

{isLocalFileWarning && (

本地檔案執行警告

錄音功能需要麥克風權限。請將此網站部署至 Cloudflare Pages 或使用 Local Server 開啟。

)} {error && (

{error}

)}
{/* 文章參考輸入區塊 */}
{showArticleInput && (
setArticleTitle(e.target.value)} disabled={isRecording} className="bg-white border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2 outline-none" />