// PresentationPage — fullscreen 13-slide deck. Chinese-only by design.
// Entry: URL hash #presentation. Exit: Escape (clears hash).
// Reads window.RELEASE_LOG / STATS_* / TAG_LABELS from src/releaseLog.jsx.
const TOTAL_SLIDES = 13;
// Inline case study summaries (3 lines each: 問題 / 轉折 / 學到的事).
// Sourced from docs/case-studies/ but written here as constants — no fetch.
const CASE_STUDIES = [
{
file: 'transcription-queue-discussion.md',
title: '把模糊的「排程」需求拆成 6 個明確決策',
problem: '後端有 ShowSchedule 表存頻率/時間,但專案完全沒有 cron。死資料放了兩週沒人動。',
turn: '使用者沒順 Claude 的技術假設答,而是退回畫出自己的 mental model:節目 → 集數 →(更新清單 / 轉錄)兩個動詞。',
lesson: '討論卡住時,從技術假設退回使用者情境;Claude 主動 challenge 並標推薦,使用者只需 confirm 不用每題從零想。',
},
{
file: 'sync-naming-redesign.md',
title: '「同步」這個詞讓使用者搞不清楚按鈕會做什麼',
problem: '「同步集數」(只抓 RSS)和「同步所有」(會燒 OpenAI 額度)語意混在一起,使用者點下去無法預期會不會花錢。',
turn: '使用者反問:「目前的同步,這件事會去執行什麼動作?」Claude 把所有按鈕的真實行為列表攤開,根因立刻清楚。',
lesson: '一個動詞被掛在性質完全不同的兩件事上時,UX 必然出問題。把名字和行為對照列出,就能找到該分裂的地方。',
},
{
file: 'local-vs-prod-verification-violation.md',
title: '明明寫好不准 local 驗,Claude 還是默默開了 http.server',
problem: '規則寫了「禁止 local 驗證」,Claude 卻自我合理化「我沒用 mock 資料只是繞 CORS」就跑了 python3 -m http.server。',
turn: '使用者沒罵人,只說「我之前好像有提過類似要求」—— 把「不是新規則,是你忘了用」這個訊號傳給 Claude。',
lesson: '規則要寫清楚 why 與「即使 X 也不行」的延伸條款;否則 AI 會字面化解讀並自我合理化。',
},
];
const NEXT_STEPS = [
'使用量統計 Dashboard(token / cost 計價)',
'正式帳號驗證系統(目前是寫死的示範帳密)',
'Pre-built base image(build 時間 10min → 30sec)',
'pg_dump 定期備份',
'API Keys tab 後端連動(目前是 mock)',
];
// Reusable slide chrome — fullscreen card with footer counter & hint.
const SlideShell = ({ slideIndex, children, accent = TOKEN.accent }) => (
{children}
{/* Footer */}
← → 切換 | Space 下一張 | Esc 退出
{slideIndex + 1} / {TOTAL_SLIDES}
);
// ── Slide 0: Cover ──
const SlideCover = () => (
PODCASTRAG · 系統介紹
一個 Podcast RAG 系統的
成長軌跡
從 24 個 archived changes 看見產品如何一塊一塊長出來
);
// ── Slide 1: System intro ──
const SlideIntro = () => (
WHAT IS PODCASTRAG
讓使用者用對話查 Podcast 內容,
不用聽完整集就找得到關鍵段落。
{[
{ icon: 'rss', title: 'RSS → 逐字稿', desc: '貼一個 RSS URL,系統自動抓集數、用 Whisper 轉錄成帶時間戳的逐字稿。' },
{ icon: 'brain', title: '語意檢索 + RAG', desc: '逐字稿切 chunk → embedding → pgvector 搜尋 → LLM 帶引用回答。' },
{ icon: 'play', title: '點擊跳轉時間段', desc: '回答中的引用 Badge 點下去,直接跳到逐字稿對應時間點。' },
].map(item => (
))}
);
// ── Slide 2: Architecture diagram (pure div) ──
const ArchBox = ({ label, sub, color = TOKEN.accent, width = 140 }) => (
);
const ArchArrow = ({ width = 60 }) => (
);
const SlideArchitecture = () => (
ARCHITECTURE
系統架構
部署於 Zeabur + Linode SIN VPS / LLM 走 Zeabur AI Hub(OpenAI-compatible)
);
// ── Slides 3–6: Milestones (data-driven from RELEASE_LOG) ──
const SlideMilestone = ({ milestone }) => {
const entries = RELEASE_LOG
.filter(e => e.milestone === milestone)
.slice()
.sort((a, b) => (a.date < b.date ? -1 : 1));
const label = MILESTONE_LABELS[milestone] ? MILESTONE_LABELS[milestone].zh : milestone;
return (
MILESTONE · {milestone.toUpperCase()}
{label}
{entries[0]?.date} – {entries[entries.length - 1]?.date} | 共 {entries.length} 項更新
{entries.map(e => (
{e.date}
{TAG_LABELS[e.tag].zh}
{e.title.zh}
))}
);
};
// ── Slide 7: Stats ──
const SlideStats = () => {
const [stats, setStats] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
apiFetch('/stats').then(res => {
if (cancelled || !res.ok) return;
return res.json();
}).then(body => {
if (cancelled || !body) return;
setStats(body);
}).catch(() => {});
return () => { cancelled = true; };
}, []);
const epCount = stats ? stats.episodes_completed : STATS_EPISODES_COUNT;
const chunkCount = stats ? stats.transcript_chunks : STATS_VECTORS_COUNT;
const cards = [
{ value: STATS_CHANGES_COUNT, label: 'Archived Changes', sub: '從 4/19 到 5/01' },
{ value: epCount.toLocaleString(), label: '已轉錄集數', sub: '使用 OpenAI Whisper' },
{ value: chunkCount.toLocaleString(), label: '向量索引筆數', sub: 'pgvector 1536-dim' },
];
return (
BY THE NUMBERS
數字成長
截至 {STATS_AS_OF}
{cards.map(c => (
{c.value}
{c.label}
{c.sub}
))}
);
};
// ── Slides 8–10: Case studies ──
const SlideCaseStudy = ({ index }) => {
const cs = CASE_STUDIES[index];
return (
過程心得 · {index + 1} / {CASE_STUDIES.length}
{cs.title}
{[
{ tag: '問題', body: cs.problem, color: TOKEN.danger },
{ tag: '轉折', body: cs.turn, color: TOKEN.warning },
{ tag: '學到的事', body: cs.lesson, color: TOKEN.success },
].map(row => (
))}
docs/case-studies/{cs.file}
);
};
// ── Slide 11: Next steps ──
const SlideNext = () => (
WHAT'S NEXT
接下來
{NEXT_STEPS.map((step, idx) => (
))}
);
// ── Slide 12: Closing ──
const SlideClosing = () => (
謝謝聆聽
PodcastRAG · podcastrag.zeabur.app
"從 24 個小決定堆出來的系統。"
);
// ── Main ──
const PresentationPage = () => {
const [slideIndex, setSlideIndex] = React.useState(0);
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
setSlideIndex(i => Math.min(TOTAL_SLIDES - 1, i + 1));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setSlideIndex(i => Math.max(0, i - 1));
} else if (e.key === 'Escape') {
e.preventDefault();
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
let slide;
if (slideIndex === 0) slide = ;
else if (slideIndex === 1) slide = ;
else if (slideIndex === 2) slide = ;
else if (slideIndex === 3) slide = ;
else if (slideIndex === 4) slide = ;
else if (slideIndex === 5) slide = ;
else if (slideIndex === 6) slide = ;
else if (slideIndex === 7) slide = ;
else if (slideIndex === 8) slide = ;
else if (slideIndex === 9) slide = ;
else if (slideIndex === 10) slide = ;
else if (slideIndex === 11) slide = ;
else slide = ;
return {slide};
};
Object.assign(window, { PresentationPage });