// Admin Page — API Keys, LLM, RAG Config, Transcription Schedule const AdminPage = ({ lang, activePage }) => { const t = lang === 'zh'; const pages = { 'admin-api': , 'admin-llm': , 'admin-rag': , 'admin-schedule': , }; return (

{t ? '後台管理' : 'Administration'}

{{ 'admin-api': t ? 'API 金鑰管理' : 'API Key Management', 'admin-llm': t ? 'LLM 模型設定' : 'LLM Model Settings', 'admin-rag': t ? 'RAG 參數設定' : 'RAG Configuration', 'admin-schedule': t ? '轉錄排程管理' : 'Transcription Schedule' }[activePage]}

{pages[activePage]}
); }; // ── API Keys Tab ── const ApiKeysTab = ({ lang }) => { const t = lang === 'zh'; const [keys, setKeys] = React.useState([ { id: 1, provider: 'OpenAI', key: 'sk-proj-••••••••••••••••••••••••••••••XYZ1', active: true, model: 'gpt-4o', added: '2026-03-01' }, { id: 2, provider: 'Anthropic', key: 'sk-ant-api03-••••••••••••••••••••ABC2', active: true, model: 'claude-opus-4-5', added: '2026-03-15' }, { id: 3, provider: 'Google', key: 'AIza••••••••••••••••••••••••••••GHI3', active: false, model: 'gemini-2.5-pro', added: '2026-04-01' }, { id: 4, provider: 'AI Hub', key: 'hub-••••••••••••••••••••••••••••JKL4', active: true, model: 'zeabur-llm-v2', added: '2026-04-10' }, ]); const [showKey, setShowKey] = React.useState({}); const [adding, setAdding] = React.useState(false); const [newKey, setNewKey] = React.useState({ provider: 'OpenAI', key: '' }); const providerColors = { OpenAI: '#22c55e', Anthropic: '#f59e0b', Google: '#6366f1', 'AI Hub': '#22d3ee' }; return (

{t ? '管理各 LLM 供應商的 API 金鑰,金鑰以加密方式儲存。' : 'Manage API keys for LLM providers. Keys are stored encrypted.'}

setAdding(true)} size="sm">{t ? '新增金鑰' : 'Add Key'}
{adding && (

{t ? '新增 API 金鑰' : 'Add API Key'}

setNewKey(k => ({ ...k, key: e.target.value }))} placeholder="sk-..." />
{ setKeys(ks => [...ks, { id: Date.now(), ...newKey, active: true, model: '-', added: '2026-04-19' }]); setAdding(false); setNewKey({ provider: 'OpenAI', key: '' }); }}>{t ? '儲存' : 'Save'} setAdding(false)}>{t ? '取消' : 'Cancel'}
)}
{keys.map(k => (
{k.provider}
{showKey[k.id] ? k.key : k.key.replace(/(?<=.{8}).(?=.{6})/g, '•')}
{k.model}
{k.added}
setShowKey(s => ({ ...s, [k.id]: !s[k.id] }))} /> setKeys(ks => ks.filter(x => x.id !== k.id))} />
))}
); }; // ── LLM Tab ── const MASKED_KEY = '***'; const LLMTab = ({ lang }) => { const t = lang === 'zh'; const [form, setForm] = React.useState({ answer_base_url: '', answer_api_key: '', answer_model: '', rewrite_base_url: '', rewrite_api_key: '', rewrite_model: '', }); const [apiKeyTouched, setApiKeyTouched] = React.useState({ answer_api_key: false, rewrite_api_key: false }); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [message, setMessage] = React.useState(null); const loadConfig = React.useCallback(async () => { setLoading(true); setMessage(null); try { const res = await fetch(`${API_BASE}/admin/llm-config`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setForm({ answer_base_url: data.answer_base_url || '', answer_api_key: data.answer_api_key || '', answer_model: data.answer_model || '', rewrite_base_url: data.rewrite_base_url || '', rewrite_api_key: data.rewrite_api_key || '', rewrite_model: data.rewrite_model || '', }); setApiKeyTouched({ answer_api_key: false, rewrite_api_key: false }); } catch (err) { setMessage({ kind: 'error', text: (t ? '載入失敗:' : 'Load failed: ') + err.message }); } finally { setLoading(false); } }, [t]); React.useEffect(() => { loadConfig(); }, [loadConfig]); const setField = (key) => (e) => { const value = e.target.value; setForm(f => ({ ...f, [key]: value })); if (key === 'answer_api_key' || key === 'rewrite_api_key') { setApiKeyTouched(s => ({ ...s, [key]: true })); } }; const handleSave = async () => { setSaving(true); setMessage(null); const payload = { answer_base_url: form.answer_base_url, answer_model: form.answer_model, rewrite_base_url: form.rewrite_base_url, rewrite_model: form.rewrite_model, }; if (apiKeyTouched.answer_api_key && form.answer_api_key !== MASKED_KEY) { payload.answer_api_key = form.answer_api_key; } if (apiKeyTouched.rewrite_api_key && form.rewrite_api_key !== MASKED_KEY) { payload.rewrite_api_key = form.rewrite_api_key; } try { const res = await fetch(`${API_BASE}/admin/llm-config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); setForm({ answer_base_url: data.answer_base_url || '', answer_api_key: data.answer_api_key || '', answer_model: data.answer_model || '', rewrite_base_url: data.rewrite_base_url || '', rewrite_api_key: data.rewrite_api_key || '', rewrite_model: data.rewrite_model || '', }); setApiKeyTouched({ answer_api_key: false, rewrite_api_key: false }); setMessage({ kind: 'success', text: t ? '已儲存' : 'Saved' }); } catch (err) { setMessage({ kind: 'error', text: (t ? '儲存失敗:' : 'Save failed: ') + err.message }); } finally { setSaving(false); } }; return (

