Files
BiliNote/BillNote_frontend/src/pages/Onboarding/index.tsx
huangjianwu a928e0e38f feat(desktop): 桌面端首启 4 步引导
桌面端用户安装后空白进 web 主页面,提示填模型但不知道从哪填、转写引擎从哪选、
为什么 fast-whisper 在下东西。新增首启 onboarding wizard,把四件事拉成一条线:

1. 后端连通性自检(启动后调 /api/get_all_providers,OK 才能进下一步)
2. LLM 供应商 + 模型:填 OpenAI 兼容 base_url + key + model_name,调
   /add_provider 创建并 addModel 默认 model,附带 testConnection
3. 转写引擎:四选一,**默认推荐 Groq**(在线、免下载本地模型);
   选 fast-whisper 时显式提示"将下载模型"
4. Cookie 同步说明:桌面端无 chrome.cookies API,引导手动配;并指向插件版

实现:
- 新页 src/pages/Onboarding/index.tsx,单文件 stateful wizard
- App.tsx 加 /onboarding 路由 + OnboardingGuard 路由守卫:
  · 仅 Tauri 桌面端(__TAURI_INTERNALS__)拦截,纯 web 端透传,不打扰
  · localStorage('bilinote-onboarded') 不为 '1' 时强制跳 /onboarding
- 完成第 4 步 markOnboarded() 写 localStorage 后 navigate('/')

回归风险:纯 web 用户无感知;旧桌面端用户的 localStorage 没这个 key,
首次升级到含此 PR 的版本时会跳一次 onboarding(建议在升级 release notes 里
说明,避免老用户疑惑)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:30:05 +08:00

