fix(ui): 修复模型选择刷新闪烁 (#970)

* fix(ui): 修复模型选择刷新闪烁 (#958)

* test(polish): 放宽流式首字测试时序裕量

* fix(ui): ignore stale LocalAsr refresh results
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-20 19:03:01 +08:00
1 parent 7a66ca625b
commit fac8ddc685
3 files changed
+106 -16

No files matched your search

@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises';
const source = await readFile(
new URL('../src/pages/LocalAsr/index.tsx', import.meta.url),
'utf-8',
);
const refreshPolling = source.match(
/window\.setInterval\(\(\) => \{\s*void refresh\(\)\s*\}, 3000\)/g,
) ?? [];
if (refreshPolling.length !== 1) {
throw new Error(`LocalAsr should have one refresh poller, found ${refreshPolling.length}`);
}
if (!/if \(downloadDialogOpen\) return[\s\S]{0,200}window\.setInterval\(\(\) => \{\s*void refresh\(\)/.test(source)) {
throw new Error('LocalAsr refresh polling must stop while the download dialog is open');
}
for (const contract of [
'const downloadDialogOpenRef = useRef(downloadDialogOpen)',
'const refreshGenerationRef = useRef(0)',
'const makeRefreshGuard = (): RefreshGuard =>',
'refreshGenerationRef.current += 1',
'if (!isCurrent()) return',
]) {
if (!source.includes(contract)) {
throw new Error(`LocalAsr refresh guard contract is missing: ${contract}`);
}
}
console.log('LocalAsr keeps one refresh poller and pauses it for the download dialog');
+17 -5
View File
@@ -2736,15 +2736,27 @@ mod tests {
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
read_http_request(&mut stream);
let gap = std::time::Duration::from_millis(60);
let plan: Vec<(&[u8], std::time::Duration)> =
events.iter().map(|e| (e.as_slice(), gap)).collect();
let gap = std::time::Duration::from_millis(150);
let plan: Vec<(&[u8], std::time::Duration)> = events
.iter()
.enumerate()
.map(|(index, event)| {
(
event.as_slice(),
if index == 0 {
std::time::Duration::ZERO
} else {
gap
},
)
})
.collect();
write_chunked_sse_response_with_delays(&mut stream, &plan);
});
// 总时长 ~300ms,远超 120ms 的首字预算;但每个 chunk 间隔 60ms < 空闲预算。
// 总时长 ~600ms,超过 500ms 的首字预算;但每个 chunk 间隔 150ms < 空闲预算。
let timeouts = StreamingTimeouts {
first_token: std::time::Duration::from_millis(120),
first_token: std::time::Duration::from_millis(500),
idle: std::time::Duration::from_millis(500),
};
let out = streaming_test_provider(addr)
+57 -11
View File
@@ -153,6 +153,8 @@ interface LocalAsrProps {
embedded?: boolean
}
type RefreshGuard = () => boolean
export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const { t } = useTranslation()
const { prefs, updatePrefs } = useHotkeySettings()
@@ -228,6 +230,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
>({})
const [engineStatus, setEngineStatus] =
useState<LocalAsrEngineStatus | null>(null)
const downloadDialogOpenRef = useRef(downloadDialogOpen)
const refreshGenerationRef = useRef(0)
const refreshTimer = useRef<number | null>(null)
const foundryRefreshTimer = useRef<number | null>(null)
const sherpaRefreshTimer = useRef<number | null>(null)
@@ -243,6 +247,22 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const scrollGuardTimer = useRef<number | null>(null)
const scrollGuardCleanup = useRef<(() => void) | null>(null)
const setDownloadDialog = (open: boolean) => {
if (downloadDialogOpenRef.current !== open) {
downloadDialogOpenRef.current = open
refreshGenerationRef.current += 1
}
setDownloadDialogOpen(open)
}
// 清理 interval 只能阻止下一次 tick;generation 还要丢弃已经在途的异步结果。
const makeRefreshGuard = (): RefreshGuard => {
const generation = refreshGenerationRef.current
return () =>
generation === refreshGenerationRef.current &&
!downloadDialogOpenRef.current
}
const restoreScrollGuard = () => {
const guard = scrollGuard.current
if (!guard) return
@@ -318,8 +338,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshEngineStatus = async () => {
const isCurrent = makeRefreshGuard()
try {
const status = await getLocalAsrEngineStatus()
if (!isCurrent()) return
setEngineStatus(status)
} catch (err) {
console.warn("[localAsr] engine status query failed", err)
@@ -327,8 +349,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshFoundryStatus = async () => {
const isCurrent = makeRefreshGuard()
try {
const status = await getFoundryLocalAsrStatus()
if (!isCurrent()) return
setFoundryStatus(status)
if (
!foundrySelectionDirty.current &&
@@ -338,6 +362,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
void refreshFoundryModelDir(status.activeModel)
}
} catch (err) {
if (!isCurrent()) return
const message = err instanceof Error ? err.message : String(err)
setFoundryStatus({
providerId: "foundry-local-whisper",
@@ -353,8 +378,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshFoundryCatalog = async () => {
const isCurrent = makeRefreshGuard()
try {
const catalog = await getFoundryLocalAsrCatalog()
if (!isCurrent()) return
setFoundryCatalog(catalog)
} catch (err) {
console.warn("[localAsr] Foundry catalog query failed", err)
@@ -364,8 +391,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const refreshFoundryModelDir = async (
modelAlias: FoundryLocalAsrModelAlias,
) => {
const isCurrent = makeRefreshGuard()
try {
const dir = await getFoundryLocalAsrModelDir(modelAlias)
if (!isCurrent()) return
setFoundryModelDir((current) => {
if (selectedFoundryAliasRef.current !== modelAlias) {
return current
@@ -379,6 +408,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
})
} catch (err) {
if (!isCurrent()) return
console.warn("[localAsr] Foundry model dir query failed", err)
setFoundryModelDir((current) =>
selectedFoundryAliasRef.current === modelAlias &&
@@ -390,8 +420,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshSherpaStatus = async () => {
const isCurrent = makeRefreshGuard()
try {
const status = await getSherpaOnnxAsrStatus()
if (!isCurrent()) return
setSherpaStatus(status)
if (
!sherpaSelectionDirty.current &&
@@ -401,6 +433,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
void refreshSherpaModelDir(status.activeModel)
}
} catch (err) {
if (!isCurrent()) return
const message = err instanceof Error ? err.message : String(err)
setSherpaStatus({
providerId: "sherpa-onnx-local",
@@ -414,8 +447,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshSherpaCatalog = async () => {
const isCurrent = makeRefreshGuard()
try {
const catalog = await getSherpaOnnxAsrCatalog()
if (!isCurrent()) return
setSherpaCatalog(catalog)
} catch (err) {
console.warn("[localAsr] Sherpa catalog query failed", err)
@@ -423,8 +458,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refreshSherpaModelDir = async (modelAlias: string) => {
const isCurrent = makeRefreshGuard()
try {
const dir = await getSherpaOnnxAsrModelDir(modelAlias)
if (!isCurrent()) return
setSherpaModelDir((current) => (current === dir ? current : dir))
} catch (err) {
console.warn("[localAsr] Sherpa model dir query failed", err)
@@ -432,18 +469,22 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const refresh = async () => {
const isCurrent = makeRefreshGuard()
try {
if (!isCurrent()) return
setError(null)
const [s, list] = await Promise.all([
getLocalAsrSettings(),
listLocalAsrModels(),
])
if (!isCurrent()) return
setSettings(s)
setModels(list)
void Promise.all(
list.map(async (m) => {
try {
const dir = await getLocalAsrModelDir(m.id)
if (!isCurrent()) return
setModelDirs((current) =>
current[m.id] === dir
? current
@@ -475,11 +516,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}),
)
} catch (e) {
if (!isCurrent()) return
setError(e instanceof Error ? e.message : String(e))
}
}
const ensureRemoteSize = async (modelId: string, mirror: string) => {
const isCurrent = makeRefreshGuard()
if (!isCurrent()) return
setRemoteSizes((prev) => {
if (prev[modelId] && !prev[modelId].error) return prev
return {
@@ -494,6 +538,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
})
try {
const info = await fetchLocalAsrRemoteInfo(modelId, mirror)
if (!isCurrent()) return
setRemoteSizes((prev) => ({
...prev,
[modelId]: {
@@ -504,6 +549,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
},
}))
} catch (e) {
if (!isCurrent()) return
setRemoteSizes((prev) => ({
...prev,
[modelId]: {
@@ -546,6 +592,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
modelAlias: string,
mirror: string,
) => {
const isCurrent = makeRefreshGuard()
if (!isCurrent()) return
setSherpaRemoteSizes((prev) => {
if (prev[modelAlias] && !prev[modelAlias].error) return prev
return {
@@ -560,6 +608,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
})
try {
const info = await fetchSherpaOnnxAsrRemoteInfo(modelAlias, mirror)
if (!isCurrent()) return
setSherpaRemoteSizes((prev) => ({
...prev,
[modelAlias]: {
@@ -570,6 +619,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
},
}))
} catch (e) {
if (!isCurrent()) return
setSherpaRemoteSizes((prev) => ({
...prev,
[modelAlias]: {
@@ -584,14 +634,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
useEffect(() => {
void refresh()
// 3s 轮询磁盘状态:模型被外部删除 / 下载中断时前端自动跟随(删除后
// 看板选中自动回落、下拉回到引擎级入口),不用等重开页面。qwen3 的
// list 是本地 fs walk,很轻;远端尺寸有缓存不会重复请求。
const pollTimer = window.setInterval(() => {
void refresh()
}, 3000)
return () => {
window.clearInterval(pollTimer)
if (scrollGuardCleanup.current) scrollGuardCleanup.current()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -605,7 +648,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const pollTimer = window.setInterval(() => {
void refresh()
}, 3000)
return () => window.clearInterval(pollTimer)
return () => {
refreshGenerationRef.current += 1
window.clearInterval(pollTimer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen])
@@ -1949,7 +1995,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
null
if (!dialogEntry || dialogEntry.isDownloaded) return
dispatchEntryAction(dialogEntry, "download")
setDownloadDialogOpen(false)
setDownloadDialog(false)
}
const selectedEntryRemote = selectedEntry
@@ -2036,7 +2082,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
// 立刻反映到列表与详情,不等 3s 轮询。
void refresh()
}}
onOpenDownload={() => setDownloadDialogOpen(true)}
onOpenDownload={() => setDownloadDialog(true)}
downloadDisabled={
busyModelId !== null ||
sherpaBusy !== null ||
@@ -2442,7 +2488,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
return { status: "ok" as const, card: state }
}}
onStart={startDownloadFromDialog}
onClose={() => setDownloadDialogOpen(false)}
onClose={() => setDownloadDialog(false)}
/>
)}