Created
August 28, 2026 01:42
-
-
Save EmbraceLife/a8cde2c5d59432425de937dfc27b0f7b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """The main fasthtml app of solveblog | |
| Docs: https://AnswerDotAI.github.io/solveblog/app.html.md""" | |
| # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_app.ipynb. | |
| # %% auto #0 | |
| __all__ = ['cfg', 'db', 'subscribers', 'FootnoteRef', 'YoutubeEmbed', 'sidenote_css', 'cell_css', 'toc_css', 'mobile_toc_css', | |
| 'AUDIO_CSS', 'annotation_js', 'AUDIO_JS', 'hdrs', 'app', 'rt', 'KOKORO_VERSION', 'KOKORO_CHINESE_RE', | |
| 'socials', 'ftr_content', 'pages', 'posts', 'search_index', 'Subscriber', 'nb2md', 'nb2md_sections', | |
| 'span_token', 'extract_footnotes', 'extract_headings', 'toc_sidebar', 'tts_settings_ui', 'ContentRenderer', | |
| 'from_md', 'tts_proxy', 'kokoro_proxy', 'theme_toggle', 'x_icon', 'social_link', 'read', 'hx_attrs', | |
| 'hx_link', 'navbar', 'layout', 'subscribe_form', 'Post', 'build_search_index', 'tag_label', 'tag_link', | |
| 'tag_toggle', 'tag_filter', 'post_intro', 'post_preview', 'post_article', 'render_article_sections', 'index', | |
| 'blog', 'blogpost', 'subscribe', 'rss_feed'] | |
| # %% ../nbs/01_app.ipynb #37a647b9 | |
| from fastcore.nbio import * | |
| from fastcore.utils import * | |
| import json, os, re, uuid, asyncio, mistletoe as mst | |
| from functools import lru_cache,cache | |
| from collections import Counter | |
| from dateutil.parser import parse as parse_date | |
| from fastcore.ansi import ansi2html | |
| from html import escape | |
| from datetime import datetime, date | |
| from fasthtml.common import * | |
| from fastlite import * | |
| from monsterui.all import * | |
| from urllib.parse import quote, unquote | |
| from . import load_cfg | |
| # %% ../nbs/01_app.ipynb #fc5c182c | |
| cfg = load_cfg() | |
| cfg.dom = 'https://'+json.loads(os.environ['PUBLIC_DOMAINS'])[str(cfg.port)] | |
| # %% ../nbs/01_app.ipynb #62bef3df | |
| db = database("subscribers.db") | |
| class Subscriber: id:int; email:str; created_at:str | |
| subscribers = db.create(Subscriber, transform=True) | |
| # %% ../nbs/01_app.ipynb #b03da416 | |
| def nb2md(path): | |
| "Read notebook, return frontmatter Post object (like frontmatter.load for .md files)" | |
| cells = json.loads(Path(path).read_text()).get('cells', []) | |
| fm_src, md_cells = '', cells | |
| if cells and cells[0]['cell_type'] == 'raw': | |
| fm_src = ''.join(cells[0]['source']) | |
| md_cells = cells[1:] | |
| def cell_md(c): | |
| src = ''.join(c['source']) | |
| if c.get('metadata', {}).get('pinned'): return None | |
| if c['cell_type'] == 'code': | |
| code = f'<pre><code class="language-python">{escape(src)}</code></pre>' | |
| if outs := render_outputs(c.get('outputs', [])): return f'<div class="cell">{code}<div class="cell-output">{outs}</div></div>' | |
| return f'<div class="cell">{code}</div>' | |
| if c['cell_type'] == 'raw': return f'```\n{src}\n```' | |
| if c['cell_type'] == 'markdown': | |
| if atts := c.get('attachments', {}): | |
| for fname, mimes in atts.items(): | |
| mime, data = next(iter(mimes.items())) | |
| src = src.replace(f'attachment:{fname}', f'data:{mime};base64,{data}') | |
| if c.get('metadata', {}).get('solveit_ai'): | |
| sep = re.split(r'##### 🤖Reply🤖<!--.*?-->\n*', src, maxsplit=1) | |
| prompt = sep[0].strip() | |
| if len(sep) > 1 and sep[1].strip(): | |
| response = sep[1].strip() | |
| return f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div><div class="ai-response"><span class="ai-label">AI</span>\n\n{response}\n\n</div></div>' | |
| return f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div></div>' | |
| return src | |
| rendered = [r for r in (cell_md(c) for c in md_cells) if r is not None] | |
| md = (rendered[0] + '\n\n<!--INTRO-->\n\n' + '\n\n'.join(rendered[1:])) if len(rendered) > 1 else '\n\n'.join(rendered) | |
| return frontmatter(f"{fm_src}\n\n{md}") if fm_src else ({}, md) | |
| # %% ../nbs/01_app.ipynb #1874cd5c | |
| def nb2md_sections(path): | |
| "Read notebook, return (frontmatter, list of (cell_id, text_or_html, cell_type))" | |
| cells = json.loads(Path(path).read_text()).get('cells', []) | |
| fm_src, md_cells = '', cells | |
| if cells and cells[0]['cell_type'] == 'raw': | |
| fm_src = ''.join(cells[0]['source']) | |
| md_cells = cells[1:] | |
| def section_md(c): | |
| src = ''.join(c['source']) | |
| cid = c.get('id', '') | |
| if c.get('metadata', {}).get('pinned'): return None | |
| if c['cell_type'] == 'markdown': | |
| if c.get('metadata', {}).get('solveit_ai'): | |
| sep = re.split(r'##### 🤖Reply🤖<!--.*?-->\n*', src, maxsplit=1) | |
| prompt = sep[0].strip() | |
| if len(sep) > 1 and sep[1].strip(): | |
| response = sep[1].strip() | |
| return (cid, f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div><div class="ai-response"><span class="ai-label">AI</span>\n\n{response}\n\n</div></div>', 'prompt') | |
| return (cid, f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div></div>', 'prompt') | |
| if atts := c.get('attachments', {}): | |
| for fname, mimes in atts.items(): | |
| mime, data = next(iter(mimes.items())) | |
| src = src.replace(f'attachment:{fname}', f'data:{mime};base64,{data}') | |
| return (cid, src, 'note') | |
| if c['cell_type'] == 'code': | |
| code = f'<pre><code class="language-python">{escape(src)}</code></pre>' | |
| if outs := render_outputs(c.get('outputs', [])): | |
| return (cid, f'<div class="cell">{code}<div class="cell-output">{outs}</div></div>', 'code') | |
| return (cid, f'<div class="cell">{code}</div>', 'code') | |
| if c['cell_type'] == 'raw': | |
| return (cid, f'```\n{src}\n```', 'raw') | |
| return None | |
| sections = [r for r in (section_md(c) for c in md_cells) if r is not None] | |
| return frontmatter(f"{fm_src}\n\n") if fm_src else ({},), sections | |
| # %% ../nbs/01_app.ipynb #875cf17d | |
| def span_token(name, pat, attr, prec=5): | |
| class T(mst.span_token.SpanToken): | |
| precedence,parse_inner,parse_group,pattern = prec,False,1,re.compile(pat) | |
| def __init__(self, match): setattr(self, attr, match.group(1)) | |
| T.__name__ = name | |
| return T | |
| # %% ../nbs/01_app.ipynb #3d1ecd05 | |
| FootnoteRef = span_token('FootnoteRef', r'\[\^([^\]]+)\](?!:)', 'target') | |
| YoutubeEmbed = span_token('YoutubeEmbed', r'\[yt:([a-zA-Z0-9_-]+)\]', 'video_id', 6) | |
| # %% ../nbs/01_app.ipynb #12c543fd | |
| def extract_footnotes(content): | |
| pat = re.compile(r'^\[\^([^\]]+)\]:\s*(.+?)(?=(?:^|\n)\[\^|\n\n|\Z)', re.MULTILINE | re.DOTALL) | |
| defs = {m.group(1): m.group(2).strip() for m in pat.finditer(content)} | |
| for m in pat.finditer(content): content = content.replace(m.group(0), '', 1) | |
| return content.strip(), defs | |
| # %% ../nbs/01_app.ipynb #7706f280 | |
| _BLOCK = re.compile( | |
| r'(<div class="ai-cell">[\s\S]*?</div></div>' # prompt cell (2 closers) | |
| r'|<div class="cell">[\s\S]*?</div></div>' # code cell WITH output | |
| r'|<div class="cell"><pre[\s\S]*?</pre></div>' # code cell WITHOUT output | |
| r'|```[\s\S]*?```)') | |
| def extract_headings(content): | |
| "Extract markdown headings, return (content_with_ids, headings_list)" | |
| headings = [] | |
| def _slugify(txt): return re.sub(r'[^\w\s-]', '', txt.strip().lower()).replace(' ', '-') | |
| def _replace(m): | |
| hashes, text = m.group(1), m.group(2) | |
| slug = _slugify(text) | |
| headings.append((len(hashes), text.strip(), slug)) | |
| return f'{hashes} {text}{{id="{slug}"}}' | |
| # Split by code/prompt blocks; only scan the parts between them | |
| parts = _BLOCK.split(content) | |
| for i, part in enumerate(parts): | |
| if _BLOCK.fullmatch(part): continue | |
| parts[i] = re.sub(r'^(#{1,6})\s+(.+)$', _replace, part, flags=re.MULTILINE) | |
| content = ''.join(parts) | |
| return content, headings | |
| # %% ../nbs/01_app.ipynb #cb31c7e3 | |
| def toc_sidebar(headings): | |
| "Build a sticky TOC sidebar from heading list" | |
| if not headings: return '' | |
| items = [] | |
| for level, text, slug in headings: | |
| if level < 2: continue | |
| indent = 'pl-' + str((level - 2) * 4) | |
| items.append(A(text, href=f'#{slug}', cls=f'block text-sm py-1 {indent} text-muted-foreground hover:text-primary transition-colors toc-link')) | |
| return Div( | |
| Div(Span("Contents", cls="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 block"), *items), | |
| cls="hidden lg:block w-48 shrink-0 self-start sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto" | |
| ) | |
| # %% ../nbs/01_app.ipynb #062fd751 | |
| def _mobile_toc(headings): | |
| "Build a floating mobile TOC button + slide-out panel" | |
| if not headings: return '' | |
| items = [A(text, href=f'#{slug}', cls="toc-link", onclick="document.getElementById('toc-mobile-panel').classList.remove('open')") for level, text, slug in headings if level >= 2] | |
| btn = Div("📑", cls="toc-mobile-btn", onclick="document.getElementById('toc-mobile-panel').classList.toggle('open')") | |
| panel = Div( | |
| Div(Span("Contents"), Span("✕", cls="toc-close", onclick="document.getElementById('toc-mobile-panel').classList.remove('open')"), cls="toc-panel-header"), | |
| Div(*items, cls="toc-panel-body"), | |
| id="toc-mobile-panel", cls="toc-mobile-panel" | |
| ) | |
| return Div(btn, panel, cls="lg:hidden") | |
| # %% ../nbs/01_app.ipynb #20158ba1 | |
| def tts_settings_ui(): | |
| "Floating TTS settings button + panel (rendered on every page)" | |
| btn = Div("🔊", cls="tts-settings-btn", title="TTS 设置", | |
| onclick="document.querySelector('.tts-settings-panel').classList.toggle('open')") | |
| panel = Div( | |
| Div("TTS 设置", cls="panel-title"), | |
| # 引擎切换(三态:Browser → Doubao → Kokoro) | |
| Div( | |
| Span("引擎", cls="text-sm"), | |
| Span("🔊 Browser", cls="tts-provider-btn text-xs px-2 py-1 rounded cursor-pointer bg-background border border-border hover:border-primary transition-colors select-none", | |
| onclick="toggleTTSProvider(this)", title="点击切换:Browser → Doubao → Kokoro"), | |
| cls="flex items-center justify-between" | |
| ), | |
| # 豆包 API Key | |
| Div( | |
| Label("豆包 API Key", _for="doubaoApiKeyInput"), | |
| Input(type="password", id="doubaoApiKeyInput", placeholder="输入你的 API Key"), | |
| cls="flex flex-col gap-1" | |
| ), | |
| # 豆包音色选择 | |
| Div( | |
| Label("豆包音色", _for="doubaoVoiceSelect"), | |
| Select( | |
| ft_hx('optgroup', | |
| Option("Vivi 女声(中英混读)", value="zh_female_vv_uranus_bigtts"), | |
| label="中英混读(默认)"), | |
| ft_hx('optgroup', | |
| Option("Tim 男声(美式英语)", value="en_male_tim_uranus_bigtts"), | |
| Option("Dacey 女声(美式英语)", value="en_female_dacey_uranus_bigtts"), | |
| Option("Stokie 女声(美式英语)", value="en_female_stokie_uranus_bigtts"), | |
| Option("Jimmy 男声(美式英语)", value="en_male_jimmy_uranus_bigtts"), | |
| label="英文专用"), | |
| ft_hx('optgroup', | |
| Option("云舟 男声(沉稳自然)", value="zh_male_m191_uranus_bigtts"), | |
| Option("小何 女声(通用)", value="zh_female_xiaohe_uranus_bigtts"), | |
| Option("爽快思思 女声(多情感)", value="zh_female_shuangkuaisisi_uranus_bigtts"), | |
| label="中文专用"), | |
| id="doubaoVoiceSelect" | |
| ), | |
| cls="flex flex-col gap-1" | |
| ), | |
| # ── Kokoro 设置 ── | |
| Div( | |
| Label("Kokoro Replicate API Key(英文)", _for="kokoroApiKeyInput"), | |
| Input(type="password", id="kokoroApiKeyInput", placeholder="r8_... (英文音色用)"), | |
| cls="flex flex-col gap-1" | |
| ), | |
| Div( | |
| Label("Kokoro OpenRouter API Key(中文)", _for="kokoroOpenrouterKeyInput"), | |
| Input(type="password", id="kokoroOpenrouterKeyInput", placeholder="sk-or-... (中文音色用)"), | |
| cls="flex flex-col gap-1" | |
| ), | |
| Div( | |
| Label("Kokoro 音色", _for="kokoroVoiceSelect"), | |
| Select( | |
| ft_hx('optgroup', | |
| Option("Emma 女声(英文,默认)", value="bf_emma"), | |
| Option("Bella 女声(英文)", value="af_bella"), | |
| Option("Ryan 男声(英文)", value="am_ryan"), | |
| label="英文音色(Replicate)"), | |
| ft_hx('optgroup', | |
| Option("Xiaoyi 女声(中文)", value="zf_xiaoyi"), | |
| label="中文音色(OpenRouter)"), | |
| id="kokoroVoiceSelect" | |
| ), | |
| cls="flex flex-col gap-1" | |
| ), | |
| # 语速 | |
| Div( | |
| Span("语速"), | |
| Input(type="range", id="ttsSpeedSlider", min="0.5", max="2", step="0.1", value="1"), | |
| Span("1x", id="ttsSpeedValue"), | |
| cls="speed-row" | |
| ), | |
| # 缓存信息 + 清空按钮 | |
| Div( | |
| Span(id="ttsCacheSizeDisplay", cls="text-xs text-muted-foreground"), | |
| Button("清空缓存", id="clearTtsCacheBtn", | |
| cls="text-xs px-2 py-1 rounded border border-border hover:border-primary cursor-pointer shadow-none"), | |
| cls="flex items-center justify-between gap-2" | |
| ), | |
| # 保存按钮 | |
| Button("保存设置", cls="save-btn", onclick="saveTtsSettings()"), | |
| cls="tts-settings-panel" | |
| ) | |
| floating_player = Div( | |
| Span("⏸", cls="fp-play-btn", onclick="toggleFloatingPlay()"), | |
| Span("⏹", cls="fp-stop-btn", onclick="stopPlayback()"), | |
| Input(type="range", cls="fp-progress", min="0", max="100", value="0", step="0.1", | |
| oninput="seekTts(this.value)"), | |
| Span("0:00 / 0:00", cls="fp-time"), | |
| id="floating-player", cls="floating-player" | |
| ) | |
| return btn, panel, floating_player | |
| # %% ../nbs/01_app.ipynb #ae16b7fe | |
| class ContentRenderer(FrankenRenderer): | |
| def __init__(self, *extras, img_dir=None, footnotes=None, **kwargs): | |
| super().__init__(*extras, img_dir=img_dir, **kwargs) | |
| self.footnotes,self.fn_counter = footnotes or {},0 | |
| def render_footnote_ref(self, token): | |
| self.fn_counter += 1 | |
| n,target = self.fn_counter,token.target | |
| content = self.footnotes.get(target, f"[Missing footnote: {target}]") | |
| rendered = mst.markdown(content, partial(ContentRenderer, img_dir=self.img_dir)).strip() | |
| if rendered.startswith('<p>') and rendered.endswith('</p>'): rendered = rendered[3:-4] | |
| style = "text-sm leading-relaxed border-l-2 border-amber-400 dark:border-blue-400 pl-3 text-neutral-500 dark:text-neutral-400 transition-all duration-500 w-full my-2 xl:my-0" | |
| toggle_hs = f"on click if window.innerWidth >= 1280 then add .hl to #sn-{n} then wait 1s then remove .hl from #sn-{n} else toggle .open on me then toggle .show on #sn-{n}" | |
| ref = Span(id=f"snref-{n}", role="doc-noteref", aria_label=f"Sidenote {n}", cls="sidenote-ref cursor-pointer", _=toggle_hs) | |
| note = Span(NotStr(rendered), id=f"sn-{n}", role="doc-footnote", aria_labelledby=f"snref-{n}", cls=f"sidenote {style}") | |
| hide = lambda c: to_xml(Span(c, cls="hidden", aria_hidden="true")) | |
| return hide(" (") + to_xml(ref) + to_xml(note) + hide(")") | |
| def render_youtube_embed(self, token): | |
| iframe = Iframe(src=f"https://www.youtube.com/embed/{token.video_id}", title="YouTube video player", frameborder="0", | |
| allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share", | |
| referrerpolicy="strict-origin-when-cross-origin", allowfullscreen=True, cls="w-full aspect-video rounded-lg my-6") | |
| return to_xml(iframe) | |
| def render_link(self, token): | |
| href,inner,title = token.target,self.render_inner(token),f' title="{token.title}"' if token.title else '' | |
| is_internal = href.startswith('/') and not href.startswith('//') and '.' not in href.split('/')[-1] | |
| hx = f' hx-get="{href}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top"' if is_internal else '' | |
| ext = '' if is_internal else ' target="_blank" rel="noopener noreferrer"' | |
| return f'<a href="{href}"{hx}{ext} class="text-primary underline"{title}>{inner}</a>' | |
| # %% ../nbs/01_app.ipynb #12091396 | |
| def from_md(content, img_dir='/public/posts'): | |
| content, footnotes = extract_footnotes(content) | |
| mods = {'pre': 'border border-gray-300 rounded-md my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed', | |
| 'ul': 'uk-list uk-list-bullet space-y-2 mb-6 ml-6 text-base', 'ol': 'uk-list uk-list-decimal space-y-2 mb-6 ml-6 text-base', 'hr': 'border-t border-border my-8'} | |
| rendered = render_md(content, class_map_mods=mods, img_dir=img_dir, renderer=partial(ContentRenderer, FootnoteRef, YoutubeEmbed, footnotes=footnotes)) | |
| return Div(rendered, cls="w-full") | |
| # %% ../nbs/01_app.ipynb #45f17cd0 | |
| sidenote_css = Style(""" | |
| .sidenote-ref { | |
| color: rgb(221, 166, 55); background-color: rgba(221, 166, 55, 0.15); | |
| display: inline-block; transition: transform 0.2s; | |
| font-size: 0.6rem; vertical-align: top; line-height: 1; | |
| padding: 0 0.15rem; border-radius: 0.2rem; margin-left: 0.1rem; | |
| } | |
| .sidenote-ref:after { content: "›"; } | |
| .dark .sidenote-ref { color: rgb(96, 165, 250); background-color: rgba(96, 165, 250, 0.15); } | |
| .sidenote { display: none; } | |
| @media (max-width: 1279px) { | |
| .sidenote-ref:after { content: "⌃"; } | |
| .sidenote-ref { transform: rotate(180deg); } | |
| .sidenote-ref.open { transform: rotate(0deg); } | |
| .sidenote.show { display: block; float: left; clear: both; width: 95%; margin: 0.75rem 2.5%; position: relative; } | |
| } | |
| @media (min-width: 1280px) { | |
| .sidenote { display: block; float: right; clear: right; width: 14rem; margin-right: -16rem; margin-top: 0.25rem; margin-bottom: 0.75rem; } | |
| .sidenote.hl { background-color: rgba(221, 166, 55, 0.1); } | |
| .dark .sidenote.hl { background-color: rgba(96, 165, 250, 0.1); } | |
| } | |
| """) | |
| # %% ../nbs/01_app.ipynb #8f72d41c | |
| cell_css = Style(""" | |
| .cell pre { margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; } | |
| .cell-output pre, .cell-output pre code { background: #f0f0f0; } | |
| .dark .cell-output pre, .dark .cell-output pre code { background: #1e2128; } | |
| .cell-output { border: 1px solid rgb(209 213 219); border-top: 0; border-bottom-left-radius: 0.375rem; border-bottom-right-radius: 0.375rem; overflow: hidden; } | |
| .ansi-red-fg { color: #e75c58; } .ansi-green-fg { color: #00a250; } .ansi-yellow-fg { color: #ddb62b; } | |
| .ansi-blue-fg { color: #208ffb; } .ansi-magenta-fg { color: #d160c4; } .ansi-cyan-fg { color: #60c6c8; } | |
| .ansi-white-fg { color: #c5c1b4; } .ansi-bold { font-weight: bold; } | |
| .ai-cell { border: 1px solid rgb(209 213 219); border-radius: 0.375rem; overflow: hidden; margin: 1.5rem 0; } | |
| .ai-prompt { padding: 0.75rem 1rem; } | |
| .ai-prompt > p:last-child, .ai-response > p:last-child { margin-bottom: 0; } | |
| .ai-response { padding: 0.75rem 1rem; background: #f0f0f0; border-top: 1px solid rgb(209 213 219); } | |
| .dark .ai-response { background: #1e2128; } | |
| .ai-prompt .ai-label, .ai-response .ai-label { float: right; font-size: 0.6rem; font-weight: 600; color: rgb(156 163 175); letter-spacing: 0.05em; margin-left: 0.5rem; } | |
| """) | |
| # %% ../nbs/01_app.ipynb #4490fdeb | |
| toc_css = Style(""" | |
| .toc-link.active { color: var(--primary); font-weight: 600; } | |
| """) | |
| # %% ../nbs/01_app.ipynb #2c3ecdac | |
| mobile_toc_css = Style(""" | |
| .toc-mobile-btn { | |
| position: fixed; bottom: 10px; right: 1.5rem; z-index: 1000; | |
| width: 3rem; height: 3rem; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 1.5rem; cursor: pointer; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.15); | |
| } | |
| .toc-mobile-panel { | |
| position: fixed; top: 0; right: 0; z-index: 1001; | |
| width: 280px; height: 100vh; | |
| background: hsl(var(--background)); | |
| transform: translateX(100%); transition: transform 0.3s ease; | |
| overflow-y: auto; padding: 1.5rem; | |
| box-shadow: -2px 0 8px rgba(0,0,0,0.1); | |
| } | |
| .toc-mobile-panel.open { transform: translateX(0); } | |
| .toc-panel-header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding-bottom: 1rem; margin-bottom: 1rem; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .toc-panel-header span:last-child { cursor: pointer; font-size: 1.25rem; } | |
| .toc-panel-body a { | |
| display: block; padding: 0.5rem 0; font-size: 0.9rem; | |
| color: hsl(var(--foreground)); | |
| } | |
| .toc-panel-body a:hover { color: var(--primary); } | |
| @media (max-width: 1023px) { | |
| .toc-mobile-btn { display: flex; } | |
| } | |
| """) | |
| # %% ../nbs/01_app.ipynb #1541d7c6 | |
| AUDIO_CSS = Style(""" | |
| .note-block { display: flex; flex-direction: column; } | |
| .note-block > .audio-controls { order: -1; } | |
| .audio-controls { display: inline-flex; align-items: center; gap: 6px; } | |
| .audio-controls .pause-btn, | |
| .audio-controls .stop-btn { display: none; } | |
| .audio-controls.playing .play-btn { display: none; } | |
| .audio-controls.playing .pause-btn { display: inline; } | |
| .audio-controls.playing .stop-btn { display: inline; } | |
| .audio-controls.paused .play-btn { display: inline; } | |
| .audio-controls.paused .pause-btn { display: none; } | |
| .audio-controls.paused .stop-btn { display: inline; } | |
| .audio-controls .play-btn { cursor: pointer; } | |
| .audio-controls .pause-btn { cursor: pointer; } | |
| .audio-controls .stop-btn { cursor: pointer; } | |
| /* TTS Settings Button */ | |
| .tts-settings-btn { | |
| position: fixed; bottom: 2rem; left: 2rem; z-index: 1000; | |
| width: 2.5rem; height: 2.5rem; border-radius: 50%; | |
| background: hsl(var(--background)); border: 1px solid var(--border); | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.15); cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; font-size: 1.1rem; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .tts-settings-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } | |
| /* TTS Settings Panel */ | |
| .tts-settings-panel { | |
| position: fixed; bottom: 5rem; left: 2rem; z-index: 1001; | |
| width: 300px; padding: 1.2rem; border-radius: 0.75rem; | |
| background: hsl(var(--background)); border: 1px solid var(--border); | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.18); | |
| display: none; flex-direction: column; gap: 1rem; | |
| } | |
| .tts-settings-panel.open { display: flex; } | |
| .tts-settings-panel .panel-title { font-weight: 600; font-size: 1rem; } | |
| .tts-settings-panel label { | |
| display: flex; align-items: center; gap: 0.5rem; | |
| font-size: 0.9rem; cursor: pointer; | |
| } | |
| .tts-settings-panel select, | |
| .tts-settings-panel input[type="password"], | |
| .tts-settings-panel input[type="text"] { | |
| width: 100%; padding: 0.5rem; border: 1px solid var(--border); | |
| border-radius: 0.375rem; font-size: 0.85rem; | |
| background: transparent; color: inherit; outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| .tts-settings-panel select:focus, | |
| .tts-settings-panel input:focus { border-color: var(--primary); } | |
| .tts-settings-panel .speed-row { | |
| display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem; | |
| } | |
| .tts-settings-panel input[type="range"] { flex: 1; cursor: pointer; } | |
| .tts-settings-panel .hint { | |
| font-size: 0.75rem; color: var(--muted-foreground); | |
| line-height: 1.4; | |
| } | |
| .tts-settings-panel .save-btn { | |
| padding: 0.55rem; border: none; border-radius: 0.375rem; | |
| background: var(--primary); color: white; cursor: pointer; | |
| font-size: 0.85rem; font-weight: 500; transition: opacity 0.2s; | |
| } | |
| .tts-settings-panel .save-btn:hover { opacity: 0.9; } | |
| .voice-group { display: flex; flex-direction: column; gap: 0.5rem; } | |
| .voice-group select { margin-top: 0.25rem; } | |
| .tts-provider-btn { user-select: none; -webkit-user-select: none; } | |
| .tts-provider-btn:active { transform: scale(0.95); } | |
| /* 就地进度条(每节播放控件旁)*/ | |
| .tts-progress { | |
| flex: 1; | |
| min-width: 50px; | |
| height: 4px; | |
| cursor: pointer; | |
| display: none; | |
| accent-color: var(--primary); | |
| } | |
| .tts-progress.active { display: inline-block; } | |
| /* 悬浮播放条(跟随滚动)*/ | |
| .floating-player { | |
| position: fixed; | |
| bottom: 1rem; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 1002; | |
| display: none; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 8px 18px; | |
| background: hsl(var(--background) / 0.85); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.18); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| max-width: 92vw; | |
| width: 420px; | |
| } | |
| .floating-player.visible { display: flex; } | |
| /* 手机端:上移避开底部两个角按钮 */ | |
| @media (max-width: 1023px) { | |
| .floating-player { | |
| bottom: 4rem; | |
| width: calc(100% - 2rem); | |
| } | |
| .tts-settings-btn { | |
| bottom: 1rem; | |
| left: 1.5rem; | |
| } | |
| /* ── TTS panel: cap height + scroll inside so it never overflows the screen ── */ | |
| .tts-settings-panel { | |
| bottom: 4rem; | |
| left: 1rem; | |
| right: 1rem; | |
| width: auto; | |
| max-height: calc(100vh - 5rem); | |
| overflow-y: auto; | |
| -webkit-overflow-scrolling: touch; | |
| padding: 0.85rem; | |
| gap: 0.65rem; | |
| } | |
| .tts-settings-panel .panel-title { font-size: 0.9rem; } | |
| .tts-settings-panel label { font-size: 0.8rem; } | |
| .tts-settings-panel select, | |
| .tts-settings-panel input[type="password"], | |
| .tts-settings-panel input[type="text"] { | |
| padding: 0.4rem; | |
| font-size: 0.8rem; | |
| } | |
| .tts-settings-panel .speed-row { font-size: 0.8rem; } | |
| .tts-settings-panel .save-btn { | |
| padding: 0.45rem; | |
| font-size: 0.8rem; | |
| } | |
| } | |
| .floating-player .fp-play-btn, | |
| .floating-player .fp-stop-btn { | |
| cursor: pointer; | |
| font-size: 1.1rem; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| .floating-player .fp-play-btn:active, | |
| .floating-player .fp-stop-btn:active { transform: scale(0.9); } | |
| .floating-player .fp-progress { | |
| flex: 1; | |
| height: 4px; | |
| cursor: pointer; | |
| accent-color: var(--primary); | |
| display: none; | |
| } | |
| .floating-player .fp-time { | |
| font-size: 0.72rem; | |
| color: var(--muted-foreground); | |
| min-width: 78px; | |
| text-align: right; | |
| display: none; | |
| } | |
| .floating-player.doubao-mode .fp-progress, | |
| .floating-player.doubao-mode .fp-time { display: inline; } | |
| """) | |
| # %% ../nbs/01_app.ipynb #9c9314fc | |
| annotation_js = NotStr(r""" | |
| <script> | |
| const STORAGE_KEY = 'blog_annotations'; | |
| const EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours | |
| pruneExpired(); | |
| function saveAnnotation(annotation) { | |
| const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); | |
| all.push(annotation); | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); | |
| } | |
| function copyAllToClipboard() { | |
| const all = pruneExpired(); | |
| if (!all.length) return; | |
| const blocks = all.reverse().map(a => { | |
| const title = a.title || a.url; | |
| return `${title}\n${a.url}\n\n> "${a.text}"\n\nComment: ${a.comment}\n\n---`; | |
| }); | |
| navigator.clipboard.writeText(blocks.join('\n')).catch(() => { | |
| // 剪贴板不可用时静默失败,不影响标注保存 | |
| }); | |
| } | |
| function copySelectedText(text) { | |
| // 优先使用现代 Clipboard API | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); | |
| } else { | |
| fallbackCopy(text); | |
| } | |
| } | |
| function fallbackCopy(text) { | |
| const ta = document.createElement('textarea'); | |
| ta.value = text; | |
| ta.style.position = 'fixed'; | |
| ta.style.opacity = '0'; | |
| ta.style.left = '-9999px'; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| try { document.execCommand('copy'); } catch(e) {} | |
| document.body.removeChild(ta); | |
| } | |
| function pruneExpired() { | |
| const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); | |
| const cutoff = Date.now() - EXPIRY_MS; | |
| const filtered = all.filter(a => (a.timestamp * 1000) > cutoff); | |
| if (filtered.length !== all.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); | |
| return filtered; | |
| } | |
| function positionPopup(popup, rect) { | |
| const viewportW = window.innerWidth; | |
| const popupW = Math.min(320, viewportW - 32); | |
| popup.style.cssText = ` | |
| position:fixed; z-index:9999; background:#fff; border:1px solid #ddd; | |
| border-radius:8px; padding:12px; box-shadow:0 4px 12px rgba(0,0,0,0.15); | |
| font-family:sans-serif; width:${popupW}px; | |
| left:${Math.max(16, Math.min(rect.left + rect.width/2 - popupW/2, viewportW - popupW - 16))}px; | |
| top:${rect.bottom + 8}px; | |
| `; | |
| } | |
| function showAnnotationPopup(range, selectedText) { | |
| document.querySelector('.hl-popup')?.remove(); | |
| const popup = document.createElement('div'); | |
| popup.className = 'hl-popup'; | |
| popup.innerHTML = ` | |
| <textarea placeholder="Type or dictate your comment..." rows="3" style="width:100%;border:1px solid #ccc;border-radius:4px;padding:6px;font-family:sans-serif;font-size:14px;resize:none;background:#fff;color:#333"></textarea> | |
| <div style="display:flex;gap:8px;margin-top:8px"> | |
| <button class="hl-save" style="flex:1;padding:6px 12px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px">Save</button> | |
| <button class="hl-cancel" style="flex:1;padding:6px 12px;background:#e5e7eb;color:#374151;border:none;border-radius:4px;cursor:pointer;font-size:14px">Cancel</button> | |
| </div> | |
| `; | |
| positionPopup(popup, range.getBoundingClientRect()); | |
| popup.querySelector('.hl-save').onclick = () => { | |
| const comment = popup.querySelector('textarea').value; | |
| saveAnnotation({ | |
| id: 'hli_' + Date.now() + '_' + Math.floor(Math.random() * 1000), | |
| url: location.href, | |
| title: document.title, | |
| text: selectedText, | |
| comment: comment, | |
| timestamp: Math.floor(Date.now() / 1000), | |
| }); | |
| copyAllToClipboard(); | |
| try { | |
| const span = document.createElement('span'); | |
| span.className = 'hl-yellow'; | |
| span.style.backgroundColor = 'rgba(255,255,0,0.4)'; | |
| span.style.borderRadius = '2px'; | |
| range.surroundContents(span); | |
| } catch(e) {} | |
| popup.remove(); | |
| window.getSelection().removeAllRanges(); | |
| }; | |
| popup.querySelector('.hl-cancel').onclick = () => { | |
| popup.remove(); | |
| window.getSelection().removeAllRanges(); | |
| }; | |
| document.body.appendChild(popup); | |
| popup.querySelector('textarea').focus(); | |
| } | |
| function showChoicePopup(range, selectedText) { | |
| document.querySelector('.hl-popup')?.remove(); | |
| const popup = document.createElement('div'); | |
| popup.className = 'hl-popup hl-choice-popup'; | |
| popup.innerHTML = ` | |
| <div style="display:flex;gap:8px"> | |
| <button class="hl-copy" style="flex:1;padding:8px 12px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500">Copy</button> | |
| <button class="hl-comment" style="flex:1;padding:8px 12px;background:#e5e7eb;color:#374151;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500">Comments</button> | |
| </div> | |
| `; | |
| positionPopup(popup, range.getBoundingClientRect()); | |
| popup.querySelector('.hl-copy').onclick = () => { | |
| copySelectedText(selectedText); | |
| popup.remove(); | |
| window.getSelection().removeAllRanges(); | |
| }; | |
| popup.querySelector('.hl-comment').onclick = () => { | |
| popup.remove(); | |
| showAnnotationPopup(range, selectedText); | |
| }; | |
| document.body.appendChild(popup); | |
| } | |
| document.addEventListener('mouseup', function(e) { | |
| if (!window.__annotationEnabled) return; | |
| if (e.target.closest('.hl-popup')) return; | |
| const sel = window.getSelection(); | |
| const text = sel.toString().trim(); | |
| if (!text || text.length < 3) return; | |
| showChoicePopup(sel.getRangeAt(0), text); | |
| }); | |
| document.addEventListener('touchend', function(e) { | |
| if (!window.__annotationEnabled) return; | |
| if (e.target.closest('.hl-popup')) return; | |
| setTimeout(() => { | |
| const sel = window.getSelection(); | |
| const text = sel.toString().trim(); | |
| if (!text || text.length < 3) return; | |
| showChoicePopup(sel.getRangeAt(0), text); | |
| }, 10); | |
| }); | |
| </script> | |
| """) | |
| # %% ../nbs/01_app.ipynb #756b1d7d | |
| AUDIO_JS = NotStr(r""" | |
| <script> | |
| (function() { | |
| let playAllQueue = []; | |
| let isPlayAll = false; | |
| let gen = 0; | |
| let currentCid = null; | |
| let chunkIdx = 0; | |
| let currentPlayAllText = ''; | |
| let savedBookmark = null; | |
| let currentDoubaoAudio = null; | |
| let doubaoLoading = false; | |
| let activeProgressBar = null; | |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); | |
| let enVoice = null, zhVoice = null; | |
| // ========== IndexedDB 音频缓存 ========== | |
| const CACHE_DB = 'tts_audio_cache', CACHE_STORE = 'blobs'; | |
| function openCacheDB() { | |
| return new Promise((resolve, reject) => { | |
| const req = indexedDB.open(CACHE_DB, 1); | |
| req.onupgradeneeded = e => { if (!e.target.result.objectStoreNames.contains(CACHE_STORE)) e.target.result.createObjectStore(CACHE_STORE); }; | |
| req.onsuccess = () => resolve(req.result); | |
| req.onerror = () => reject(req.error); | |
| }); | |
| } | |
| function cacheGet(key) { | |
| return openCacheDB().then(db => new Promise((resolve, reject) => { | |
| const tx = db.transaction(CACHE_STORE, 'readonly'); | |
| const r = tx.objectStore(CACHE_STORE).get(key); | |
| r.onsuccess = () => resolve(r.result); | |
| r.onerror = () => reject(r.error); | |
| })); | |
| } | |
| function cacheSet(key, val) { | |
| return openCacheDB().then(db => new Promise((resolve, reject) => { | |
| const tx = db.transaction(CACHE_STORE, 'readwrite'); | |
| tx.objectStore(CACHE_STORE).put(val, key); | |
| tx.oncomplete = () => resolve(); | |
| tx.onerror = () => reject(tx.error); | |
| })); | |
| } | |
| function cacheKey(text, speaker) { | |
| let h = 5381, s = speaker + '|' + text; | |
| for (let i = 0; i < s.length; i++) { h = ((h << 5) + h) + s.charCodeAt(i); h |= 0; } | |
| return 'tts_' + h.toString(36) + '_len_' + s.length; | |
| } | |
| // Decode exact duration via Web Audio API — immune to missing MP3 headers | |
| let decodedDuration = null; | |
| async function decodeDuration(bytes) { | |
| try { | |
| const blob = new Blob([bytes], { type: 'audio/mpeg' }); | |
| const buf = await blob.arrayBuffer(); | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| const ctx = new AC(); | |
| return await new Promise((resolve, reject) => { | |
| ctx.decodeAudioData(buf.slice(0), | |
| (audioBuf) => { ctx.close(); resolve(audioBuf.duration); }, | |
| (err) => { ctx.close(); reject(err); } | |
| ); | |
| }); | |
| } catch (e) { | |
| console.warn('decodeDuration failed, falling back to audio.duration', e); | |
| return null; | |
| } | |
| } | |
| function getEffectiveDuration() { | |
| if (decodedDuration && isFinite(decodedDuration)) return decodedDuration; | |
| const d = currentDoubaoAudio && currentDoubaoAudio.duration; | |
| return (d && isFinite(d) && !isNaN(d)) ? d : null; | |
| } | |
| async function getCacheTotalSize() { | |
| const db = await openCacheDB(); | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(CACHE_STORE, 'readonly'); | |
| const cursor = tx.objectStore(CACHE_STORE).openCursor(); | |
| let total = 0; | |
| cursor.onsuccess = e => { | |
| const c = e.target.result; | |
| if (c) { if (c.value instanceof Uint8Array) total += c.value.byteLength; c.continue(); } | |
| else resolve(total); | |
| }; | |
| cursor.onerror = () => reject(cursor.error); | |
| }); | |
| } | |
| async function clearAllTtsCache() { | |
| const db = await openCacheDB(); | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(CACHE_STORE, 'readwrite'); | |
| tx.objectStore(CACHE_STORE).clear(); | |
| tx.oncomplete = () => resolve(); | |
| tx.onerror = () => reject(tx.error); | |
| }); | |
| } | |
| async function refreshCacheSizeDisplay() { | |
| const el = document.getElementById('ttsCacheSizeDisplay'); | |
| if (!el) return; | |
| try { | |
| const bytes = await getCacheTotalSize(); | |
| el.textContent = bytes > 1024 ? `音频缓存: ${(bytes/1024).toFixed(1)} KB` : '音频缓存: 0 KB'; | |
| } catch { el.textContent = '音频缓存: 不可用'; } | |
| } | |
| window.clearTTSCache = async function() { await clearAllTtsCache(); refreshCacheSizeDisplay(); console.log('TTS cache cleared'); }; | |
| // ========== TTS Provider 管理 ========== | |
| function getProvider() { return localStorage.getItem('tts_provider') || 'browser'; } | |
| function setProvider(p) { localStorage.setItem('tts_provider', p); } | |
| function initProviderButtons() { | |
| const p = getProvider(); | |
| const label = p === 'doubao' ? '🤖 Doubao' : p === 'kokoro' ? '🎵 Kokoro' : '🔊 Browser'; | |
| document.querySelectorAll('.tts-provider-btn').forEach(btn => { btn.textContent = label; }); | |
| } | |
| window.toggleTTSProvider = function(el) { | |
| const order = ['browser', 'doubao', 'kokoro']; | |
| const cur = getProvider(); | |
| const next = order[(order.indexOf(cur) + 1) % order.length]; | |
| if (next === 'doubao' && !localStorage.getItem('doubao_api_key')) { | |
| const key = prompt('请输入豆包 TTS API Key(将保存在本地浏览器):'); | |
| if (!key) return; | |
| localStorage.setItem('doubao_api_key', key.trim()); | |
| const input = document.getElementById('doubaoApiKeyInput'); | |
| if (input) input.value = key.trim(); | |
| } | |
| if (next === 'kokoro') { | |
| if (!localStorage.getItem('kokoro_api_key')) { | |
| const key = prompt('请输入 Replicate API Key(Kokoro 英文音色,将保存在本地):'); | |
| if (!key) return; | |
| localStorage.setItem('kokoro_api_key', key.trim()); | |
| const input = document.getElementById('kokoroApiKeyInput'); | |
| if (input) input.value = key.trim(); | |
| } | |
| if (!localStorage.getItem('kokoro_openrouter_key')) { | |
| const key = prompt('请输入 OpenRouter API Key(Kokoro 中文音色,将保存在本地):'); | |
| if (!key) return; | |
| localStorage.setItem('kokoro_openrouter_key', key.trim()); | |
| const input = document.getElementById('kokoroOpenrouterKeyInput'); | |
| if (input) input.value = key.trim(); | |
| } | |
| } | |
| stopPlayback(); | |
| setProvider(next); | |
| initProviderButtons(); | |
| }; | |
| // ========== 设置面板:保存 / 加载 ========== | |
| window.saveTtsSettings = function() { | |
| const apiKey = document.getElementById('doubaoApiKeyInput').value.trim(); | |
| const voice = document.getElementById('doubaoVoiceSelect').value; | |
| const kokoroKey = document.getElementById('kokoroApiKeyInput').value.trim(); | |
| const kokoroOpenrouterKey = document.getElementById('kokoroOpenrouterKeyInput').value.trim(); | |
| const kokoroVoice = document.getElementById('kokoroVoiceSelect').value; | |
| const speed = parseFloat(document.getElementById('ttsSpeedSlider').value); | |
| if (apiKey) localStorage.setItem('doubao_api_key', apiKey); | |
| localStorage.setItem('doubao_voice', voice); | |
| if (kokoroKey) localStorage.setItem('kokoro_api_key', kokoroKey); | |
| if (kokoroOpenrouterKey) localStorage.setItem('kokoro_openrouter_key', kokoroOpenrouterKey); | |
| localStorage.setItem('kokoro_voice', kokoroVoice); | |
| localStorage.setItem('tts_speed', speed); | |
| alert('设置已保存'); | |
| document.querySelector('.tts-settings-panel').classList.remove('open'); | |
| }; | |
| function loadTtsSettings() { | |
| const savedKey = localStorage.getItem('doubao_api_key') || ''; | |
| const savedVoice = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts'; | |
| const savedKokoroKey = localStorage.getItem('kokoro_api_key') || ''; | |
| const savedKokoroVoice = localStorage.getItem('kokoro_voice') || 'bf_emma'; | |
| const savedSpeed = parseFloat(localStorage.getItem('tts_speed')) || 1.0; | |
| const keyInput = document.getElementById('doubaoApiKeyInput'); | |
| const voiceSelect = document.getElementById('doubaoVoiceSelect'); | |
| const kokoroKeyInput = document.getElementById('kokoroApiKeyInput'); | |
| const kokoroVoiceSelect = document.getElementById('kokoroVoiceSelect'); | |
| const speedSlider = document.getElementById('ttsSpeedSlider'); | |
| const speedValue = document.getElementById('ttsSpeedValue'); | |
| const savedKokoroOrKey = localStorage.getItem('kokoro_openrouter_key') || ''; | |
| const kokoroOrKeyInput = document.getElementById('kokoroOpenrouterKeyInput'); | |
| if (kokoroOrKeyInput) kokoroOrKeyInput.value = savedKokoroOrKey; | |
| if (keyInput) keyInput.value = savedKey; | |
| if (voiceSelect) voiceSelect.value = savedVoice; | |
| if (kokoroKeyInput) kokoroKeyInput.value = savedKokoroKey; | |
| if (kokoroVoiceSelect) kokoroVoiceSelect.value = savedKokoroVoice; | |
| if (speedSlider) speedSlider.value = savedSpeed; | |
| if (speedValue) speedValue.textContent = savedSpeed + 'x'; | |
| } | |
| function bindTtsSettingsUi() { | |
| const speedSlider = document.getElementById('ttsSpeedSlider'); | |
| const speedValue = document.getElementById('ttsSpeedValue'); | |
| if (speedSlider && speedValue) { | |
| speedSlider.addEventListener('input', () => { speedValue.textContent = speedSlider.value + 'x'; }); | |
| } | |
| const clearBtn = document.getElementById('clearTtsCacheBtn'); | |
| if (clearBtn) { | |
| clearBtn.addEventListener('click', async () => { | |
| if (!confirm('确定清空所有 TTS 音频缓存?下次播放将重新调用 API 生成。')) return; | |
| await clearAllTtsCache(); | |
| await refreshCacheSizeDisplay(); | |
| alert('TTS 音频缓存已清空。'); | |
| }); | |
| } | |
| } | |
| // ========== 浏览器 TTS(Web Speech API)========== | |
| function pickVoices() { | |
| const voices = speechSynthesis.getVoices(); | |
| if (!voices.length) return false; | |
| const enNames = isMobile ? ['Samantha', 'Daniel'] : ['Daniel', 'Jamie', 'Aaron']; | |
| const enFallback = isMobile ? 'Samantha' : 'Google UK English Female'; | |
| enVoice = enNames.map(n => voices.find(v => v.name.includes(n))).find(Boolean); | |
| if (!enVoice) enVoice = voices.find(v => v.name.includes(enFallback)) | |
| || voices.find(v => v.lang === 'en-US') || voices.find(v => v.lang.startsWith('en')); | |
| const zhNames = isMobile ? ['美嘉', '婷婷'] : ['Meijia', 'Li-Mu', 'Yu-shu']; | |
| const zhFallback = isMobile ? '美嘉' : 'Google 普通话'; | |
| zhVoice = zhNames.map(n => voices.find(v => v.name.includes(n))).find(Boolean); | |
| if (!zhVoice) zhVoice = voices.find(v => v.name.includes(zhFallback)) | |
| || voices.find(v => v.lang.startsWith('zh')); | |
| return !!(enVoice && zhVoice); | |
| } | |
| function waitForVoices() { | |
| let attempts = 0; | |
| const timer = setInterval(() => { if (pickVoices() || ++attempts >= 20) clearInterval(timer); }, 500); | |
| } | |
| speechSynthesis.onvoiceschanged = pickVoices; | |
| waitForVoices(); | |
| function detectLang(text) { | |
| const zh = text.match(/[\u4e00-\u9fff]/g); | |
| return (zh && zh.length > text.length * 0.3) ? 'zh' : 'en'; | |
| } | |
| function getVoice(lang) { return lang === 'zh' ? zhVoice : enVoice; } | |
| function chunkText(text, size) { | |
| const chunks = []; | |
| for (let i = 0; i < text.length; i += size) chunks.push(text.slice(i, i + size)); | |
| return chunks; | |
| } | |
| function speakChunks(chunks, btns, myGen, onDone) { | |
| if (!chunks.length || myGen !== gen) { if (myGen === gen && onDone) onDone(); return; } | |
| const [first, ...rest] = chunks; | |
| const lang = detectLang(first); | |
| const u = new SpeechSynthesisUtterance(first); | |
| u.lang = lang === 'zh' ? 'zh-CN' : 'en-US'; | |
| const v = getVoice(lang); | |
| if (v) u.voice = v; | |
| u.onend = () => { chunkIdx++; speakChunks(rest, btns, myGen, onDone); }; | |
| u.onerror = () => { if (myGen === gen) { resetBtns(btns); if (onDone) onDone(); } }; | |
| btns.className = 'audio-controls playing'; | |
| showFloatingPlayer(); | |
| speechSynthesis.speak(u); | |
| } | |
| // ========== 进度条(就地 + 悬浮,仅豆包模式可跳转)========== | |
| function showProgressBar(btns) { | |
| const bar = btns.querySelector('.tts-progress'); | |
| if (!bar) return; | |
| bar.classList.add('active'); | |
| bar.value = 0; | |
| activeProgressBar = bar; | |
| } | |
| function hideProgressBar() { | |
| if (activeProgressBar) { activeProgressBar.classList.remove('active'); activeProgressBar.value = 0; } | |
| activeProgressBar = null; | |
| } | |
| function updateProgressBar() { | |
| if (!currentDoubaoAudio) return; | |
| if (!currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return; | |
| if (activeProgressBar) activeProgressBar.value = (currentDoubaoAudio.currentTime / currentDoubaoAudio.duration) * 100; | |
| updateFloatingProgress(); | |
| } | |
| window.seekTts = function(value) { | |
| if (!currentDoubaoAudio || !currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return; | |
| currentDoubaoAudio.currentTime = (value / 100) * currentDoubaoAudio.duration; | |
| }; | |
| // ========== 悬浮播放条(跟随滚动)========== | |
| function showFloatingPlayer() { | |
| const fp = document.getElementById('floating-player'); | |
| if (!fp) return; | |
| fp.classList.toggle('doubao-mode', getProvider() !== 'browser'); | |
| fp.classList.add('visible'); | |
| setFloatingPlayIcon(true); | |
| // 播放时把两个角按钮稍微淡化,减少视觉干扰 | |
| document.querySelector('.toc-mobile-btn')?.style.setProperty('opacity', '0.4'); | |
| document.querySelector('.tts-settings-btn')?.style.setProperty('opacity', '0.4'); | |
| } | |
| function hideFloatingPlayer() { | |
| const fp = document.getElementById('floating-player'); | |
| if (fp) fp.classList.remove('visible'); | |
| document.querySelector('.toc-mobile-btn')?.style.removeProperty('opacity'); | |
| document.querySelector('.tts-settings-btn')?.style.removeProperty('opacity'); | |
| } | |
| function setFloatingPlayIcon(playing) { | |
| const btn = document.querySelector('.fp-play-btn'); | |
| if (btn) btn.textContent = playing ? '⏸' : '▶'; | |
| } | |
| function formatTime(sec) { | |
| if (isNaN(sec)) return '0:00'; | |
| const m = Math.floor(sec / 60), s = Math.floor(sec % 60); | |
| return m + ':' + (s < 10 ? '0' : '') + s; | |
| } | |
| function updateFloatingProgress() { | |
| if (!currentDoubaoAudio) return; | |
| const bar = document.querySelector('.fp-progress'); | |
| const timeEl = document.querySelector('.fp-time'); | |
| if (!bar || !timeEl) return; | |
| if (!currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return; | |
| bar.value = (currentDoubaoAudio.currentTime / currentDoubaoAudio.duration) * 100; | |
| timeEl.textContent = formatTime(currentDoubaoAudio.currentTime) + ' / ' + formatTime(currentDoubaoAudio.duration); | |
| } | |
| window.toggleFloatingPlay = function() { | |
| if (isPlaying()) { pauseAllAudio(); setFloatingPlayIcon(false); } | |
| else { resumeAllAudio(); setFloatingPlayIcon(true); } | |
| }; | |
| // ========== 豆包 TTS(带 IndexedDB 缓存)========== | |
| async function doubaoFetch(text) { | |
| const apiKey = localStorage.getItem('doubao_api_key'); | |
| const speaker = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts'; | |
| const speed = parseFloat(localStorage.getItem('tts_speed')) || 1.0; | |
| if (!apiKey) throw new Error('未设置 API Key'); | |
| // 先查缓存 | |
| const key = cacheKey(text, speaker); | |
| const cached = await cacheGet(key).catch(() => null); | |
| if (cached && cached.byteLength > 0) { | |
| console.log('📦 TTS cache hit', (cached.byteLength / 1024).toFixed(1) + 'KB'); | |
| refreshCacheSizeDisplay(); | |
| return cached; | |
| } | |
| const speechRate = Math.round((speed - 1) * 100); | |
| const resp = await fetch('/api/tts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text, api_key: apiKey, speaker, speech_rate: speechRate }) | |
| }); | |
| if (!resp.ok) { | |
| const errText = await resp.text(); | |
| throw new Error('HTTP ' + resp.status + ': ' + errText.substring(0, 200)); | |
| } | |
| const respText = await resp.text(); | |
| const lines = respText.split('\n').filter(l => l.trim()); | |
| let allBase64 = '', lastError = null; | |
| for (const line of lines) { | |
| let chunk; | |
| try { chunk = JSON.parse(line); } catch (e) { continue; } | |
| if (chunk.data) { allBase64 += chunk.data; continue; } | |
| if (chunk.code !== 200 && chunk.code !== 0 && chunk.message) lastError = chunk.message; | |
| } | |
| if (!allBase64) throw new Error('未收到音频数据' + (lastError ? ': ' + lastError : '')); | |
| const binary = atob(allBase64); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| cacheSet(key, bytes).catch(() => {}); | |
| console.log('💾 TTS cached', (bytes.byteLength / 1024).toFixed(1) + 'KB'); | |
| refreshCacheSizeDisplay(); | |
| return bytes; | |
| } | |
| function playDoubao(text, btns, myGen, onDone) { | |
| if (myGen !== gen) return; | |
| doubaoLoading = true; | |
| btns.className = 'audio-controls playing'; | |
| doubaoFetch(text).then(bytes => { | |
| doubaoLoading = false; | |
| playBytesAudio(bytes, btns, myGen, onDone); | |
| }).catch(err => { | |
| doubaoLoading = false; | |
| console.error('豆包 TTS 失败:', err); | |
| if (myGen !== gen) return; | |
| alert('豆包 TTS 失败: ' + err.message + '\n已自动回退到浏览器 TTS。'); | |
| speakChunks(chunkText(text, 1000), btns, myGen, onDone); | |
| }); | |
| } | |
| // ========== Kokoro TTS(Replicate 代理,超低费用)========== | |
| async function kokoroFetch(text) { | |
| const repKey = localStorage.getItem('kokoro_api_key'); | |
| const orKey = localStorage.getItem('kokoro_openrouter_key'); | |
| const userVoice = localStorage.getItem('kokoro_voice') || 'bf_emma'; | |
| const speed = parseFloat(localStorage.getItem('tts_speed')) || 1.0; | |
| const isZh = /[\u4e00-\u9fff]/.test(text); | |
| const voice = isZh ? 'zf_xiaoyi' : userVoice; | |
| // ── 先查 Kokoro 缓存 ── | |
| const key = cacheKey(text, 'kokoro_' + voice); | |
| const cached = await cacheGet(key).catch(() => null); | |
| if (cached && cached.byteLength > 0) { | |
| console.log('📦 Kokoro cache hit', (cached.byteLength / 1024).toFixed(1) + 'KB'); | |
| refreshCacheSizeDisplay(); | |
| return cached; | |
| } | |
| // ── 未命中,调用 API ── | |
| if (isZh && !orKey) throw new Error('中文文本需要 OpenRouter API Key'); | |
| if (!isZh && !repKey) throw new Error('英文文本需要 Replicate API Key'); | |
| const resp = await fetch('/api/kokoro', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| text, replicate_key: repKey, openrouter_key: orKey, | |
| voice, speed, lang: isZh ? 'zh' : 'en', | |
| }), | |
| }); | |
| const data = await resp.json(); | |
| if (!resp.ok || data.error) throw new Error(data.error || ('HTTP ' + resp.status)); | |
| if (!data.output) throw new Error('Kokoro 未返回音频'); | |
| // ── 统一转成 Uint8Array(data URL 或远程 URL)── | |
| let bytes; | |
| if (data.output.startsWith('data:')) { | |
| const base64 = data.output.split(',')[1]; | |
| const binary = atob(base64); | |
| bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| } else { | |
| const audioResp = await fetch(data.output); | |
| const buffer = await audioResp.arrayBuffer(); | |
| bytes = new Uint8Array(buffer); | |
| } | |
| // ── 写入缓存 ── | |
| cacheSet(key, bytes).catch(() => {}); | |
| console.log('💾 Kokoro cached', (bytes.byteLength / 1024).toFixed(1) + 'KB'); | |
| refreshCacheSizeDisplay(); | |
| return bytes; | |
| } | |
| function playKokoro(text, btns, myGen, onDone) { | |
| if (myGen !== gen) return; | |
| doubaoLoading = true; | |
| btns.className = 'audio-controls playing'; | |
| kokoroFetch(text).then(bytes => { | |
| doubaoLoading = false; | |
| playBytesAudio(bytes, btns, myGen, onDone); | |
| }).catch(err => { | |
| doubaoLoading = false; | |
| console.error('Kokoro TTS 失败:', err); | |
| if (myGen !== gen) return; | |
| alert('Kokoro TTS 失败: ' + err.message + '\n已自动回退到浏览器 TTS。'); | |
| speakChunks(chunkText(text, 1000), btns, myGen, onDone); | |
| }); | |
| } | |
| // ========== 公共:从 Uint8Array 缓存播放音频(Doubao/Kokoro 共用)========== | |
| function playBytesAudio(bytes, btns, myGen, onDone) { | |
| if (myGen !== gen) return; | |
| const blob = new Blob([bytes], { type: 'audio/mpeg' }); | |
| currentDoubaoAudio = new Audio(URL.createObjectURL(blob)); | |
| showProgressBar(btns); | |
| showFloatingPlayer(); | |
| currentDoubaoAudio.ontimeupdate = updateProgressBar; | |
| currentDoubaoAudio.onended = () => { | |
| if (myGen === gen) { | |
| currentDoubaoAudio = null; | |
| hideProgressBar(); | |
| hideFloatingPlayer(); | |
| if (onDone) onDone(); | |
| } | |
| }; | |
| currentDoubaoAudio.play().catch(err => { | |
| console.error('音频播放失败:', err); | |
| if (myGen === gen) { resetBtns(btns); hideProgressBar(); hideFloatingPlayer(); if (onDone) onDone(); } | |
| }); | |
| } | |
| function stripAsterisks(text) { | |
| if (!text) return ''; | |
| return text | |
| .replace(/\*\*\*(.+?)\*\*\*/g, '$1') // ***粗斜体*** → 粗斜体 | |
| .replace(/\*\*(.+?)\*\*/g, '$1') // **粗体(可含 *斜体*)** → 粗体(可含 *斜体*) | |
| .replace(/(^|\s)\*([^*\n]+)\*(?=\s|$|[.,!?;:])/g, '$1$2'); // *斜体* → 斜体 | |
| } | |
| // ========== 统一播放入口 ========== | |
| async function playText(text, btns, cid, onDone) { | |
| const clean = stripAsterisks(text); | |
| const provider = getProvider(); | |
| const isZh = /[\u4e00-\u9fff]/.test(clean); | |
| // 计算两个 provider 当前音色的缓存 key | |
| const doubaoSpeaker = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts'; | |
| const kokoroVoice = isZh ? 'zf_xiaoyi' : (localStorage.getItem('kokoro_voice') || 'bf_emma'); | |
| const doubaoKey = cacheKey(clean, doubaoSpeaker); | |
| const kokoroKey = cacheKey(clean, 'kokoro_' + kokoroVoice); | |
| // ── 规则 1:当前选 Kokoro,但有 Doubao 缓存 → 静默用 Doubao 缓存 ── | |
| if (provider === 'kokoro') { | |
| const doubaoCached = await cacheGet(doubaoKey).catch(() => null); | |
| if (doubaoCached && doubaoCached.byteLength > 0) { | |
| console.log('⚡ Doubao cache exists, using it instead of Kokoro API'); | |
| playBytesAudio(doubaoCached, btns, gen, onDone); | |
| return; | |
| } | |
| playKokoro(clean, btns, gen, onDone); | |
| return; | |
| } | |
| // ── 规则 2:当前选 Doubao,但有 Kokoro 缓存 → 弹窗确认是否覆盖 ── | |
| if (provider === 'doubao') { | |
| const kokoroCached = await cacheGet(kokoroKey).catch(() => null); | |
| if (kokoroCached && kokoroCached.byteLength > 0) { | |
| const proceed = confirm( | |
| '这段文本已有 Kokoro 缓存。\n\n要用 Doubao 重新生成并覆盖吗?\n(将消耗一次 Doubao API 调用)' | |
| ); | |
| if (proceed) { | |
| playDoubao(clean, btns, gen, onDone); // 内部会写入 Doubao 缓存 | |
| } else { | |
| playBytesAudio(kokoroCached, btns, gen, onDone); | |
| } | |
| return; | |
| } | |
| playDoubao(clean, btns, gen, onDone); | |
| return; | |
| } | |
| // ── Browser 模式 ── | |
| speakChunks(chunkText(clean, 1000), btns, gen, onDone); | |
| } | |
| function speakText(text, btns, cid) { | |
| stopAllAudio(); gen++; currentCid = cid; chunkIdx = 0; | |
| playText(text, btns, cid); | |
| } | |
| function resetBtns(btns) { btns.className = 'audio-controls'; } | |
| function stopAllAudio() { | |
| speechSynthesis.cancel(); | |
| if (currentDoubaoAudio) { currentDoubaoAudio.pause(); currentDoubaoAudio.currentTime = 0; currentDoubaoAudio = null; } | |
| doubaoLoading = false; | |
| hideProgressBar(); | |
| hideFloatingPlayer(); | |
| } | |
| function pauseAllAudio() { | |
| if (getProvider() !== 'browser') { if (currentDoubaoAudio) currentDoubaoAudio.pause(); } | |
| else speechSynthesis.pause(); | |
| setFloatingPlayIcon(false); | |
| } | |
| function resumeAllAudio() { | |
| if (getProvider() !== 'browser') { if (currentDoubaoAudio) currentDoubaoAudio.play(); } | |
| else speechSynthesis.resume(); | |
| setFloatingPlayIcon(true); | |
| } | |
| function isPlaying() { | |
| if (getProvider() !== 'browser') return !!(currentDoubaoAudio && !currentDoubaoAudio.paused) || doubaoLoading; | |
| return speechSynthesis.speaking && !speechSynthesis.paused; | |
| } | |
| function isPaused() { | |
| if (getProvider() !== 'browser') return !!(currentDoubaoAudio && currentDoubaoAudio.paused); | |
| return speechSynthesis.paused; | |
| } | |
| // ========== 单节播放控制 ========== | |
| window.togglePlay = function(el) { | |
| const btns = el.closest('.audio-controls'); | |
| const block = el.closest('.note-block'); | |
| const cid = block.getAttribute('data-cell-id'); | |
| const text = block.dataset.text; | |
| if (isPaused() && savedBookmark && savedBookmark.cid === cid) { | |
| resumeAllAudio(); btns.className = 'audio-controls playing'; savedBookmark = null; return; | |
| } | |
| savedBookmark = null; isPlayAll = false; | |
| speakText(text, btns, cid); | |
| }; | |
| window.pausePlayback = function() { | |
| pauseAllAudio(); | |
| if (currentCid) savedBookmark = { cid: currentCid }; | |
| const playing = document.querySelector('.audio-controls.playing'); | |
| if (playing) playing.className = 'audio-controls paused'; | |
| }; | |
| window.stopPlayback = function() { | |
| gen++; chunkIdx = 0; currentCid = null; savedBookmark = null; | |
| stopAllAudio(); isPlayAll = false; playAllQueue = []; | |
| document.querySelectorAll('.audio-controls.playing, .audio-controls.paused').forEach(b => { b.className = 'audio-controls'; }); | |
| }; | |
| // ========== 连播控制 ========== | |
| window.togglePlayAll = function(el) { | |
| const btns = el.closest('.audio-controls'); | |
| if (isPaused() && savedBookmark && savedBookmark.isPlayAll) { | |
| resumeAllAudio(); btns.className = 'audio-controls audio-playall playing'; savedBookmark = null; return; | |
| } | |
| savedBookmark = null; | |
| const sections = [...document.querySelectorAll('.note-block[data-text]')]; | |
| if (!sections.length) return; | |
| gen++; chunkIdx = 0; currentCid = null; stopAllAudio(); | |
| playAllQueue = sections.map(s => s.dataset.text); | |
| isPlayAll = true; | |
| playNext(btns); | |
| }; | |
| window.pauseAll = function() { | |
| pauseAllAudio(); | |
| const playing = document.querySelector('.audio-controls.playing'); | |
| if (playing) playing.className = 'audio-controls paused'; | |
| savedBookmark = { isPlayAll: true }; | |
| }; | |
| window.stopAll = function() { stopPlayback(); }; | |
| function playNext(playallBtns) { | |
| if (!playAllQueue.length || !isPlayAll) { | |
| if (playallBtns) playallBtns.className = 'audio-controls audio-playall'; | |
| isPlayAll = false; return; | |
| } | |
| if (playallBtns) playallBtns.className = 'audio-controls audio-playall playing'; | |
| const text = playAllQueue.shift(); | |
| currentPlayAllText = text; | |
| playText(text, playallBtns, null, () => playNext(playallBtns)); | |
| } | |
| // ========== 初始化 ========== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initProviderButtons(); | |
| loadTtsSettings(); | |
| bindTtsSettingsUi(); | |
| refreshCacheSizeDisplay(); | |
| if (document.body) document.body.addEventListener('htmx:afterSwap', () => { | |
| initProviderButtons(); loadTtsSettings(); bindTtsSettingsUi(); refreshCacheSizeDisplay(); | |
| }); | |
| }); | |
| })(); | |
| </script> | |
| """) | |
| # %% ../nbs/01_app.ipynb #b8147943 | |
| _custom_css = Style(Path('custom.css').read_text()) if Path('custom.css').exists() else '' | |
| _navbar_hide_css = Style(""" | |
| #site-navbar.hidden { | |
| transform: translateY(-110%); | |
| } | |
| """) | |
| hdrs = (*Theme.slate.headers(highlightjs=True), | |
| Link(rel="icon", href="/public/favicon.ico"), | |
| Script(src="https://unpkg.com/hyperscript.org@0.9.12"), | |
| Link(rel="preconnect", href="https://fonts.googleapis.com"), Link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""), | |
| Link(rel="stylesheet", href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap"), | |
| Style("body { font-family: 'IBM Plex Sans', sans-serif; } code, pre { font-family: 'IBM Plex Mono', monospace; }"), | |
| sidenote_css, cell_css, AUDIO_CSS, _custom_css, annotation_js, AUDIO_JS, toc_css, mobile_toc_css, _navbar_hide_css, *tts_settings_ui()) | |
| app = FastHTML(hdrs=hdrs, debug=True) | |
| app.mount("/public", StaticFiles(directory="public"), name="public") | |
| rt = app.route | |
| # %% ../nbs/01_app.ipynb #5d2235f1 | |
| # %% Doubao TTS 代理路由 | |
| @rt('/api/tts') | |
| async def tts_proxy(request): | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| return Response('{"error":"invalid JSON body"}', media_type='application/json', status_code=400) | |
| api_key = body.get("api_key", "") | |
| text = body.get("text", "") | |
| speaker = body.get("speaker", "zh_female_vv_uranus_bigtts") | |
| if not api_key: | |
| return Response('{"error":"missing api_key"}', media_type='application/json', status_code=400) | |
| if not text: | |
| return Response('{"error":"missing text"}', media_type='application/json', status_code=400) | |
| headers = { | |
| "X-Api-Key": api_key, | |
| "X-Api-Resource-Id": "seed-tts-2.0", | |
| "X-Api-Request-Id": str(uuid.uuid4()), | |
| "Content-Type": "application/json", | |
| } | |
| speech_rate = body.get("speech_rate", 0) | |
| payload = json.dumps({ | |
| "req_params": { | |
| "text": text, | |
| "speaker": speaker, | |
| "audio_params": {"format": "mp3", "sample_rate": 24000, "speech_rate": speech_rate}, | |
| } | |
| }).encode() | |
| target = "https://openspeech.bytedance.com/api/v3/tts/unidirectional" | |
| # 优先用 httpx,没有则用 urllib 兜底 | |
| try: | |
| import httpx | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| resp = await client.post(target, headers=headers, content=payload) | |
| return Response(resp.content, media_type="text/plain", status_code=resp.status_code) | |
| except ImportError: | |
| pass | |
| import urllib.request, urllib.error | |
| req = urllib.request.Request(target, data=payload, headers=headers, method="POST") | |
| try: | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| return Response(resp.read(), media_type="text/plain", status_code=200) | |
| except urllib.error.HTTPError as e: | |
| return Response(e.read(), media_type="text/plain", status_code=e.code) | |
| except Exception as e: | |
| return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502) | |
| # %% ../nbs/01_app.ipynb #32c6709b | |
| # %% Kokoro TTS 代理路由(英文→Replicate,中文→OpenRouter) | |
| KOKORO_VERSION = 'jaaari/kokoro-82m:f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13' | |
| KOKORO_CHINESE_RE = re.compile(r'[\u4e00-\u9fff]') | |
| @rt('/api/kokoro') | |
| async def kokoro_proxy(request): | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| return Response('{"error":"invalid JSON body"}', media_type='application/json', status_code=400) | |
| text = body.get("text", "") | |
| if not text: | |
| return Response('{"error":"missing text"}', media_type='application/json', status_code=400) | |
| is_chinese = bool(KOKORO_CHINESE_RE.search(text)) | |
| try: | |
| import httpx | |
| if is_chinese: | |
| return await _kokoro_openrouter(httpx, body, text) | |
| else: | |
| return await _kokoro_replicate(httpx, body, text) | |
| except ImportError: | |
| pass | |
| # urllib fallback | |
| if is_chinese: | |
| return _kokoro_openrouter_urllib(body, text) | |
| else: | |
| return _kokoro_replicate_urllib(body, text) | |
| async def _kokoro_replicate(client, body, text): | |
| api_key = body.get("replicate_key") or body.get("api_key", "") | |
| voice = body.get("voice", "bf_emma") | |
| speed = float(body.get("speed", 0.9)) | |
| if not api_key: | |
| return Response('{"error":"missing replicate_key"}', media_type='application/json', status_code=400) | |
| version_hash = KOKORO_VERSION.split(':')[1] | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Prefer": "wait"} | |
| payload = {"version": version_hash, "input": {"text": text[:10000], "voice": voice, "speed": speed}} | |
| try: | |
| async with client.AsyncClient(timeout=30) as c: | |
| resp = await c.post("https://api.replicate.com/v1/predictions", headers=headers, json=payload) | |
| if resp.status_code >= 400: | |
| return Response(resp.content, media_type="application/json", status_code=resp.status_code) | |
| result = resp.json() | |
| attempts = 0 | |
| while result.get("status") not in ("succeeded", "failed") and attempts < 60: | |
| await asyncio.sleep(1) | |
| pr = await c.get(f"https://api.replicate.com/v1/predictions/{result['id']}", | |
| headers={"Authorization": f"Bearer {api_key}"}) | |
| result = pr.json() | |
| attempts += 1 | |
| if result.get("status") == "failed": | |
| return Response(json.dumps({"error": result.get("error", "unknown")}), | |
| media_type="application/json", status_code=502) | |
| if result.get("status") != "succeeded": | |
| return Response(json.dumps({"error": "Kokoro timed out"}), | |
| media_type="application/json", status_code=504) | |
| return Response(json.dumps({"output": result.get("output")}), media_type="application/json") | |
| except Exception as e: | |
| import traceback | |
| tb = traceback.format_exc() | |
| print(f"[Kokoro OpenRouter ERROR] {type(e).__name__}: {e!r}") | |
| print(tb) | |
| return Response(json.dumps({"error": f"proxy error: {type(e).__name__}: {e!r}"}), | |
| media_type="application/json", status_code=502) | |
| async def _kokoro_openrouter(client, body, text): | |
| api_key = body.get("openrouter_key", "") | |
| voice = body.get("voice", "zf_xiaoyi") | |
| if not voice.startswith("zf_"): | |
| voice = "zf_xiaoyi" | |
| if not api_key: | |
| return Response('{"error":"missing openrouter_key"}', media_type='application/json', status_code=400) | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} | |
| payload = {"model": "hexgrad/kokoro-82m", "input": text[:10000], "voice": voice, "response_format": "mp3"} | |
| import base64 | |
| last_err = None | |
| for attempt in range(3): | |
| try: | |
| async with client.AsyncClient(timeout=60) as c: | |
| resp = await c.post("https://openrouter.ai/api/v1/audio/speech", headers=headers, json=payload) | |
| if resp.status_code == 429: | |
| # 速率限制,等 2 秒后重试 | |
| await asyncio.sleep(2) | |
| last_err = f"OpenRouter rate limited (429), attempt {attempt+1}" | |
| continue | |
| if resp.status_code >= 400: | |
| return Response(resp.content, media_type="application/json", status_code=resp.status_code) | |
| b64 = base64.b64encode(resp.content).decode() | |
| data_url = f"data:audio/mpeg;base64,{b64}" | |
| return Response(json.dumps({"output": data_url}), media_type="application/json") | |
| except Exception as e: | |
| last_err = f"{type(e).__name__}: {e!r}" | |
| print(f"[Kokoro OpenRouter attempt {attempt+1} ERROR] {last_err}") | |
| if attempt < 2: | |
| await asyncio.sleep(1.5 * (attempt + 1)) | |
| import traceback | |
| print(f"[Kokoro OpenRouter all retries failed] {last_err}") | |
| return Response(json.dumps({"error": f"proxy error: {last_err}"}), media_type="application/json", status_code=502) | |
| def _kokoro_replicate_urllib(body, text): | |
| import urllib.request, urllib.error, time | |
| api_key = body.get("replicate_key") or body.get("api_key", "") | |
| voice = body.get("voice", "bf_emma") | |
| speed = float(body.get("speed", 0.9)) | |
| if not api_key: | |
| return Response('{"error":"missing replicate_key"}', media_type='application/json', status_code=400) | |
| version_hash = KOKORO_VERSION.split(':')[1] | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Prefer": "wait"} | |
| payload = json.dumps({"version": version_hash, "input": {"text": text[:10000], "voice": voice, "speed": speed}}).encode() | |
| try: | |
| req = urllib.request.Request("https://api.replicate.com/v1/predictions", data=payload, headers=headers, method="POST") | |
| with urllib.request.urlopen(req, timeout=30) as resp: | |
| result = json.loads(resp.read()) | |
| attempts = 0 | |
| while result.get("status") not in ("succeeded", "failed") and attempts < 60: | |
| time.sleep(1) | |
| pr = urllib.request.Request(f"https://api.replicate.com/v1/predictions/{result['id']}", | |
| headers={"Authorization": f"Bearer {api_key}"}) | |
| with urllib.request.urlopen(pr, timeout=10) as r: | |
| result = json.loads(r.read()) | |
| attempts += 1 | |
| if result.get("status") == "failed": | |
| return Response(json.dumps({"error": result.get("error", "unknown")}), media_type="application/json", status_code=502) | |
| if result.get("status") != "succeeded": | |
| return Response(json.dumps({"error": "Kokoro timed out"}), media_type="application/json", status_code=504) | |
| return Response(json.dumps({"output": result.get("output")}), media_type="application/json") | |
| except urllib.error.HTTPError as e: | |
| return Response(e.read(), media_type="application/json", status_code=e.code) | |
| except Exception as e: | |
| return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502) | |
| def _kokoro_openrouter_urllib(body, text): | |
| import urllib.request, urllib.error, base64 | |
| api_key = body.get("openrouter_key", "") | |
| voice = body.get("voice", "zf_xiaoyi") | |
| if not api_key: | |
| return Response('{"error":"missing openrouter_key"}', media_type='application/json', status_code=400) | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} | |
| payload = json.dumps({"model": "hexgrad/kokoro-82m", "input": text[:10000], "voice": voice, "response_format": "mp3"}).encode() | |
| try: | |
| req = urllib.request.Request("https://openrouter.ai/api/v1/audio/speech", data=payload, headers=headers, method="POST") | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| audio_bytes = resp.read() | |
| b64 = base64.b64encode(audio_bytes).decode() | |
| return Response(json.dumps({"output": f"data:audio/mpeg;base64,{b64}"}), media_type="application/json") | |
| except urllib.error.HTTPError as e: | |
| return Response(e.read(), media_type="application/json", status_code=e.code) | |
| except Exception as e: | |
| return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502) | |
| # %% ../nbs/01_app.ipynb #2088b609 | |
| def theme_toggle(): | |
| return Button(UkIcon("moon", cls="dark:hidden"), UkIcon("sun", cls="hidden dark:block"), | |
| _="""on click | |
| toggle .dark on <html/> | |
| set franken to (localStorage's __FRANKEN__ or '{}') as Object | |
| if the first <html/> matches .dark set franken's mode to 'dark' else set franken's mode to 'light' end | |
| set localStorage's __FRANKEN__ to franken as JSON""", | |
| cls="p-1 hover:scale-110 shadow-none", type="button") | |
| # %% ../nbs/01_app.ipynb #28164a2b | |
| def x_icon(): return Svg(ft_hx("path", d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"), width=20, height=20, fill="currentColor", viewBox="0 0 16 16", aria_hidden="true") | |
| # %% ../nbs/01_app.ipynb #5a14c5bb | |
| def social_link(k, v): | |
| ext = dict(rel="nofollow noindex") if k == "mail" else {} if k == "rss" else dict(target="_blank", rel="noopener noreferrer") | |
| return A(x_icon() if k == "twitter" else UkIcon(k, width=20, height=20), href=v, aria_label=k.title(), cls="hover:text-primary transition-colors", **ext) | |
| # %% ../nbs/01_app.ipynb #a9e6b9d9 | |
| _social_urls = dict(github='https://github.com/{}', twitter='https://x.com/{}', linkedin='https://www.linkedin.com/in/{}/') | |
| socials = {k: _social_urls[k].format(cfg[k]) for k in _social_urls if cfg.get(k)} | |
| socials['rss'] = '/rss.xml' | |
| if cfg.get('email'): socials['mail'] = '/contact' | |
| ftr_content = Div(*[social_link(k,v) for k,v in socials.items()], cls="flex justify-center gap-6 text-muted-foreground") | |
| # %% ../nbs/01_app.ipynb #607a4561 | |
| def read(p): return nb2md(p) if p.suffix=='.ipynb' else frontmatter(p.read_text()) | |
| # %% ../nbs/01_app.ipynb #b3bd4ebc | |
| pages = {p.stem.split('_',1)[1]: read(p) for p in Path('public/pages').ls(file_exts=['.md','.ipynb']).sorted()} | |
| # %% ../nbs/01_app.ipynb #1c8779ee | |
| def hx_attrs(url, target="#main-content"): return dict(hx_get=url, hx_target=target, hx_push_url="true", hx_swap="innerHTML show:window:top") | |
| def hx_link(txt, href, cls="text-primary underline", target="#main-content", **kw): return A(txt, cls=cls, **hx_attrs(href, target), **kw) | |
| # %% ../nbs/01_app.ipynb #3a832a32 | |
| def navbar(): | |
| menu_id,btn_id = f"menu-{unqid()}",f"btn-{unqid()}" | |
| search_id = f"search-{unqid()}" | |
| results_id = f"results-{unqid()}" | |
| brand = A(Img(src="/public/nav.png", alt=cfg.name, cls="w-6 h-6 rounded-full"), Span(cfg.name), href="/", cls="flex items-center gap-2 text-lg font-bold", **hx_attrs("/")) | |
| def navlinks(_=None): return [hx_link("Blog", "/blog", cls="hover:scale-110", _=_), *[hx_link(s.title(), f"/{s}", cls="hover:scale-110", _=_) for s in pages]] | |
| hamburger = Button(UkIcon("menu", width=30, height=30), cls="p-0 border-0 shadow-none", _=f"on click toggle .hidden on #{menu_id}", type="button", id=btn_id) | |
| search_container = Div(cls="search-container")( | |
| Label(UkIcon("search", width=20, height=20), cls="search-icon md:hidden", | |
| _for=search_id), | |
| Input(placeholder="Search posts...", id=search_id, | |
| cls="search-input w-40 text-sm px-3 py-1.5", | |
| oninput=f"doSearch(this.value, '{results_id}')"), | |
| Button("✕", cls="search-clear md:hidden", type="button", | |
| onclick=f"this.previousElementSibling.value=''; doSearch('', '{results_id}')") | |
| ) | |
| search_results = Div(id=results_id, cls="search-results absolute top-full left-0 right-0 max-h-96 overflow-y-auto hidden z-50") | |
| search_js = NotStr(fr""" | |
| <script> | |
| const search_data = {search_index}; | |
| document.addEventListener('click', (e) => {{ | |
| const results = document.querySelector('.search-results'); | |
| if (results.classList.contains('hidden')) return; | |
| if (e.target.closest('.search-container') || e.target.closest('.search-results')) return; | |
| results.classList.add('hidden'); | |
| }}); | |
| document.addEventListener('keydown', (e) => {{ | |
| if (e.key === 'Escape') {{ | |
| document.querySelector('.search-results').classList.add('hidden'); | |
| }} | |
| }}); | |
| function matchQuery(title, q) {{ | |
| const words = q.trim().split(/\s+/).filter(Boolean); | |
| return words.every(w => {{ | |
| const tl = title.toLowerCase(), wl = w.toLowerCase(); | |
| if (tl.includes(wl)) return true; | |
| let si = 0; | |
| for (let c of wl) | |
| if ((si = tl.indexOf(c, si) + 1) === 0) return false; | |
| return true; | |
| }}); | |
| }} | |
| function highlight(title, q) {{ | |
| if (!q) return title; | |
| const words = q.trim().split(/\s+/).filter(Boolean); | |
| const tl = title.toLowerCase(); | |
| const exact = new Array(title.length).fill(false); | |
| const fuzzy = new Array(title.length).fill(false); | |
| for (const w of words) {{ | |
| const wl = w.toLowerCase(); | |
| // Step 1: exact substring? → PINK | |
| const idx = tl.indexOf(wl); | |
| if (idx !== -1) {{ | |
| for (let i = idx; i < idx + wl.length; i++) exact[i] = true; | |
| }} else {{ | |
| // Step 2: fuzzy within a single word? → YELLOW | |
| const textWords = tl.split(/\s+/); | |
| let found = false; | |
| for (const tw of textWords) {{ | |
| let si = 0, first = -1, last = -1; | |
| for (let j = 0; j < tw.length; j++) {{ | |
| if (si < wl.length && tw[j] === wl[si]) {{ | |
| if (first === -1) first = j; last = j; si++; | |
| }} | |
| }} | |
| if (si === wl.length) {{ | |
| // Found! Map back to title positions | |
| const wordStart = tl.indexOf(tw); | |
| for (let j = first; j <= last; j++) {{ | |
| if (tw[j - first] && wl.includes(tw[j]) && !exact[wordStart + j]) fuzzy[wordStart + j] = true; | |
| }} | |
| found = true; break; | |
| }} | |
| }} | |
| }} | |
| }} | |
| let result = ''; | |
| for (let i = 0; i < title.length; i++) {{ | |
| if (exact[i]) result += `<span class="search-highlight-exact">${{title[i]}}</span>`; | |
| else if (fuzzy[i]) result += `<span class="search-highlight">${{title[i]}}</span>`; | |
| else result += title[i]; | |
| }} | |
| return result; | |
| }} | |
| function doSearch(q, resultsId) {{ | |
| const el = document.getElementById(resultsId); | |
| if (!q.trim()) {{ el.classList.add('hidden'); el.innerHTML=''; return; }} | |
| const matched = search_data.filter(c => | |
| matchQuery(c.title, q) || | |
| (c.summary && matchQuery(c.summary, q)) || | |
| (c.headings && c.headings.some(h => matchQuery(h.text, q))) | |
| ); | |
| const words = q.trim().split(/\s+/).filter(Boolean); | |
| const enriched = matched.map(c => ({{ | |
| ...c, | |
| titleMatch: matchQuery(c.title, q), | |
| summaryMatch: c.summary ? matchQuery(c.summary, q) : false, | |
| headingMatches: c.headings | |
| ? c.headings.filter(h => {{ | |
| const hWords = h.text.toLowerCase().split(/\s+/); | |
| return words.some(w => {{ | |
| const wl = w.toLowerCase(); | |
| return hWords.some(hWord => {{ | |
| if (hWord.includes(wl)) return true; | |
| let si = 0; | |
| for (const ch of wl) if ((si = hWord.indexOf(ch, si) + 1) === 0) return false; | |
| return true; | |
| }}); | |
| }}); | |
| }}) | |
| : [] | |
| }})); | |
| console.log(enriched) | |
| function scorePost(c, q) {{ | |
| const ql = q.toLowerCase(); | |
| const words = q.trim().split(/\s+/).filter(Boolean); | |
| // Tier 1: full phrase as continuous substring | |
| if (c.title && c.title.toLowerCase().includes(ql)) return 1000; | |
| if (c.summary && c.summary.toLowerCase().includes(ql)) return 950; | |
| if (c.headings && c.headings.some(h => h.text.toLowerCase().includes(ql))) return 900; | |
| // Tier 2: each word exact independently | |
| let exactCount = 0, exactScore = 0; | |
| for (const w of words) {{ | |
| const wl = w.toLowerCase(); | |
| if (c.title && c.title.toLowerCase().includes(wl)) {{ exactCount++; exactScore += 10; }} | |
| else if (c.summary && c.summary.toLowerCase().includes(wl)) {{ exactCount++; exactScore += 5; }} | |
| else if (c.headings && c.headings.some(h => h.text.toLowerCase().includes(wl))) {{ exactCount++; exactScore += 3; }} | |
| }} | |
| if (words.length >= 2 && exactCount === words.length) return 100 + exactScore; | |
| if (exactCount > 0) return 50 + exactScore; | |
| // Tier 3: fuzzy | |
| let fuzzyCount = 0; | |
| const fields = [c.title || '', c.summary || '', | |
| ...(c.headings || []).map(h => h.text)].join(' ').toLowerCase(); | |
| for (const w of words) {{ | |
| const wl = w.toLowerCase(); | |
| for (const tw of fields.split(/\s+/)) {{ | |
| let si = 0; | |
| for (let j = 0; j < tw.length && si < wl.length; j++) {{ | |
| if (tw[j] === wl[si]) si++; | |
| }} | |
| if (si === wl.length) {{ fuzzyCount++; console.log(` "${{w}}" matched word "${{tw}}"`); break; }} | |
| }} | |
| }} | |
| if (fuzzyCount > 0) console.log(`Fuzzy: ${{c.title}} — ${{fuzzyCount}} match(es), score=${{10 + 5 * fuzzyCount}}`); | |
| return fuzzyCount > 0 ? 10 + 5 * fuzzyCount : 0; | |
| }} | |
| if (!matched.length) {{ el.innerHTML = '<div class="p-3 text-sm text-muted-foreground">No posts found</div>'; el.classList.remove('hidden'); return; }} | |
| // ── INSERT HERE (after early return, before el.innerHTML) ── | |
| enriched.sort((a, b) => scorePost(b, q) - scorePost(a, q)); | |
| console.log(enriched.map(c => ({{title: c.title, score: scorePost(c, q)}}))) | |
| const visible = enriched.filter(c => scorePost(c, q) > 0); | |
| el.innerHTML = visible.map(c => {{ | |
| const titleHtml = c.titleMatch | |
| ? `<a href="/blog/${{c.slug}}" hx-get="/blog/${{c.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top" onclick="document.getElementById('${{resultsId}}').classList.add('hidden')" class="search-result-item block p-3 cursor-pointer text-sm"><span class="font-medium">${{highlight(c.title, q)}}</span></a>` | |
| : `<div class="search-result-item block p-3 text-sm"><span class="font-medium">${{highlight(c.title, q)}}</span></div>`; | |
| const headingHtml = c.headingMatches.length | |
| ? `<ul class="list-disc pl-6 mt-1 space-y-0.5">${{c.headingMatches.map(h => | |
| `<li><a href="/blog/${{c.slug}}#${{h.slug}}" hx-get="/blog/${{c.slug}}#${{h.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" onclick="document.getElementById('${{resultsId}}').classList.add('hidden'); setTimeout(() => document.getElementById('${{h.slug}}')?.scrollIntoView({{behavior:'smooth'}}), 150)" class="text-sm cursor-pointer">${{highlight(h.text, q)}}</a></li>` | |
| ).join('')}}</ul>` | |
| : ''; | |
| const summaryHtml = c.summary | |
| ? c.summaryMatch | |
| ? `<a href="/blog/${{c.slug}}" hx-get="/blog/${{c.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top" onclick="document.getElementById('${{resultsId}}').classList.add('hidden')" class="block text-xs text-muted-foreground pl-3 mt-0.5 mb-1 hover:underline">${{highlight(c.summary, q)}}</a>` | |
| : `<div class="text-xs text-muted-foreground pl-3 mt-0.5 mb-1">${{highlight(c.summary, q)}}</div>` | |
| : ''; | |
| // Line 115 (updated return): | |
| return titleHtml + summaryHtml + headingHtml; | |
| }}).join(''); | |
| el.classList.remove('hidden'); | |
| htmx.process(el); | |
| }} | |
| </script>""") | |
| return Nav(id="site-navbar", cls="border rounded-lg shadow backdrop-blur-md bg-background/98 relative transition-transform duration-300 ease-out")( | |
| Div(cls="search-nav-row flex items-center p-4 gap-4")( | |
| brand, search_container, | |
| Div(*navlinks(), theme_toggle(), cls="hidden md:flex items-center space-x-4 ml-auto"), | |
| Div(theme_toggle(), hamburger, cls="flex md:hidden items-center gap-4 ml-auto"), | |
| ), | |
| Div(*navlinks(_=f"on click add .hidden to #{menu_id}"), cls="flex flex-col space-y-4 p-4 text-center hidden", id=menu_id, _=f"on click elsewhere if event.target.closest('#{btn_id}') is null add .hidden to me"), | |
| search_results, search_js | |
| ) | |
| # %% ../nbs/01_app.ipynb #4c65aa23 | |
| def layout(*content, htmx, title=None, wide=False): | |
| w = 'max-w-5xl' if wide else 'max-w-2xl' | |
| main = Main(*content, cls=f'w-full {w} mx-auto px-6 py-8 space-y-8', id="main-content") | |
| if htmx and htmx.request: | |
| main.attrs['hx-swap-oob'] = 'true' | |
| return (Title(title), *content, main) | |
| ctr_cls = 'w-full max-w-2xl mx-auto' | |
| return Title(title), Div(cls="flex flex-col min-h-screen")( | |
| Div(navbar(), cls=f'{ctr_cls} px-4 sticky top-0 z-50 mt-4'), | |
| main, | |
| Footer(Divider(), subscribe_form(), ftr_content, cls=f'{ctr_cls} px-6 mt-auto mb-6 space-y-6') | |
| ) | |
| # %% ../nbs/01_app.ipynb #f9eebe98 | |
| def subscribe_form(): | |
| return Div( | |
| P("Subscribe via ", A("RSS", href="/rss.xml", cls="text-primary underline"), " or enter your email to get notified of new posts directly in your inbox", cls="text-sm text-muted-foreground mb-2"), | |
| Form(Input(type="email", name="email", placeholder="your@email.com", required=True, cls="flex-1 rounded-l-md"), Button("Subscribe", cls=(ButtonT.primary, "rounded-l-none rounded-r-md")), cls="flex", hx_post="/subscribe", hx_swap="outerHTML"), | |
| cls="mt-6") | |
| # %% ../nbs/01_app.ipynb #78b5b275 | |
| class Post: | |
| def __init__(self, path): | |
| self.path,self.slug = (p := Path(path)),p.stem | |
| self.meta, self.content = read(p) | |
| self.title,self.excerpt,self.tags = self.meta['title'],self.meta.get('excerpt',''),L(self.meta.get('tags', [])) | |
| self.date = d if isinstance(d:=self.meta['date'], date) else parse_date(str(d)).date() | |
| self.datestr = self.date.strftime('%d %b %Y') | |
| self.external_url = self.meta.get('external_url') | |
| self.unlisted = self.meta.get('unlisted', False) | |
| self.img_dir = '/'+str(self.path.parent) | |
| # %% ../nbs/01_app.ipynb #45b855d2 | |
| posts = {p.slug: p for p in L(Path('public/posts').rglob('*')).filter(lambda p: p.suffix in ('.md','.ipynb') and not any(part.startswith('.') for part in p.parts)).map(Post).sorted(key=lambda p: p.date, reverse=True)} | |
| # %% ../nbs/01_app.ipynb #764a3eac | |
| def build_search_index(): | |
| "Build a JSON-serializable search index of all post titles and slugs, with heading data" | |
| idx = [] | |
| for p in posts.values(): | |
| if p.unlisted: continue | |
| entry = {"title": p.title, "slug": p.slug, "summary": p.content.split('<!--INTRO-->')[0].strip()} | |
| _, hdrs = extract_headings(p.content) | |
| # Only include h2+ headings (skip h1 which is the title) | |
| entry["headings"] = [{"text": text, "slug": slug} | |
| for level, text, slug in hdrs if level >= 2] | |
| idx.append(entry) | |
| return idx | |
| # %% ../nbs/01_app.ipynb #f6413e00 | |
| search_index = json.dumps(build_search_index()) | |
| # %% ../nbs/01_app.ipynb #56666803 | |
| _pill_cls = "text-xs px-2 py-1 rounded border transition-all" | |
| def tag_label(tag): return Span(tag, cls=f"{_pill_cls} bg-muted") | |
| def tag_link(tag, target="#main-content"): return A(tag, cls=f"{_pill_cls} bg-muted hover:bg-primary/20", **hx_attrs(f"/blog?tags={quote(tag)}", target)) | |
| def tag_toggle(tag, selected, avail=None): | |
| new = ','.join(quote(t) for t in selected ^ {tag}) | |
| hx = hx_attrs(f"/blog?tags={new}" if new else "/blog", "#posts-list") | |
| if tag in selected: return A(tag, cls=f"{_pill_cls} bg-primary cursor-pointer text-primary-foreground", **hx) | |
| if avail and tag in avail: return A(tag, cls=f"{_pill_cls} cursor-pointer bg-muted hover:bg-primary/20", **hx) | |
| return Span(tag, cls=f"{_pill_cls} opacity-30 cursor-not-allowed") | |
| # %% ../nbs/01_app.ipynb #0ea3b326 | |
| def tag_filter(selected, all_posts, filtered): | |
| counts = Counter(all_posts.attrgot('tags').concat()) | |
| if not counts: return Div(id="tag-filter") | |
| avail = set(filtered.attrgot('tags').concat()) | |
| tags = sorted(counts, key=lambda t: (-counts[t], t)) | |
| btns = [tag_toggle(t, selected, avail) for t in tags] | |
| if selected: btns.append(ft_hx("button", UkIcon("x", width=12, height=12), "Clear", | |
| cls=f"{_pill_cls} flex items-center gap-1 cursor-pointer hover:shadow-sm", **hx_attrs("/blog", "#posts-list"))) | |
| return Div(Span("Filter:", cls="text-sm font-medium mr-1"), *btns, cls="pb-4 flex flex-wrap items-center gap-2", id="tag-filter") | |
| # %% ../nbs/01_app.ipynb #705d3e5f | |
| def post_intro(content): | |
| if '<!--INTRO-->' in content: return content.split('<!--INTRO-->', 1)[0].rstrip() | |
| m = re.search(r'^##\s', content, re.MULTILINE) | |
| return content[:m.start()].rstrip() if m else content | |
| # %% ../nbs/01_app.ipynb #0b615cea | |
| def post_preview(p): | |
| intro, url = post_intro(p.content), blogpost.to(slug=p.slug) | |
| return Div( | |
| Div(Span(cls="flex-1 border-t border-muted"), Span(p.datestr, cls="text-sm text-muted-foreground px-4"), Span(cls="flex-1 border-t border-muted"), cls="flex items-center my-8"), | |
| H2(hx_link(p.title, url, cls="font-bold")), from_md(intro, img_dir=p.img_dir), | |
| Div(hx_link("↪ Keep reading", url, cls="text-sm text-primary hover:underline") if intro != p.content else Span(), | |
| Div(*p.tags.map(partial(tag_link, target="#posts-list")), cls="flex gap-2 flex-wrap"), cls="flex justify-between items-center mt-4")) | |
| # %% ../nbs/01_app.ipynb #b4e04e74 | |
| def post_article(p, slug): | |
| content, headings = extract_headings(p.content) | |
| tags = Div(*p.tags.map(tag_link), cls="flex gap-2 flex-wrap") if p.tags else None | |
| toc = toc_sidebar(headings) | |
| mobile_toc = _mobile_toc(headings) | |
| article = Article(H1(p.title, cls="text-3xl font-bold mb-3"), | |
| Div(Span(p.date.strftime("%B %d, %Y"), cls="text-muted-foreground text-sm"), tags, cls="flex justify-between items-center mb-8 flex-wrap gap-4"), | |
| Div(mobile_toc, from_md(content, img_dir=p.img_dir), cls="space-y-4"), cls="max-w-2xl min-w-0 w-full") | |
| return Div(article, toc, cls="flex gap-8 items-start justify-end") | |
| # %% ../nbs/01_app.ipynb #77ee304d | |
| def render_article_sections(p, slug): | |
| "Render a blog post with per-section audio controls" | |
| fm, sections = nb2md_sections(p.path) | |
| content, headings = extract_headings(p.content) | |
| tags = Div(*p.tags.map(tag_link), cls="flex gap-2 flex-wrap") if p.tags else None | |
| toc = toc_sidebar(headings) | |
| mobile_toc = _mobile_toc(headings) | |
| playall_bar = Div( | |
| Span("▶", cls="play-btn", onclick="togglePlayAll(this)"), | |
| Span("⏸", cls="pause-btn", onclick="pauseAll(this)"), | |
| Span("⏹", cls="stop-btn", onclick="stopAll(this)"), | |
| Span("Play All", cls="ml-2 text-sm"), | |
| Input(type="range", cls="tts-progress ml-2", min="0", max="100", value="0", step="0.1", | |
| oninput="seekTts(this.value)"), | |
| # TTS 引擎切换按钮 | |
| Span("🔊 Browser", cls="tts-provider-btn ml-auto text-xs px-2 py-1 rounded cursor-pointer bg-background border border-border hover:border-primary transition-colors select-none", | |
| onclick="toggleTTSProvider(this)", title="点击切换 TTS 引擎"), | |
| cls="audio-controls audio-playall flex items-center gap-2 mb-4 p-2 bg-muted rounded" | |
| ) | |
| note_blocks = [] | |
| for cid, text, ctype in sections: | |
| if ctype == 'note': | |
| text_with_ids, _ = extract_headings(text) | |
| rendered = from_md(text_with_ids, img_dir=p.img_dir) | |
| controls = Div( | |
| Span("▶", cls="play-btn", onclick=f"togglePlay(this,'{cid}')"), | |
| Span("⏸", cls="pause-btn", onclick=f"pausePlayback()"), | |
| Span("⏹", cls="stop-btn", onclick=f"stopPlayback()"), | |
| Input(type="range", cls="tts-progress", min="0", max="100", value="0", step="0.1", | |
| oninput="seekTts(this.value)"), | |
| cls="audio-controls flex items-center gap-1" | |
| ) | |
| h_match = re.match(r'^(#{1,6})\s+(.+)$', text.strip().split('\n')[0]) | |
| if h_match and hasattr(rendered, 'children') and rendered.children: | |
| heading = rendered.children[0] | |
| rest = rendered.children[1:] | |
| note_blocks.append( | |
| Div( | |
| heading, | |
| controls, | |
| *rest, | |
| cls="note-block", **{'data-cell-id': cid, 'data-text': text} | |
| ) | |
| ) | |
| else: | |
| note_blocks.append( | |
| Div(controls, rendered, cls="note-block", | |
| **{'data-cell-id': cid, 'data-text': text}) | |
| ) | |
| else: | |
| note_blocks.append(NotStr(text)) | |
| first_section, *rest = note_blocks if note_blocks else [''] | |
| article = Article( | |
| H1(p.title, cls="text-3xl font-bold mb-3"), | |
| Div( | |
| Span(p.date.strftime("%B %d, %Y"), cls="text-muted-foreground text-sm"), | |
| tags, | |
| cls="flex justify-between items-center mb-8 flex-wrap gap-4" | |
| ), | |
| Div(mobile_toc, playall_bar, first_section, *rest, cls="space-y-4"), | |
| cls="max-w-2xl min-w-0 w-full" | |
| ) | |
| return Div(article, toc, cls="flex gap-8 items-start justify-end") | |
| # %% ../nbs/01_app.ipynb #7e30f617 | |
| @rt | |
| def index(): return RedirectResponse('/blog', status_code=307) | |
| # %% ../nbs/01_app.ipynb #1f1a91df | |
| _scrollspy_script = Script(""" | |
| (function() { | |
| if (window.__scrollspyInit) return; | |
| window.__scrollspyInit = true; | |
| let currentObserver = null; | |
| function initScrollspy() { | |
| const links = document.querySelectorAll('.toc-link'); | |
| if (!links.length) return; | |
| const ids = [...links].map(l => l.getAttribute('href').slice(1)); | |
| const headings = ids.map(id => document.getElementById(id)).filter(Boolean); | |
| if (currentObserver) currentObserver.disconnect(); | |
| currentObserver = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) { | |
| document.querySelectorAll('.toc-link.active').forEach(l => l.classList.remove('active')); | |
| const matches = document.querySelectorAll('.toc-link[href="#' + e.target.id + '"]'); | |
| matches.forEach(a => { a.classList.add('active'); a.scrollIntoView({block: 'nearest'}); }); | |
| } | |
| }); | |
| }, {rootMargin: '-20% 0px -70% 0px'}); | |
| headings.forEach(h => { if (document.querySelector('.toc-link[href="#' + h.id + '"]')) currentObserver.observe(h); }); | |
| links.forEach(l => l.addEventListener('click', () => { | |
| links.forEach(x => x.classList.remove('active')); | |
| l.classList.add('active'); | |
| })); | |
| } | |
| document.addEventListener('DOMContentLoaded', initScrollspy); | |
| document.body.addEventListener('htmx:afterSwap', initScrollspy); | |
| })(); | |
| """) | |
| # %% ../nbs/01_app.ipynb #d067cb56 | |
| _navbar_autohide_script = Script(""" | |
| (function() { | |
| if (window.__navbarAutohideInit) return; | |
| window.__navbarAutohideInit = true; | |
| const navbar = document.getElementById('site-navbar'); | |
| if (!navbar) return; | |
| let lastY = window.scrollY; | |
| let ticking = false; | |
| let lastToggle = 0; | |
| const THRESHOLD = 20; // 触发切换的最小滚动距离(原 8,提高到 20) | |
| const COOLDOWN = 250; // 两次切换之间的最小间隔(ms),覆盖 CSS 过渡 | |
| const TOP_ZONE = 60; // 距顶部此距离内始终显示 | |
| function onScroll() { | |
| const currentY = window.scrollY; | |
| const now = performance.now(); | |
| const diff = currentY - lastY; | |
| if (currentY < TOP_ZONE) { | |
| // 顶部区域:始终显示,并刷新基准点 | |
| navbar.classList.remove('hidden'); | |
| lastY = currentY; | |
| } else if (now - lastToggle > COOLDOWN) { | |
| // 冷却结束后才允许切换 | |
| if (diff > THRESHOLD) { | |
| navbar.classList.add('hidden'); | |
| lastY = currentY; // ← 只在状态切换时更新基准 | |
| lastToggle = now; | |
| } else if (diff < -THRESHOLD) { | |
| navbar.classList.remove('hidden'); | |
| lastY = currentY; // ← 只在状态切换时更新基准 | |
| lastToggle = now; | |
| } | |
| // 未过阈值:lastY 不变,滚动距离持续累积到阈值 | |
| } | |
| // 冷却中:什么都不做,lastY 也不变 | |
| ticking = false; | |
| } | |
| window.addEventListener('scroll', () => { | |
| if (!ticking) { | |
| requestAnimationFrame(onScroll); | |
| ticking = true; | |
| } | |
| }, { passive: true }); | |
| })(); | |
| """) | |
| # %% ../nbs/01_app.ipynb #694a3b5e | |
| @rt | |
| def blog(htmx, tags:str=None): | |
| selected = {unquote(t.strip()) for t in (tags or '').split(',') if t.strip()} | |
| all_posts = L(posts.values()).filter(lambda p: not p.unlisted) | |
| filtered = all_posts.filter(lambda p: selected <= set(p.tags)) | |
| posts_content = Div(*filtered.map(post_preview)) if filtered else Div(P("No posts found matching those tags.", cls="text-muted-foreground"), cls="py-8 text-center") | |
| posts_div = Div(posts_content, id="posts-list") | |
| tag_filt = tag_filter(selected, all_posts, filtered) | |
| if htmx and htmx.target == "posts-list": | |
| tag_filt.attrs['hx-swap-oob'] = 'true' | |
| return posts_div, tag_filt | |
| return layout(H2(cfg.description or "Blog"), tag_filt, posts_div, title=f"{cfg.name} - Blog", htmx=htmx), _scrollspy_script, _navbar_autohide_script | |
| # %% ../nbs/01_app.ipynb #36235871 | |
| @rt('/blog/{slug}') | |
| def blogpost(htmx, slug:str): | |
| p = posts.get(slug) | |
| if not p: return layout(H1("Post Not Found", cls="text-4xl font-bold mb-4"), P("Sorry, this blog post doesn't exist."), title="Post Not Found", htmx=htmx) | |
| if p.external_url: return Response(headers={"HX-Redirect": p.external_url}) | |
| if p.meta.get('audio', True): | |
| content = render_article_sections(p, slug) | |
| else: | |
| content = post_article(p, slug) | |
| flag = 'window.__annotationEnabled=true;' if p.meta.get('annotation', True) else 'window.__annotationEnabled=false;' | |
| _, headings = extract_headings(p.content) | |
| return layout(content, title=f"{cfg.name} - {p.title}", htmx=htmx, wide=bool(headings)), Script(flag), _scrollspy_script, _navbar_autohide_script | |
| # %% ../nbs/01_app.ipynb #b065211e | |
| @rt | |
| def subscribe(email:str): | |
| if list(subscribers.rows_where("email = ?", [email], limit=1)): return P("You're already subscribed!", cls="text-sm text-muted-foreground mt-6") | |
| subscribers.insert(Subscriber(email=email, created_at=datetime.now().isoformat())) | |
| return P("Thanks for subscribing!", cls="text-sm text-green-600 mt-6") | |
| # %% ../nbs/01_app.ipynb #c64d78d5 | |
| @rt('/rss.xml') | |
| def rss_feed(): | |
| ps = list(posts.values()) | |
| def item(p): return f"<item><title>{p.title}</title><link>{cfg.dom}/blog/{p.slug}</link><guid>{cfg.dom}/blog/{p.slug}</guid><pubDate>{p.date.strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate><description><![CDATA[{p.excerpt}]]></description>{''.join(f'<category>{t}</category>' for t in p.tags)}</item>" | |
| items = ''.join(item(p) for p in ps if not p.external_url) | |
| rss = f'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>{cfg.name}</title><link>{cfg.dom}</link><description>{cfg.description}</description><language>en-us</language><lastBuildDate>{ps[0].date.strftime("%a, %d %b %Y %H:%M:%S +0000") if ps else ""}</lastBuildDate><atom:link href="{cfg.dom}/rss.xml" rel="self" type="application/rss+xml"/>{items}</channel></rss>' | |
| return Response(rss, media_type="application/rss+xml") | |
| # %% ../nbs/01_app.ipynb #32a1dad7 | |
| if cfg.get('email'): | |
| @rt | |
| def contact(): return RedirectResponse(f"mailto:{cfg.email}", status_code=302) | |
| # %% ../nbs/01_app.ipynb #e58fbb9e | |
| for slug in pages: | |
| @rt(f'/{slug}') | |
| def page(htmx, slug:str=slug): | |
| title = slug.replace('-',' ').title() | |
| return layout(H2(title), from_md(pages[slug][1], img_dir='/public/pages'), | |
| title=f"{cfg.name} - {title}", htmx=htmx) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment