mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-13 03:30:10 +08:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 —— 它们比 lastError(hook 自己总结的那句)信息密度更高,
|
||||
// 通常 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">
|
||||
仍然无法解决?复制日志去
|
||||
<a
|
||||
href="https://github.com/JefferyHcool/BiliNote/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
GitHub Issues
|
||||
</a>
|
||||
反馈。
|
||||
</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
|
||||
|
||||
@@ -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 + 转写 API(Groq 等)+ 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user