{t ? '設定 Answer 與 Rewrite 兩個 LLM,後台儲存於 llm_config 單列資料表。' : 'Configure the Answer and Rewrite LLMs (stored in the singleton llm_config row).'}

{loading ? (
{t ? '載入中...' : 'Loading...'}
) : (
{message && (
{message.text}
)}
{t ? '重新載入' : 'Reload'} {saving ? (t ? '儲存中...' : 'Saving...') : (t ? '儲存設定' : 'Save Configuration')}
)}
); }; const LLMConfigSection = ({ title, lang, baseUrl, onBaseUrl, apiKey, onApiKey, model, onModel }) => { const t = lang === 'zh'; return (

{title}

); }; const SliderParam = ({ label, value, min, max, step, onChange, hint }) => (
{value}
onChange(Number(e.target.value))} style={{ width: '100%', accentColor: TOKEN.accent }} />

{hint}

); // ── RAG Tab ── const RAGTab = ({ lang }) => { const t = lang === 'zh'; const [cfg, setCfg] = React.useState({ chunkSize: 512, overlap: 64, topK: 5, similarity: 0.72, embedModel: 'text-embedding-3-large', rerank: true, hybridSearch: true }); const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); return (
set('chunkSize', v)} hint={t ? '建議 256–1024' : 'Recommended 256–1024'} /> set('overlap', v)} hint={t ? '避免截斷語意' : 'Prevents semantic cutoff'} />
set('topK', v)} hint={t ? '返回最相關的 K 個段落' : 'Return K most relevant segments'} /> set('similarity', v)} hint={`≥ ${cfg.similarity} ${t ? '才納入結果' : 'to include in results'}`} />
set('rerank', v)} hint={t ? '使用 Cross-Encoder 精排' : 'Use Cross-Encoder for precision'} /> set('hybridSearch', v)} hint={t ? 'BM25 + 向量搜尋混合' : 'BM25 + Vector search blend'} />
{['text-embedding-3-large', 'text-embedding-3-small', 'text-embedding-ada-002'].map(m => ( ))}
{t ? '儲存設定' : 'Save Configuration'}
); }; const Section = ({ title, children }) => (

{title}

{children}
); const ToggleParam = ({ label, value, onChange, hint }) => (
onChange(!value)} style={{ width: 36, height: 20, borderRadius: 99, background: value ? TOKEN.accent : TOKEN.surfaceBorder, cursor: 'pointer', position: 'relative', transition: 'background 0.15s', flexShrink: 0 }}>

