fix(ui): 修复模型选择刷新闪烁 (#970)
* fix(ui): 修复模型选择刷新闪烁 (#958) * test(polish): 放宽流式首字测试时序裕量 * fix(ui): ignore stale LocalAsr refresh results
This commit is contained in:
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');
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user