Files
BiliNote/BillNote_frontend/src/pages/Onboarding/index.tsx
huangjianwu 37f7ee6e15 feat(desktop): 后端健康监控韧性 + onboarding 修复 + 全局代理 UI
- useCheckBackend 重写:60s 总超时取代 while(true) 死轮询,订阅 Tauri
  backend-ready/terminated/startup-timeout 事件,裸 fetch 探测避免
  启动期 toast 叠堆
- Tauri lib.rs:spawn 后 HTTP 探针轮询 /api/sys_check 拿 200 才算就绪
  (之前 TCP connect 会被孤儿进程误判);RunEvent::Exit 钩子退出前
  kill sidecar,修孤儿进程占端口;restart 前发 backend-restarting
  让前端忽略主动 kill 引发的 terminated
- BackendInitDialog:失败态展示原因 + 最近 stderr + 重启/复制日志按钮
- StartupBanner:收到 restarted/ready 自动清「已退出」横幅
- BackendHealthIndicator:修 /api/api/sys_health 双前缀 404
- Onboarding:step1 后端连通改自动重试 + 事件触发 + 手动按钮;step2
  撞预置供应商名时改为更新已存在供应商;errText 统一错误文案
- 全局代理 UI:下载配置页新增代理卡片(services/proxy.ts + ProxyConfig)
- request.ts 加 suppressToast 配置位,预期失败不弹全局红 toast
- NoteForm/taskStore:捕获就绪门禁错误,引导去音频转写配置页下载
- providerCard:整行可点切换(之前只有 icon 区域响应)
- Monitor 页 Whisper 卡显示模型本地下载状态
- tauri/api 升级对齐 2.11,修 vite build 版本不匹配

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

360 lines
16 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 { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { addProvider, addModel, testConnection, getProviderList, updateProviderById } from '@/services/model'
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
import logo from '@/assets/icon.svg'
// 后端 R.error / ProviderError 的形状是 { code, msg, data },没有 .message。
// 直接 ${e} 会渲染成 [object Object],这里统一抽取可读文案。
function errText(e: any): string {
if (!e) return '未知错误'
if (typeof e === 'string') return e
return e.msg || e.message || JSON.stringify(e)
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 后端连通性自检不走共享 axios会弹 toast用裸 fetch 避免启动期 toast 叠堆
function getBackendBase(): string {
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
}
async function pingBackend(): Promise<boolean> {
try {
const res = await fetch(`${getBackendBase()}/sys_check`)
if (!res.ok) return false
const json = await res.json().catch(() => null)
return json?.code === 0
}
catch {
return false
}
}
// 桌面端首启 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 只在 step===1 时 ping 一次。失败后 backendOk=false 永远卡死,
// 即便后端随后就绪了也不会刷新。现在改成:
// - 手动重试按钮调用 doPing
// - Tauri backend-ready / backend-restarted 事件触发 doPing
// - 初次失败后 2s 自动再 ping 一次(覆盖 sidecar 慢热场景)
const doPing = useCallback(async () => {
setPinging(true)
const ok = await pingBackend()
setBackendOk(ok)
setPinging(false)
return ok
}, [])
useEffect(() => {
if (step !== 1) return
let cancelled = false
let timerId: ReturnType<typeof setTimeout> | null = null
let offReady: (() => void) | null = null
let offRestarted: (() => void) | null = null
;(async () => {
const ok = await doPing()
if (cancelled) return
if (!ok) {
// 后端可能正在解压/启动2s 后再试一次
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
}
// 桌面端订阅 Tauri 事件:后端真正就绪 / 重启完成时立刻再检查一次
if (isTauri) {
try {
const { listen } = await import('@tauri-apps/api/event')
offReady = await listen('backend-ready', () => { if (!cancelled) doPing() })
offRestarted = await listen('backend-restarted', () => { if (!cancelled) doPing() })
}
catch { /* 拿不到事件 API 不致命 */ }
}
})()
return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
offReady?.()
offRestarted?.()
}
}, [step, doPing])
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 {
const name = providerName.trim()
let pid: string | undefined
// 后端 seed_default_providers() 会预置 OpenAI / DeepSeek / Qwen 等同名供应商,
// 直接 add_provider 撞名会报「供应商名称已存在」。所以:撞名时改为
// 「找到那个已存在的同名供应商 → 更新它的 key / base_url」而不是新建。
// 这些调用都带 silent:true —— 撞名是预期内的,不弹全局红 toast。
try {
const res: any = await addProvider({
name,
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
type: 'custom',
logo: 'custom',
}, { silent: true })
pid = (res?.data ?? res) as string | undefined
if (!pid) throw new Error('后端未返回 provider id')
}
catch (addErr: any) {
const msg = errText(addErr)
if (!msg.includes('已存在')) throw addErr
// 撞名:复用已存在的同名供应商
const list: any[] = (await getProviderList({ silent: true })) || []
const existing = list.find(p => p?.name === name)
if (!existing?.id) throw new Error(`供应商「${name}」已存在但无法定位,请换个名字`)
pid = existing.id
await updateProviderById({
id: pid,
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
enabled: 1,
}, { silent: true })
}
setProviderId(pid!)
// 加一个默认 model同名 model 已存在时后端会报错,这里也容错)
try {
await addModel({ provider_id: pid!, model_name: modelName.trim() }, { silent: true })
}
catch (modelErr: any) {
const msg = errText(modelErr)
if (!msg.includes('已存在')) throw modelErr
// 模型已存在,直接继续
}
// 测试连通(失败不阻断流程,让用户自己决定继续)
try { await testConnection({ id: pid!, model: modelName.trim() }, { silent: true }) }
catch (e: any) {
console.warn('测试连接失败:', errText(e))
}
next()
}
catch (e: any) {
setError(`保存失败:${errText(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(`保存失败:${errText(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">
{backendOk !== true && (
<button
className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
disabled={pinging}
onClick={doPing}
>
{pinging ? '检测中…' : '重新检测'}
</button>
)}
<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