266 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
import logo from '@/assets/icon.svg'
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
//
// 1. 后端连通性自检
// 2. LLM 供应商 + 模型(最简:只引导填一个 OpenAI-兼容供应商 + 一个 model 名)
// 3. 转写引擎选择(推荐 Groq 在线,避开本地模型下载坑)
// 4. 可选Cookie 同步说明(仅当用户关注 B 站等需要登录态的平台时)
const ONBOARD_KEY = 'bilinote-onboarded'
export function isOnboarded(): boolean {
return localStorage.getItem(ONBOARD_KEY) === '1'
}
function markOnboarded() {
localStorage.setItem(ONBOARD_KEY, '1')
}
const Onboarding = () => {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [error, setError] = useState('')
// step 1
const [pinging, setPinging] = useState(false)
const [backendOk, setBackendOk] = useState<boolean | null>(null)
// step 2
const [providerName, setProviderName] = useState('OpenAI')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1')
const [modelName, setModelName] = useState('gpt-4o-mini')
const [providerId, setProviderId] = useState<string | null>(null)
const [savingProvider, setSavingProvider] = useState(false)
// step 3
const [transcriberType, setTranscriberType] = useState<string>('groq')
const [savingTranscriber, setSavingTranscriber] = useState(false)
function next() {
setError('')
setStep(s => s + 1)
}
function prev() {
setError('')
setStep(s => Math.max(1, s - 1))
}
// step 1: ping 后端
useEffect(() => {
if (step !== 1) return
let cancelled = false
;(async () => {
setPinging(true)
try {
await getProviderList()
if (!cancelled) setBackendOk(true)
}
catch {
if (!cancelled) setBackendOk(false)
}
finally {
if (!cancelled) setPinging(false)
}
})()
return () => { cancelled = true }
}, [step])
async function saveProvider() {
setError('')
if (!apiKey.trim()) { setError('请填 API Key'); return }
if (!baseUrl.trim()) { setError('请填 API 地址'); return }
if (!providerName.trim()) { setError('请填供应商名'); return }
if (!modelName.trim()) { setError('请填模型名'); return }
setSavingProvider(true)
try {
// 复用桌面 web 端的 add_providertype 必须是 'custom'backend 强制)
const res: any = await addProvider({
name: providerName.trim(),
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
type: 'custom',
logo: 'custom',
})
const newId = (res?.data ?? res) as string | undefined
if (!newId) throw new Error('后端未返回 provider id')
setProviderId(newId)
// 加一个默认 model
await addModel({ provider_id: newId, model_name: modelName.trim() })
// 测试连通
try { await testConnection({ id: newId }) }
catch (e: any) {
// 测试失败不阻断流程,让用户自己决定继续
console.warn('测试连接失败:', e?.message ?? e)
}
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
}
finally {
setSavingProvider(false)
}
}
async function saveTranscriber() {
setError('')
setSavingTranscriber(true)
try {
// fast-whisper / mlx-whisper 需指定 model size在线 (groq/bcut/kuaishou) 不用
const needsSize = transcriberType === 'fast-whisper' || transcriberType === 'mlx-whisper'
await updateTranscriberConfig({
transcriber_type: transcriberType,
...(needsSize ? { whisper_model_size: 'tiny' } : {}),
} as any)
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
}
finally {
setSavingTranscriber(false)
}
}
function finish() {
markOnboarded()
navigate('/', { replace: true })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-pink-50 p-6">
<div className="w-full max-w-xl rounded-xl border bg-white p-6 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<img src={logo} alt="logo" className="h-10 w-10" />
<div>
<h1 className="text-xl font-bold">使 BiliNote</h1>
<p className="text-xs text-gray-500"></p>
</div>
</div>
{/* Stepper */}
<div className="mb-5 flex items-center gap-2 text-xs text-gray-500">
{[1, 2, 3, 4].map(s => (
<div key={s} className="flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full border ${step >= s ? 'border-blue-600 bg-blue-600 text-white' : 'border-gray-300 bg-white text-gray-400'}`}
>{s}</div>
{s < 4 && <div className={`h-px w-8 ${step > s ? 'bg-blue-600' : 'bg-gray-300'}`} />}
</div>
))}
</div>
{step === 1 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 1 · </h2>
<p className="text-sm text-gray-600"> Python </p>
{pinging && <div className="text-sm text-gray-500"></div>}
{backendOk === true && <div className="rounded bg-green-50 p-2 text-sm text-green-700"> </div>}
{backendOk === false && (
<div className="rounded bg-red-50 p-2 text-sm text-red-700">
1-2
</div>
)}
<div className="flex gap-2 justify-end">
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={!backendOk} onClick={next}>
</button>
</div>
</section>
)}
{step === 2 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 2 · </h2>
<p className="text-sm text-gray-600"> OpenAI DeepSeek / Qwen / Claude / / OpenAI </p>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"></span>
<input className="input border rounded px-2 py-1" value={providerName} onChange={e => setProviderName(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API </span>
<input className="input border rounded px-2 py-1" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API Key</span>
<input type="password" className="input border rounded px-2 py-1" value={apiKey} onChange={e => setApiKey(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"> gpt-4o-mini / deepseek-chat / qwen-turbo</span>
<input className="input border rounded px-2 py-1" value={modelName} onChange={e => setModelName(e.target.value)} />
</label>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingProvider} onClick={saveProvider}>
{savingProvider ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 3 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 3 · </h2>
<p className="text-sm text-gray-600"><strong>线</strong> ~600MB </p>
<div className="grid gap-2">
{[
{ value: 'groq', title: 'Groq在线推荐', desc: '注册 https://groq.com/ 拿免费 key速度快、英文语料佳。无需本地模型。' },
{ value: 'bcut', title: '必剪(在线,免登)', desc: '免登,中文表现好;偶尔限流。' },
{ value: 'kuaishou', title: '快手(在线,免登)', desc: '与必剪类似,备选。' },
{ value: 'fast-whisper', title: 'Faster Whisper本地', desc: '完全离线但首次需下载 ~75MBtiny至 ~3GBlarge-v3的模型。CPU 慢。' },
].map(opt => (
<label key={opt.value} className={`flex gap-3 p-3 rounded border cursor-pointer ${transcriberType === opt.value ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="transcriber" value={opt.value} checked={transcriberType === opt.value} onChange={e => setTranscriberType(e.target.value)} />
<div>
<div className="text-sm font-medium">{opt.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{opt.desc}</div>
</div>
</label>
))}
</div>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingTranscriber} onClick={saveTranscriber}>
{savingTranscriber ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 4 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 4 · Cookie </h2>
<p className="text-sm text-gray-600">
<strong>B / / </strong> cookie
<br />
YouTube cookie
</p>
<div className="rounded bg-gray-50 p-3 text-xs text-gray-600">
<a className="text-blue-600 underline" href="https://github.com/JefferyHcool/BiliNote/tree/develop/BillNote_extension" target="_blank" rel="noreferrer">BillNote_extension</a> cookie
</div>
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={finish}>
BiliNote
</button>
</div>
</section>
)}
</div>
</div>
)
}
export default Onboarding