修复AGC模型目录同步的加载态与保存竞态
Project CI / Repository checks (pull_request) Successful in 2m56s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Backend tests (pull_request) Failing after 4m34s
Project CI / Native shell tests (pull_request) Failing after 7m54s

- ConversationModelSelect:目录同步与配置读取/写回失败时统一在 finally 收起加载态,避免选择器永久卡在 busy、无法切换或刷新
- ConversationModelSelect:配置读取失败单独提示「读取客户端配置失败」,不再误报为模型目录加载失败
- ConversationModelSelect:配置写回串行化,发送前校验等待在途保存并读取最新配置,避免保存中放行旧选择、或用旧快照覆盖刚完成的选择
- ConversationModelSelect:服务端未返回 revision 时按目录已变化处理,避免界面停止刷新
- ProjectSupervisorView:提交前模型校验期间禁用输入框与发送按钮,避免重复提交与校验窗口内编辑丢失
- 测试:补充配置读取失败恢复、保存中发送前校验等待、目录请求去重用例;修正依赖配置读取时序的 appSurface 用例
- 文档:修正首页入口「按需加载」与实现不符的描述
This commit is contained in:
2026-09-09 12:00:10 +08:00
parent 2942489575
commit 07fc26953a
5 changed files with 191 additions and 56 deletions
@@ -24,6 +24,9 @@ export type ConversationModelSelectHandle = {
ensureUsable: () => Promise<boolean>;
};
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {}
export function ConversationModelSelect({
className,
disabled,
@@ -55,7 +58,8 @@ export function ConversationModelSelect({
);
const selectedRef = useRef('');
const selectionEpochRef = useRef(0);
const saveInFlightRef = useRef(false);
const busyTokenRef = useRef(0);
const configWriteChainRef = useRef<Promise<unknown>>(Promise.resolve());
const onReadyRef = useRef(onReady);
const mountedRef = useRef(true);
@@ -74,31 +78,51 @@ export function ConversationModelSelect({
onReadyRef.current?.(ready);
}, []);
// 配置写回串行化:目录同步的写回与用户选择按入队顺序落盘,用户选择最后写入。
const queueConfigWrite = useCallback(
(write: () => Promise<GameCreatorAppConfigView>) => {
const run = configWriteChainRef.current.then(write, write);
configWriteChainRef.current = run.then(
() => undefined,
() => undefined,
);
return run;
},
[],
);
const waitForConfigWrite = useCallback(
() => configWriteChainRef.current.then(() => undefined),
[],
);
const applyCatalog = useCallback(
async (
catalog: ClientLlmModelCatalog,
showBusy: boolean,
epochAtRequest: number,
configPromise: Promise<GameCreatorAppConfigView | null>,
) => {
if (
mountedRef.current &&
appliedRevisionRef.current !== catalog.revision
(appliedRevisionRef.current !== catalog.revision ||
!Number.isFinite(catalog.revision))
) {
appliedRevisionRef.current = catalog.revision;
setModels(catalog.models);
setDefaultModelId(catalog.defaultModelId);
}
const invoke = resolveTauriInvoke();
const config = await configPromise;
if (!invoke || !config) throw new Error('Native host unavailable');
if (
saveInFlightRef.current ||
selectionEpochRef.current !== epochAtRequest
) {
if (mountedRef.current && showBusy) setBusy(false);
if (!invoke) throw new ModelSelectionConfigError('读取客户端配置失败');
// 有在途写回时先等它结束,避免读到旧配置、也避免用旧快照覆盖新选择。
await waitForConfigWrite();
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
const config = await invoke<GameCreatorAppConfigView>(
'read_game_creator_app_config',
).catch(() => null);
if (!config) throw new ModelSelectionConfigError('读取客户端配置失败');
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
}
const saved = config.config.selectedModelId;
const followsDefault = config.config.selectedModelIsDefault === true;
const enabled = (id: string) =>
@@ -123,31 +147,43 @@ export function ConversationModelSelect({
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
}
if (next && (next !== saved || nextIsDefault !== followsDefault)) {
const persisted = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: next, isDefault: nextIsDefault },
);
let persisted: GameCreatorAppConfigView;
try {
persisted = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: next,
isDefault: nextIsDefault,
}),
);
} catch {
throw new ModelSelectionConfigError('模型选择保存失败');
}
if (
persisted.config.selectedModelId !== next ||
persisted.config.selectedModelIsDefault !== nextIsDefault
)
throw new Error('Model selection was not saved');
throw new ModelSelectionConfigError('模型选择保存失败');
}
// 写回期间用户又做了新选择:保留新选择,不用本次快照覆盖界面。
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
const ready = Boolean(next);
if (!mountedRef.current) return ready;
selectedRef.current = next;
setSelected(next);
setNotice(nextNotice);
setError(ready ? '' : '请选择可用模型');
if (showBusy) setBusy(false);
markReady(ready);
return ready;
},
[markReady],
[markReady, queueConfigWrite, waitForConfigWrite],
);
const syncCatalog = useCallback(
async (showBusy: boolean) => {
const busyToken = showBusy
? ++busyTokenRef.current
: busyTokenRef.current;
if (showBusy) {
if (mountedRef.current) {
setBusy(true);
@@ -156,38 +192,44 @@ export function ConversationModelSelect({
markReady(false);
}
const epochAtRequest = selectionEpochRef.current;
const invoke = resolveTauriInvoke();
const configPromise: Promise<GameCreatorAppConfigView | null> = invoke
? invoke<GameCreatorAppConfigView>(
'read_game_creator_app_config',
).catch(() => null)
: Promise.resolve(null);
try {
const catalog = await refreshLlmModelCatalog();
return await applyCatalog(
catalog,
showBusy,
epochAtRequest,
configPromise,
);
} catch {
const cached = cachedLlmModelCatalog();
if (cached) {
const ready = await applyCatalog(
cached,
showBusy,
epochAtRequest,
configPromise,
).catch(() => false);
if (mountedRef.current) setError('模型列表加载失败');
return ready;
let catalog: ClientLlmModelCatalog;
let usingCachedCatalog = false;
try {
catalog = await refreshLlmModelCatalog();
} catch {
const cached = cachedLlmModelCatalog();
if (!cached) {
if (mountedRef.current) setError('模型列表加载失败');
markReady(false);
return false;
}
catalog = cached;
usingCachedCatalog = true;
}
if (mountedRef.current) {
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current)
setError('模型列表加载失败');
if (showBusy) setBusy(false);
return ready;
} catch (error) {
if (mountedRef.current) {
setError(
error instanceof ModelSelectionConfigError
? error.message
: '模型列表加载失败',
);
}
markReady(false);
return false;
} finally {
// 只有最新的 showBusy 同步负责收起加载态,失败路径也必须恢复可交互。
if (
showBusy &&
mountedRef.current &&
busyTokenRef.current === busyToken
) {
setBusy(false);
}
}
},
[applyCatalog, markReady],
@@ -232,16 +274,17 @@ export function ConversationModelSelect({
async function select(id: string) {
selectionEpochRef.current += 1;
saveInFlightRef.current = true;
markReady(false);
setBusy(true);
setError('');
try {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
const result = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: id, isDefault: false },
const result = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: id,
isDefault: false,
}),
);
if (result.config.selectedModelId !== id)
throw new Error('Selection was not saved');
@@ -255,7 +298,6 @@ export function ConversationModelSelect({
if (mountedRef.current) setError('模型选择保存失败');
markReady(false);
} finally {
saveInFlightRef.current = false;
if (mountedRef.current) setBusy(false);
}
}
@@ -149,7 +149,9 @@ export function ProjectSupervisorView({
: '发送';
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
return (
<section
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
@@ -331,18 +333,28 @@ export function ProjectSupervisorView({
return;
}
event.preventDefault();
if (modelValidateInFlightRef.current) return;
modelValidateInFlightRef.current = true;
setModelValidating(true);
const validateModel = async () => {
const ready = modelSelectRef.current
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) onSubmit(event);
try {
const ready = modelSelectRef.current
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) onSubmit(event);
} finally {
modelValidateInFlightRef.current = false;
setModelValidating(false);
}
};
void validateModel();
}}
>
<textarea
aria-label={directCodex ? '陶泥儿对话内容' : '项目需求'}
disabled={runtimePanelProps.controlBusy || needsUserInput}
disabled={
runtimePanelProps.controlBusy || needsUserInput || modelValidating
}
rows={3}
value={chatInput}
placeholder={
@@ -379,7 +391,7 @@ export function ProjectSupervisorView({
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
(directCodex && !modelReady)
(directCodex && (!modelReady || modelValidating))
}
>
{submitting ? (
@@ -711,6 +711,8 @@ export function registerRuntimeSettingsTests() {
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
// 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。
await waitFor(() => expect(readCount).toBe(1));
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
@@ -198,6 +198,8 @@ test('keeps model options disabled while a selection save is in flight', async (
);
expect(onReady).toHaveBeenLastCalledWith(false);
// 配置写回按队列落盘,保存请求在下一个微任务才发出。
await waitFor(() => expect(resolveSave).toBeDefined());
resolveSave?.({ config: { selectedModelId: 'fast' } });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
@@ -321,3 +323,80 @@ test('keeps an explicit selection when the server default changes', async () =>
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('recovers the selector when reading the native config fails', async () => {
invoke.mockRejectedValueOnce(new Error('config unreadable'));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('读取客户端配置失败');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false));
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(
screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('读取客户端配置失败')).toBeNull();
});
test('pre-send validation waits for an in-flight selection save', async () => {
let resolveSave: (() => void) | undefined;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
await new Promise<void>((resolve) => {
resolveSave = () => {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
resolve();
};
});
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
let settled = false;
let pending!: Promise<boolean>;
await act(async () => {
pending = ref.current!.ensureUsable().finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(settled).toBe(false);
resolveSave?.();
await act(async () => {
await expect(pending).resolves.toBe(true);
});
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('reuses a single in-flight catalog request', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(loadClientLlmModels).toHaveBeenCalledTimes(1);
fireEvent(window, new Event('focus'));
fireEvent(window, new Event('focus'));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
expect(loadClientLlmModels).toHaveBeenCalledTimes(2);
});