{hint}

); // ── Schedule Tab ── const ScheduleTab = ({ lang }) => { const t = lang === 'zh'; const [shows, setShows] = React.useState(null); const [loading, setLoading] = React.useState(true); const [fetchError, setFetchError] = React.useState(null); const [showForm, setShowForm] = React.useState(false); const [form, setForm] = React.useState({ rss: '', name: '', freq: 'daily', time: '06:00', whisperModel: 'large-v3', maxEp: 0 }); const [rssLoading, setRssLoading] = React.useState(false); const [rssError, setRssError] = React.useState(null); const [rssPreview, setRssPreview] = React.useState(null); const [syncing, setSyncing] = React.useState(false); const [syncingId, setSyncingId] = React.useState(null); const [confirmState, setConfirmState] = React.useState(null); const setF = (k, v) => setForm(f => ({ ...f, [k]: v })); const loadSchedules = React.useCallback(async () => { setLoading(true); setFetchError(null); try { const res = await fetch(`${API_BASE}/admin/schedules`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setShows(data); } catch (err) { setFetchError(err.message); } finally { setLoading(false); } }, []); React.useEffect(() => { loadSchedules(); }, [loadSchedules]); const handleToggle = async (item) => { const next = !(item.schedule?.enabled); try { const res = await fetch(`${API_BASE}/shows/${item.show_id}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: next }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const updated = await res.json(); setShows(prev => prev.map(s => s.show_id === item.show_id ? { ...s, schedule: updated } : s)); } catch (err) { alert((t ? '更新失敗:' : 'Update failed: ') + err.message); } }; const handleSyncAll = async () => { if (!shows) return; const enabled = shows.filter(s => s.schedule?.enabled === true); if (enabled.length === 0) { alert(t ? '沒有啟用中的節目' : 'No enabled shows'); return; } setSyncing(true); try { await Promise.all( enabled.map(s => fetch(`${API_BASE}/shows/${s.show_id}/transcribe-all`, { method: 'POST' })) ); alert((t ? '已排入 ' : 'Queued ') + enabled.length + (t ? ' 個節目的轉錄任務' : ' shows for transcription')); } catch (err) { alert((t ? '同步失敗:' : 'Sync failed: ') + err.message); } finally { setSyncing(false); } }; const handleFetchRSS = async () => { if (!form.rss) return; setRssLoading(true); setRssError(null); setRssPreview(null); try { const res = await fetch(`${API_BASE}/rss-preview?url=${encodeURIComponent(form.rss)}`); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); setRssPreview(data); setF('name', data.title); } catch (err) { setRssError(err.message); } finally { setRssLoading(false); } }; const handleAddSchedule = async () => { if (!form.rss || !form.name) return; try { const createRes = await fetch(`${API_BASE}/shows`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rss_url: form.rss }), }); if (!createRes.ok && createRes.status !== 409) { const detail = await createRes.text(); throw new Error(detail || `HTTP ${createRes.status}`); } let show; if (createRes.ok) { show = await createRes.json(); } else { const listRes = await fetch(`${API_BASE}/shows`); const list = await listRes.json(); show = list.find(s => s.rss_url === form.rss); if (!show) throw new Error(t ? '找不到對應節目' : 'Show not found'); } await fetch(`${API_BASE}/shows/${show.id}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, frequency: form.freq, run_time: form.time, whisper_model: form.whisperModel, max_episodes: form.maxEp, }), }); setShowForm(false); setForm({ rss: '', name: '', freq: 'daily', time: '06:00', whisperModel: 'large-v3', maxEp: 0 }); setRssPreview(null); await loadSchedules(); } catch (err) { alert((t ? '建立失敗:' : 'Create failed: ') + err.message); } }; const handleSyncShow = async (item) => { setSyncingId(item.show_id); try { const res = await fetch(`${API_BASE}/shows/${item.show_id}/sync`, { method: 'POST' }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); alert(t ? `已同步:新增 ${data.added} 集、更新 ${data.updated} 集(總計 ${data.total} 集)` : `Synced: added ${data.added}, updated ${data.updated} (total ${data.total})`); await loadSchedules(); } catch (err) { alert((t ? '同步失敗:' : 'Sync failed: ') + err.message); } finally { setSyncingId(null); } }; const handleRemoveSchedule = async (item) => { try { const res = await fetch(`${API_BASE}/shows/${item.show_id}/schedule`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } await loadSchedules(); } catch (err) { alert((t ? '移除排程失敗:' : 'Remove schedule failed: ') + err.message); } }; const handleDeleteShow = async (item) => { try { const res = await fetch(`${API_BASE}/shows/${item.show_id}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } await loadSchedules(); } catch (err) { alert((t ? '刪除節目失敗:' : 'Delete show failed: ') + err.message); } }; const confirmLabels = { 'delete-show': { title: t ? '刪除節目' : 'Delete Show', message: (item) => t ? `即將刪除節目「${item.show_title}」及其所有集數、逐字稿、排程設定。此操作不可復原。` : `About to delete show "${item.show_title}" and all its episodes, transcripts, and schedule. This cannot be undone.`, confirmLabel: t ? '確認刪除' : 'Confirm Delete', handler: handleDeleteShow, }, 'remove-schedule': { title: t ? '移除排程' : 'Remove Schedule', message: (item) => t ? `即將移除節目「${item.show_title}」的轉錄排程設定。節目與已轉錄集數不受影響。` : `About to remove the transcription schedule for "${item.show_title}". The show and transcribed episodes are not affected.`, confirmLabel: t ? '確認移除' : 'Confirm Remove', handler: handleRemoveSchedule, }, }; return (

