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>
This commit is contained in:
huangjianwu
2026-05-14 19:01:37 +08:00
parent 41f17592c2
commit 37f7ee6e15
22 changed files with 1273 additions and 651 deletions

View File

@@ -10,12 +10,14 @@ import BackendLogPanel from './BackendLogPanel'
type Health = 'green' | 'yellow' | 'red' | 'unknown'
const HEALTH_POLL_MS = 5000
const SYS_HEALTH_PATH = '/api/sys_health'
// 路径不带 /api/,因为 backendBase() 已经把它包进 baseURL 了(同 axios 实例的语义)。
// 之前写 '/api/sys_health' + base='http://host/api' = 双 /api → 一直 404。
const SYS_HEALTH_PATH = '/sys_health'
function backendBase(): string {
// 与 services/request.ts 用的一致
// 与 utils/request.ts 的 baseURL 计算保持一致env 没设走 '/api' 兜底。
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return (fromEnv ?? '').replace(/\/$/, '')
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
}
const BackendHealthIndicator = () => {

View File

@@ -35,6 +35,9 @@ export function useBackendEvents(): BackendEvents {
const [logs, setLogs] = useState<LogEntry[]>([])
// 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值
const logsRef = useRef<LogEntry[]>([])
// 主动重启期Rust 在 kill 老 sidecar 前会 emit 'backend-restarting'。
// 期间到达的 'backend-terminated' 是我们自己造成的,不要污染状态。
const ignoreNextTerminatedRef = useRef(false)
function append(entry: LogEntry) {
const next = logsRef.current.concat(entry)
@@ -58,7 +61,23 @@ export function useBackendEvents(): BackendEvents {
const offErr = await listen<string>('backend-error', event => {
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
})
const offRestarting = await listen('backend-restarting', () => {
// 紧接着到达的 terminated 是我们主动 kill 老 sidecar 引发的,跳过 3s
ignoreNextTerminatedRef.current = true
setTimeout(() => { ignoreNextTerminatedRef.current = false }, 3000)
append({ level: 'info', text: '[Backend restarting]', ts: Date.now() })
})
const offTerm = await listen<number | null>('backend-terminated', event => {
// 主动重启窗口内的 terminated 是预期副作用,仅记日志、不改状态
if (ignoreNextTerminatedRef.current) {
ignoreNextTerminatedRef.current = false
append({
level: 'info',
text: `[Backend terminated, restart in progress] code=${event.payload ?? 'unknown'}`,
ts: Date.now(),
})
return
}
setStatus('terminated')
setExitCode(event.payload ?? null)
append({
@@ -73,7 +92,7 @@ export function useBackendEvents(): BackendEvents {
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
})
unlisteners = [offMsg, offErr, offTerm, offRestart]
unlisteners = [offMsg, offErr, offRestarting, offTerm, offRestart]
})()
return () => {

View File

@@ -1,13 +1,141 @@
import { useMemo, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2, AlertTriangle, RotateCcw, Clipboard } from 'lucide-react'
import { useBackendEvents } from '@/components/BackendHealth/useBackendEvents'
// 失败态预览里最多展示几行 stderr。比这还多就请用户去 copyLogs() 拷出来。
const STDERR_PREVIEW_LINES = 6
interface Props {
/** 加载中:显示转圈对话框 */
open: boolean
/** 启动失败:显示错误 + 重启/复制日志按钮 */
failed?: boolean
/** 失败原因(来自 useCheckBackend.lastError 或 Tauri 事件 payload */
lastError?: string | null
/** 重新走一遍 useCheckBackend 的轮询(不重启 sidecar */
onRetry?: () => void
}
function BackendInitDialog({ open }: Props) {
// 加载中 + 启动失败两个状态合并在一个 dialog 里。
// 失败态比加载态更紧急:用户能看到具体原因 + 一键重启 + 一键复制日志去 issue
// 而不是面对一个永远转圈的对话框。
function BackendInitDialog({ open, failed = false, lastError = null, onRetry }: Props) {
const { isTauri, restart, copyLogs, logs } = useBackendEvents()
const [restarting, setRestarting] = useState(false)
const [copyResult, setCopyResult] = useState<'idle' | 'ok' | 'fail'>('idle')
// 从 ring buffer 里挑最后几行 stderr —— 它们比 lastErrorhook 自己总结的那句)信息密度更高,
// 通常 Python traceback 的最后一行就是真正的错误类型 + 消息
const stderrPreview = useMemo(() => {
if (!failed || !logs?.length) return []
return logs
.filter((l) => l.level === 'error')
.slice(-STDERR_PREVIEW_LINES)
.map((l) => l.text)
}, [failed, logs])
// 任一态需要展示就保持 dialog 开着,关掉只在两个 flag 都熄灭时发生
const isOpen = open || failed
const handleRestart = async () => {
setRestarting(true)
try {
if (isTauri) await restart()
onRetry?.()
} catch {
// restart 内部已经 append 到 log这里不再 toast
} finally {
setRestarting(false)
}
}
const handleCopy = async () => {
const ok = await copyLogs()
setCopyResult(ok ? 'ok' : 'fail')
setTimeout(() => setCopyResult('idle'), 2000)
}
if (failed) {
return (
<Dialog open={isOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-3 mt-2 text-sm">
<p className="text-muted-foreground">
{lastError || '后端在预计时间内未就绪。'}
</p>
{stderrPreview.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{stderrPreview.length}
<span className="opacity-60"></span>:
</p>
<pre className="max-h-32 overflow-auto rounded bg-zinc-900 px-2 py-1.5 font-mono text-[11px] leading-snug text-red-200">
{stderrPreview.join('\n')}
</pre>
</div>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p></p>
<ul className="list-disc list-inside space-y-0.5 pl-1">
<li> / PyInstaller </li>
<li> ffmpeg / 8483 </li>
<li> whisper </li>
</ul>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button
size="sm"
onClick={handleRestart}
disabled={restarting}
className="gap-1.5"
>
{restarting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCcw className="w-4 h-4" />
)}
{isTauri ? (restarting ? '重启中…' : '重启后端') : '重试'}
</Button>
{isTauri && (
<Button size="sm" variant="outline" onClick={handleCopy} className="gap-1.5">
<Clipboard className="w-4 h-4" />
{copyResult === 'ok'
? '已复制 ✓'
: copyResult === 'fail'
? '复制失败'
: '复制启动日志'}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground pt-2">
&nbsp;
<a
href="https://github.com/JefferyHcool/BiliNote/issues"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline"
>
GitHub Issues
</a>
&nbsp;
</p>
</div>
</DialogContent>
</Dialog>
)
}
// 默认加载态
return (
<Dialog open={open}>
<Dialog open={isOpen}>
<DialogContent className="text-center">
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2">
@@ -15,9 +143,12 @@ interface Props {
</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground mt-2">,</p>
<p className="text-muted-foreground mt-2">
10-30
</p>
</DialogContent>
</Dialog>
)
}
export default BackendInitDialog
export default BackendInitDialog

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { getProxyConfig, updateProxyConfig } from '@/services/proxy'
// 全局代理配置:作用于 LLM API + 转写 APIGroq 等)+ yt-dlp 视频下载。
// 国内访问 OpenAI / Groq / YouTube 基本都要靠它。
const ProxyConfig = () => {
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState('')
const [effective, setEffective] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
;(async () => {
try {
const cfg = await getProxyConfig()
setEnabled(cfg.enabled)
setUrl(cfg.url)
setEffective(cfg.effective)
} catch {
/* 拦截器已 toast */
} finally {
setLoading(false)
}
})()
}, [])
const handleSave = async () => {
if (enabled && !url.trim()) {
toast.error('请填写代理地址,或关闭代理开关')
return
}
setSaving(true)
try {
const cfg = await updateProxyConfig({ enabled, url: url.trim() })
setEnabled(cfg.enabled)
setUrl(cfg.url)
setEffective(cfg.effective)
toast.success('代理配置已保存')
} catch {
/* 拦截器已 toast */
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="text-xs text-gray-400"></div>
}
// env 兜底:配置没开但 effective 有值,说明来自 HTTP_PROXY 环境变量
const fromEnv = !enabled && !!effective
return (
<div className="flex flex-col gap-2 rounded border border-neutral-200 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<p className="text-xs text-gray-400">
AI Groq YouTube
</p>
<Input
placeholder="http://127.0.0.1:7890"
value={url}
disabled={!enabled}
onChange={e => setUrl(e.target.value)}
className="text-sm"
/>
{fromEnv && (
<p className="text-xs text-amber-600">
{effective}
</p>
)}
{enabled && effective && (
<p className="text-xs text-green-600">{effective}</p>
)}
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存代理配置'}
</Button>
</div>
)
}
export default ProxyConfig

View File

@@ -41,21 +41,22 @@ const ProviderCard: FC<IProviderCardProps> = ({
<div
className={
styles.card +
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
' flex h-14 cursor-pointer items-center justify-between rounded border border-[#f3f3f3] p-2' +
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
}
// 整行可点跳转到对应供应商编辑页(之前 onClick 只挂在 icon+名字那一小块 div 上,
// 名字和开关之间的空白区域点不动)
onClick={() => navigate(`/settings/model/${id}`)}
>
<div
className="flex items-center text-lg"
onClick={() => navigate(`/settings/model/${id}`)}
>
<div className="flex items-center text-lg">
<div className="flex h-9 w-9 items-center">
<AILogo name={Icon} />
</div>
<div className="font-semibold">{providerName}</div>
</div>
<div>
{/* Switch 自己的点击不应该冒泡触发整行跳转 */}
<div onClick={e => e.stopPropagation()}>
<Switch
checked={isChecked}
onCheckedChange={handleToggle}

View File

@@ -74,8 +74,17 @@ const StartupBanner = () => {
})
})
// 后端被「重启后端」按钮拉起来后 / Rust ready-probe 检测到新 sidecar 真的就绪后,
// 自动清掉 terminated 横幅。之前 dismissible:false + 没自动清逻辑 = banner 永远卡。
const offRestarted = await listen('backend-restarted', () => {
setBanner(b => (b?.severity === 'error' ? null : b))
})
const offReady = await listen('backend-ready', () => {
setBanner(b => (b?.severity === 'error' ? null : b))
})
// backend-error 是 sidecar stderr量大噪音多这里不直接展示留给 P2 的日志面板。
unlisteners = [offWarning, offTerminated]
unlisteners = [offWarning, offTerminated, offRestarted, offReady]
})()
return () => {