Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions openless-all/app/scripts/local-asr-polling-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
22 changes: 17 additions & 5 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 57 additions & 11 deletions openless-all/app/src/pages/LocalAsr/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ interface LocalAsrProps {
embedded?: boolean
}

type RefreshGuard = () => boolean

export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const { t } = useTranslation()
const { prefs, updatePrefs } = useHotkeySettings()
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -318,17 +338,21 @@ 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)
}
}

const refreshFoundryStatus = async () => {
const isCurrent = makeRefreshGuard()
try {
const status = await getFoundryLocalAsrStatus()
if (!isCurrent()) return
setFoundryStatus(status)
if (
!foundrySelectionDirty.current &&
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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 &&
Expand All @@ -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 &&
Expand All @@ -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",
Expand All @@ -414,36 +447,44 @@ 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)
}
}

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)
}
}

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
Expand Down Expand Up @@ -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 {
Expand All @@ -494,6 +538,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
})
try {
const info = await fetchLocalAsrRemoteInfo(modelId, mirror)
if (!isCurrent()) return
setRemoteSizes((prev) => ({
...prev,
[modelId]: {
Expand All @@ -504,6 +549,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
},
}))
} catch (e) {
if (!isCurrent()) return
setRemoteSizes((prev) => ({
...prev,
[modelId]: {
Expand Down Expand Up @@ -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 {
Expand All @@ -560,6 +608,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
})
try {
const info = await fetchSherpaOnnxAsrRemoteInfo(modelAlias, mirror)
if (!isCurrent()) return
setSherpaRemoteSizes((prev) => ({
...prev,
[modelAlias]: {
Expand All @@ -570,6 +619,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
},
}))
} catch (e) {
if (!isCurrent()) return
setSherpaRemoteSizes((prev) => ({
...prev,
[modelAlias]: {
Expand All @@ -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
Expand All @@ -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])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2036,7 +2082,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
// 立刻反映到列表与详情,不等 3s 轮询。
void refresh()
}}
onOpenDownload={() => setDownloadDialogOpen(true)}
onOpenDownload={() => setDownloadDialog(true)}
downloadDisabled={
busyModelId !== null ||
sherpaBusy !== null ||
Expand Down Expand Up @@ -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)}
/>
)}

Expand Down
Loading