// Query Page — Layer 2: Search/Chat + Resizable/Collapsible Episode Panel const MOCK_EPISODES = [ { id: 'ep142', num: 142, title: '輝達 CUDA 護城河與台積電先進封裝的共生關係', titleEn: 'NVIDIA\'s CUDA Moat and TSMC Advanced Packaging Symbiosis', date: '2026-04-18', duration: '58:24', summary: '本集深度解析輝達 CUDA 生態系統如何形成技術護城河,以及台積電 CoWoS 先進封裝技術在 AI 晶片供應鏈中的不可替代性。專訪台積電前研發副總裁,揭露 2nm 製程量產挑戰。', summaryEn: 'Deep analysis of how NVIDIA\'s CUDA ecosystem forms a technological moat, and the indispensability of TSMC\'s CoWoS advanced packaging in the AI chip supply chain.', transcribed: true }, { id: 'ep141', num: 141, title: '英特爾重組後的殘局:晶圓代工業務存活機率評估', titleEn: 'Intel Post-Restructuring: Assessing Foundry Survival Odds', date: '2026-04-11', duration: '64:10', summary: '英特爾宣布將晶圓代工業務分拆為獨立子公司後,業界對其存活率看法分歧。本集邀請三位半導體分析師進行辯論,並提出關鍵觀察指標。', summaryEn: 'Intel announced the spin-off of its foundry business. Three semiconductor analysts debate its survival odds.', transcribed: true }, { id: 'ep140', num: 140, title: '日本半導體復興計畫:熊本廠之後的下一步', titleEn: 'Japan\'s Semiconductor Revival: What Comes After Kumamoto', date: '2026-04-04', duration: '51:33', summary: '熊本廠量產後,日本政府持續推動半導體自主化。本集分析 Rapidus 2nm 計畫的可行性,以及日本吸引 TSMC 第三廠的籌碼。', summaryEn: 'After the Kumamoto plant, Japan continues its semiconductor independence push.', transcribed: true }, { id: 'ep139', num: 139, title: '亞利桑那廠良率困境與美國供應鏈在地化的現實', titleEn: 'Arizona Yield Struggles and the Reality of US Supply Chain Localization', date: '2026-03-28', duration: '55:48', summary: '台積電亞利桑那廠量產後良率低於台灣廠的問題持續受關注。本集訪談在地工程師,分析人才、文化與製程轉移的多重挑戰。', summaryEn: 'TSMC Arizona yield rates remain below Taiwan fabs post-production ramp.', transcribed: true }, { id: 'ep138', num: 138, title: '中國半導體自主化進展:突破與限制的真實現況', titleEn: 'China Chip Autonomy Progress: Reality Behind the Breakthroughs', date: '2026-03-21', duration: '61:07', summary: '中國 7nm 晶片量產消息持續發酵,但與世界先進水準的差距究竟有多大?本集拆解各方說法,提供有數據支撐的客觀評估。', summaryEn: 'China\'s 7nm chip production news continues to stir debate.', transcribed: true }, { id: 'ep137', num: 137, title: '量子電腦商業化時間表:Google Willow 之後的賽局', titleEn: 'Quantum Computing Timeline: The Race After Google Willow', date: '2026-03-14', duration: '49:22', summary: 'Google Willow 晶片突破後,各大科技公司紛紛加速量子路線圖。', summaryEn: 'After Google Willow\'s breakthrough, major tech companies accelerate quantum roadmaps.', transcribed: false }, ]; const MOCK_CHAT = []; const formatTimestamp = (seconds) => { if (seconds == null || Number.isNaN(seconds)) return '--:--'; const total = Math.floor(seconds); const m = Math.floor(total / 60); const s = total % 60; return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; }; // ── Resizable Episode Panel ── const ResizableLayout = ({ lang, leftContent, rightHeader, rightContent, epCount, epTotal }) => { const t = lang === 'zh'; const containerRef = React.useRef(null); const [panelWidth, setPanelWidth] = React.useState(340); const [collapsed, setCollapsed] = React.useState(false); const [dragging, setDragging] = React.useState(false); const MIN_WIDTH = 200; const MAX_RATIO = 0.45; // max 45% of container const onDragStart = (e) => { e.preventDefault(); setDragging(true); const startX = e.clientX; const startW = panelWidth; const move = (ev) => { if (!containerRef.current) return; const maxW = containerRef.current.offsetWidth * MAX_RATIO; const delta = startX - ev.clientX; // dragging handle leftward = wider panel const newW = Math.min(maxW, Math.max(MIN_WIDTH, startW + delta)); setPanelWidth(newW); }; const up = () => { setDragging(false); window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; return (
{/* Left: Query panel */}
{leftContent}
{/* Drag handle */}
{ if (!dragging) e.currentTarget.style.background = TOKEN.accent + '88'; }} onMouseLeave={e => { if (!dragging) e.currentTarget.style.background = TOKEN.surfaceBorder; }}> {/* Dots on handle */}
{[0,1,2].map(i =>
)}
{/* Right: Episode panel (collapsible) */}
{/* Panel header with collapse toggle */}
{!collapsed && ( <>
{t ? '已轉錄集數' : 'Transcribed Episodes'}
{epCount} / {epTotal} {t ? '集' : 'eps'}
)}
{/* Episode list (hidden when collapsed) */} {!collapsed && (
{rightContent}
)} {/* Collapsed: rotated label */} {collapsed && (
{t ? '集數列表' : 'Episodes'}
)}
); }; // ── Main QueryPage ── const QueryPage = ({ lang, show, onBack, onOpenEpisode, queryMode }) => { const t = lang === 'zh'; const [activeTab, setActiveTab] = React.useState('chat'); const [chatInput, setChatInput] = React.useState(''); const [messages, setMessages] = React.useState(MOCK_CHAT); const [searchQ, setSearchQ] = React.useState(''); const [searching, setSearching] = React.useState(false); const [searchResults, setSearchResults] = React.useState(null); const [selectedEp, setSelectedEp] = React.useState(null); const [sending, setSending] = React.useState(false); const [episodes, setEpisodes] = React.useState(null); const [epError, setEpError] = React.useState(null); const chatEndRef = React.useRef(null); React.useEffect(() => { if (chatEndRef.current) chatEndRef.current.scrollTop = chatEndRef.current.scrollHeight; }, [messages]); React.useEffect(() => { let cancelled = false; setEpisodes(null); setEpError(null); (async () => { try { const res = await fetch(`${API_BASE}/shows/${show.id}/episodes?limit=200`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) setEpisodes(data); } catch (err) { if (!cancelled) setEpError(err.message); } })(); return () => { cancelled = true; }; }, [show.id]); const handleSend = async () => { const question = chatInput.trim(); if (!question || sending) return; const nextHistory = [...messages, { role: 'user', text: question }]; setMessages(nextHistory); setChatInput(''); setSending(true); try { const history = nextHistory .slice(0, -1) .slice(-10) .map(m => ({ role: m.role, content: m.text })); const res = await fetch(`${API_BASE}/shows/${show.id}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'chat', question, messages: history }), }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); setMessages(m => [...m, { role: 'assistant', text: data.answer, citations: data.citations || [] }]); } catch (err) { const msg = t ? `查詢失敗:${err.message}` : `Query failed: ${err.message}`; setMessages(m => [...m, { role: 'assistant', text: msg, citations: [] }]); } finally { setSending(false); } }; const handleSearch = async () => { const question = searchQ.trim(); if (!question || searching) return; setSearching(true); setSearchResults(null); try { const res = await fetch(`${API_BASE}/shows/${show.id}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'search', question }), }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); const mapped = (data.results || []).map(r => ({ epId: r.episode_id, epTitle: r.episode_title, startTime: r.start_time, timestamp: formatTimestamp(r.start_time), text: r.text, })); setSearchResults(mapped); } catch (err) { setSearchResults({ error: err.message }); } finally { setSearching(false); } }; const showChat = queryMode !== 2; const showSearch = queryMode !== 1; const effectiveTabs = [showChat && 'chat', showSearch && 'search'].filter(Boolean); const curTab = effectiveTabs.includes(activeTab) ? activeTab : effectiveTabs[0]; const showName = show.title; const transcribedCount = show.transcribed_count || 0; const showColor = deriveColor(show.id); const epCount = episodes ? episodes.filter(e => e.transcript_status === 'completed').length : transcribedCount; const onCitationClick = (citation) => { onOpenEpisode({ id: citation.episode_id, title: citation.episode_title }, citation.start_time); }; const leftContent = (
{/* Tab bar */} {effectiveTabs.length > 1 && (
{effectiveTabs.map(tab => ( ))}
)} {/* Chat */} {curTab === 'chat' && (
{messages.map((msg, i) => )} {sending && }
setChatInput(e.target.value)} placeholder={t ? '針對此節目內容提問...' : 'Ask anything about this show...'} onKeyDown={e => e.key === 'Enter' && handleSend()} /> {t ? '送出' : 'Send'}

{t ? `RAG 範圍:${transcribedCount} 集逐字稿` : `RAG scope: ${transcribedCount} transcripts`}

)} {/* Search */} {curTab === 'search' && (
setSearchQ(e.target.value)} placeholder={t ? '輸入關鍵字或語意搜尋...' : 'Keyword or semantic search...'} icon="search" onKeyDown={e => e.key === 'Enter' && handleSearch()} /> {t ? '搜尋' : 'Search'}
{searching &&
{t ? '搜尋中...' : 'Searching...'}
} {searchResults && searchResults.error && (
{t ? `搜尋失敗:${searchResults.error}` : `Search failed: ${searchResults.error}`}
)} {Array.isArray(searchResults) && (

{t ? `找到 ${searchResults.length} 個相關片段` : `Found ${searchResults.length} segments`}

{searchResults.map((r, i) => ( ))}
)} {!searching && !searchResults && (

{t ? '輸入關鍵字開始搜尋' : 'Enter a keyword to search'}

)}
)}
); const rightContent = (() => { if (epError) return (
{lang === 'zh' ? `載入失敗:${epError}` : `Load error: ${epError}`}
); if (episodes === null) return (
{lang === 'zh' ? '載入中...' : 'Loading...'}
); return episodes.map(ep => ( ep.transcript_status === 'completed' && (setSelectedEp(ep.id), onOpenEpisode(ep, null))} /> )); })(); return (
{/* Top bar */}
{t ? '返回' : 'Back'}
{showName} {transcribedCount} {t ? '集已轉錄' : 'transcribed'}
); }; // ── Sub-components ── const ChatBubble = ({ msg, lang, onCitationClick }) => { const isUser = msg.role === 'user'; const citations = msg.citations || []; return (
{isUser ? 'U' : 'AI'}
{msg.text}
{citations.length > 0 && (
{citations.map((c, i) => ( onCitationClick && onCitationClick(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', background: TOKEN.bg, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, color: TOKEN.accent, fontSize: 12, fontFamily: 'inherit', cursor: onCitationClick ? 'pointer' : 'default', transition: 'border-color 0.15s' }} onMouseEnter={e => onCitationClick && (e.currentTarget.style.borderColor = TOKEN.accent)} onMouseLeave={e => onCitationClick && (e.currentTarget.style.borderColor = TOKEN.surfaceBorder)}> {(c.episode_title || '').slice(0, 24) || (lang === 'zh' ? '片段' : 'Clip')} @ {formatTimestamp(c.start_time)} ))}
)}
); }; const TypingIndicator = () => (
AI
{[0,1,2].map(i => )}
); const SearchResultCard = ({ result, lang, query }) => { const hi = text => { if (!query) return text; const parts = text.split(new RegExp(`(${query})`, 'gi')); return parts.map((p, i) => p.toLowerCase() === query.toLowerCase() ? {p} : p); }; return (
{result.epTitle} {result.timestamp}

{hi(result.text)}

); }; const EpisodeCard = ({ ep, lang, selected, onClick }) => { const t = lang === 'zh'; const [hovered, setHovered] = React.useState(false); const done = ep.transcript_status === 'completed'; const dateStr = ep.published_at ? ep.published_at.slice(0, 10) : ''; const durStr = ep.duration_seconds != null ? formatTimestamp(ep.duration_seconds) : '--:--'; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ background: selected ? TOKEN.accentDim : hovered && done ? TOKEN.surfaceRaised : 'transparent', border: `1px solid ${selected ? TOKEN.accent + '55' : hovered && done ? TOKEN.surfaceBorder : 'transparent'}`, borderRadius: 10, padding: '10px 12px', cursor: done ? 'pointer' : 'default', opacity: done ? 1 : 0.45, transition: 'all 0.12s', marginBottom: 6 }}>

{ep.title}

{dateStr} {durStr} {done ? {t ? '已轉錄' : 'Done'} : {t ? '待轉錄' : 'Pending'}}
{done && }
); }; Object.assign(window, { QueryPage, MOCK_EPISODES });