diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs index 91bbc3df..94f0c813 100644 --- a/openless-all/app/scripts/local-asr-polling-contract.test.mjs +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; const source = await readFile( new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), @@ -9,6 +10,58 @@ const refreshPolling = source.match( /window\.setInterval\(\(\) => \{\s*void refresh\(\)\s*\}, 3000\)/g, ) ?? []; +const sourceFile = ts.createSourceFile( + 'LocalAsr/index.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const localAsr = sourceFile.statements.find( + (statement) => + ts.isFunctionDeclaration(statement) && statement.name?.text === 'LocalAsr', +); +if (!localAsr?.body) { + throw new Error('LocalAsr component declaration is missing'); +} + +const jsxComponentNames = new Set(); +const collectJsxComponentNames = (node) => { + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) + ) { + jsxComponentNames.add(node.tagName.text); + } + ts.forEachChild(node, collectJsxComponentNames); +}; +collectJsxComponentNames(localAsr.body); + +const createsComponentType = (node) => + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + (ts.isConditionalExpression(node) && + (createsComponentType(node.whenTrue) || createsComponentType(node.whenFalse))); + +const nestedJsxComponents = localAsr.body.statements.flatMap((statement) => { + if (!ts.isVariableStatement(statement)) return []; + return statement.declarationList.declarations + .filter( + (declaration) => + ts.isIdentifier(declaration.name) && + jsxComponentNames.has(declaration.name.text) && + declaration.initializer && + createsComponentType(declaration.initializer), + ) + .map((declaration) => declaration.name.text); +}); + +if (nestedJsxComponents.length > 0) { + throw new Error( + `LocalAsr must not create JSX component types during render; found ${nestedJsxComponents.join(', ')}`, + ); +} + if (refreshPolling.length !== 1) { throw new Error(`LocalAsr should have one refresh poller, found ${refreshPolling.length}`); } @@ -29,4 +82,6 @@ for (const contract of [ } } -console.log('LocalAsr keeps one refresh poller and pauses it for the download dialog'); +console.log( + 'LocalAsr keeps one refresh poller, pauses it for the download dialog, and preserves stable JSX component types', +); diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index b5d21b16..19392d50 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -155,6 +155,48 @@ interface LocalAsrProps { embedded?: boolean } +interface LocalAsrContentWrapperProps { + embedded: boolean + children: ReactNode +} + +// 必须保持为模块级组件:如果在 LocalAsr 渲染函数内定义,3 秒刷新引发的任意 +// setState 都会创建新的组件类型,React 会重挂整棵子树并清空 Collapsible / +// SelectLite 等子组件的交互状态。 +function LocalAsrContentWrapper({ + embedded, + children, +}: LocalAsrContentWrapperProps) { + if (embedded) return <>{children} + return ( +
+ {children} +
+ ) +} + +function LocalAsrGroupTitle({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + type RefreshGuard = () => boolean export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { @@ -1805,38 +1847,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { ? t("localAsr.foundryRetryPrepare") : t("localAsr.sherpaPrepare") - // embedded=true 嵌入「高级」设置:跳过外层 page padding/height、PageHeader, - // 与独立警告 Card——AdvancedSection 自己负责标题与短警告 + 启用时的浮层 popup, - // LocalAsr 只输出实际功能 Cards(Foundry / Qwen3 模型状态 / 模型列表)。 - const Wrapper = embedded - ? (props: { children: ReactNode }) => <>{props.children} - : (props: { children: ReactNode }) => ( -
- {props.children} -
- ) - - // 本地模型页分组标题:模型选择 / 下载与管理 / 其他。 - const GroupTitle = ({ children }: { children: ReactNode }) => ( -
- {children} -
- ) - // ─── 两栏看板的统一模型条目(Qwen3 / sherpa-onnx / foundry 归一化) ─── // allSidebarEntries = 全目录(下载弹窗用,未下载/下载中/已下载全列出, // 让「下载新模型」弹窗能选到所有可获取的模型); @@ -2057,7 +2067,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { : null return ( - + {!embedded && ( {t("localAsr.groupDownload")} + + {t("localAsr.groupDownload")} + {IS_WINDOWS && ( @@ -3400,7 +3412,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { )} - + ) }