{t ? '設定各節目的自動轉錄排程與進度監控。' : 'Configure auto-transcription schedules and monitor progress.'}

{syncing ? (t ? '同步中...' : 'Syncing...') : (t ? '同步所有' : 'Sync All')} setShowForm(v => !v)}>{t ? '新增排程' : 'Add Schedule'}
{/* Add Schedule Form */} {showForm && (

{t ? '新增轉錄排程' : 'New Transcription Schedule'}

{/* RSS Input */}
setF('rss', e.target.value)} placeholder="https://feeds.example.com/my-podcast" icon="rss" />
{rssLoading ? (t ? '讀取中...' : 'Loading...') : (t ? '讀取 RSS' : 'Fetch RSS')}
{rssPreview && (
{rssPreview.title}
{rssPreview.episode_count} {t ? '集' : 'eps'}{rssPreview.latest_published_at ? ` · ${t ? '最新' : 'Latest'}: ${rssPreview.latest_published_at.slice(0, 10)}` : ''}
)} {rssError && (
{rssError}
)}
{/* Name */}
setF('name', e.target.value)} placeholder={t ? '輸入或自動填入' : 'Enter or auto-filled from RSS'} />
{/* Frequency */}
{/* Time */}
setF('time', e.target.value)} style={{ width: '100%', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit', colorScheme: 'dark' }} />
{/* Max episodes */}
setF('maxEp', Number(e.target.value))} style={{ width: '100%', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit' }} />
{/* Whisper Model */}
{[ { id: 'large-v3', label: 'large-v3', hint: t ? '最高精度' : 'Best accuracy' }, { id: 'medium', label: 'medium', hint: t ? '平衡' : 'Balanced' }, { id: 'small', label: 'small', hint: t ? '快速' : 'Fast' }, { id: 'base', label: 'base', hint: t ? '最快' : 'Fastest' }, ].map(m => (
setF('whisperModel', m.id)} style={{ padding: '7px 14px', borderRadius: 8, border: `1px solid ${form.whisperModel === m.id ? TOKEN.accent : TOKEN.surfaceBorder}`, background: form.whisperModel === m.id ? TOKEN.accentDim : TOKEN.surfaceRaised, cursor: 'pointer', transition: 'all 0.12s' }}>
{m.label}
{m.hint}
))}
{t ? '建立排程' : 'Create Schedule'} { setShowForm(false); setRssPreview(null); }}>{t ? '取消' : 'Cancel'}
)} {loading && (
{t ? '載入中...' : 'Loading...'}
)} {fetchError && (
{(t ? '載入失敗:' : 'Load failed: ') + fetchError}
)} {!loading && !fetchError && shows && (
{shows.length === 0 && (
{t ? '目前沒有節目,請先新增。' : 'No shows yet.'}
)} {shows.map(item => { const enabled = item.schedule?.enabled === true; const sched = item.schedule; const lastTx = item.last_transcribed_at ? item.last_transcribed_at.slice(0, 16).replace('T', ' ') : '—'; return (
handleToggle(item)} style={{ width: 36, height: 20, borderRadius: 99, background: enabled ? TOKEN.accent : TOKEN.surfaceBorder, cursor: 'pointer', position: 'relative', transition: 'background 0.15s', flexShrink: 0, marginTop: 3 }}>
{item.show_title} {item.pending_count > 0 && {item.pending_count} {t ? '集待轉錄' : 'pending'}} {!sched && {t ? '未設定' : 'No schedule'}}
{item.rss_url} {sched && {t ? '頻率' : 'Freq'}: {sched.frequency} · {sched.run_time}} {t ? '最後轉錄' : 'Last'}: {lastTx} {sched && {sched.whisper_model}}
handleSyncShow(item)} disabled={syncingId === item.show_id}> {syncingId === item.show_id ? (t ? '同步中...' : 'Syncing...') : (t ? '同步集數' : 'Sync Episodes')} {sched && ( setConfirmState({ kind: 'remove-schedule', item })}> {t ? '移除排程' : 'Remove Schedule'} )} setConfirmState({ kind: 'delete-show', item })}> {t ? '刪除節目' : 'Delete Show'}
); })}
)} { const { kind, item } = confirmState; setConfirmState(null); confirmLabels[kind].handler(item); }} onCancel={() => setConfirmState(null)} />
); }; Object.assign(window, { AdminPage });