修复AGC模型目录同步的加载态与保存竞态
- ConversationModelSelect:目录同步与配置读取/写回失败时统一在 finally 收起加载态,避免选择器永久卡在 busy、无法切换或刷新 - ConversationModelSelect:配置读取失败单独提示「读取客户端配置失败」,不再误报为模型目录加载失败 - ConversationModelSelect:配置写回串行化,发送前校验等待在途保存并读取最新配置,避免保存中放行旧选择、或用旧快照覆盖刚完成的选择 - ConversationModelSelect:服务端未返回 revision 时按目录已变化处理,避免界面停止刷新 - ProjectSupervisorView:提交前模型校验期间禁用输入框与发送按钮,避免重复提交与校验窗口内编辑丢失 - 测试:补充配置读取失败恢复、保存中发送前校验等待、目录请求去重用例;修正依赖配置读取时序的 appSurface 用例 - 文档:修正首页入口「按需加载」与实现不符的描述
This commit is contained in:
+91
-49
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user