推理档:外部改配置后重读并重新种「最近落盘值」,重选同一档仍然落盘
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m14s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m30s
Project CI / Repository checks (pull_request) Successful in 7m16s
Project CI / Native shell tests (pull_request) Successful in 9m2s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m14s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m30s
Project CI / Repository checks (pull_request) Successful in 7m16s
Project CI / Native shell tests (pull_request) Successful in 9m2s
- 问题(评审 D5 边界):`persistedEffortRef` 只在挂载回读与自身落盘时更新,而配置还有别的写入者——`RuntimeConfigDialog` 保存后会 `notifyLlmConfigChanged()`。外部把 `llm.reasoningEffort` 改成「最高」后,常驻挂载的滑块手上仍是旧口径:用户改选「高」时 `next !== persisted` 本来成立,但若外部改成了用户要选的**同一档**(或先外部改档再重选原档),去重会把这次点击吞掉,界面显示 A、磁盘是 B - 改法:把「读一次配置」抽成 `readEffortRef`,挂载时读一次,另外在 `LLM_CONFIG_CHANGED_EVENT` 与窗口 `focus` 时**重读并重新种 `persistedEffortRef`**(只读不写盘;`silent` 时不重复报「推理档读取失败」);写盘在途或预览未提交时不回显,避免回读旧值把界面顶回去 - 用例:`chat-composer.suite.ts`「an external config change is re-read so re-selecting that grade still commits」——挂载读到「默认」→ 外部改档 + 派发 `LLM_CONFIG_CHANGED_EVENT` → 断言回显成「最高」→ 用户改选「高」→ **必须写一次**(改前该用例红:writes 里没有 'high',界面对着旧口径把这次点击去重掉) - 滑块 pointercancel 的用例也在这一条里(handler 随上一条提交落地):预览后 `pointercancel` → 恰好写一次 Refs #600, PR #607
This commit is contained in:
+31
-6
@@ -32,6 +32,7 @@ import {
|
||||
DEFAULT_COMPOSER_REASONING_EFFORT,
|
||||
normalizeComposerReasoningEffort,
|
||||
} from '../../../../../features/project-workspace/composerReasoningEffort';
|
||||
import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog';
|
||||
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import {
|
||||
resolveSpeechRecognitionCtor,
|
||||
@@ -383,28 +384,52 @@ export function ComposerReasoningEffortSelect({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
/*
|
||||
读一次配置:种「最近落盘值」并在空闲时回显。
|
||||
—— 配置有**别的写入者**(运行配置对话框改 `llm.reasoningEffort` 后 `notifyLlmConfigChanged()`,
|
||||
也可能在窗口失焦期间被改)。只在挂载时读一次会让「最近落盘值」过期:用户重选**同一档**会被
|
||||
去重跳过,界面显示 A、磁盘却是 B。所以挂载、`LLM_CONFIG_CHANGED_EVENT`、窗口重新获得焦点
|
||||
时都重读一次(**只读不写盘**),重新种 `persistedEffortRef`。
|
||||
—— 写盘在途或拖动预览未提交时不回显(回读拿到的还是旧值,回显会让界面闪一下)。
|
||||
*/
|
||||
const writeInFlightRef = useRef(0);
|
||||
const readEffortRef = useRef<(silent?: boolean) => void>(() => undefined);
|
||||
readEffortRef.current = (silent = false) => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
|
||||
.then((view) => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
if (!mountedRef.current) return;
|
||||
const read = normalizeComposerReasoningEffort(
|
||||
view?.config?.llm?.reasoningEffort,
|
||||
);
|
||||
// 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。
|
||||
persistedEffortRef.current = read;
|
||||
if (writeInFlightRef.current > 0 || previewEffortRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
setEffort(read);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
if (!mountedRef.current || silent) return;
|
||||
setNotice('推理档读取失败');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
readEffortRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 外部写入者改档 / 窗口重新获得焦点:重读一次让「最近落盘值」跟上,避免去重吞掉真实改档。
|
||||
const reread = () => readEffortRef.current(true);
|
||||
window.addEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
|
||||
window.addEventListener('focus', reread);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
|
||||
window.removeEventListener('focus', reread);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect';
|
||||
import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences';
|
||||
import {
|
||||
LLM_CONFIG_CHANGED_EVENT,
|
||||
LlmModelCatalogConfigError,
|
||||
resetLlmModelCatalogCacheForTest,
|
||||
} from '../../src/services/llmModelCatalog';
|
||||
@@ -1279,6 +1280,101 @@ export function registerChatComposerControlTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('strength slider: an external config change is re-read so re-selecting that grade still commits', async () => {
|
||||
// 配置还有别的写入者(`RuntimeConfigDialog` 保存后 `notifyLlmConfigChanged()`)。组件必须重读
|
||||
// 配置并重新种「最近落盘值」,否则外部把档位改成「最高」后用户再选「高」会被去重跳过:
|
||||
// 界面显示高、磁盘仍是最高(旧实现在这条用例里 writes 不含 'high')。
|
||||
let stored = 'default';
|
||||
const writes: string[] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return gameCreatorConfigView(stored);
|
||||
}
|
||||
if (command === 'select_game_creator_reasoning_effort') {
|
||||
writes.push(String(args?.effort ?? ''));
|
||||
stored = String(args?.effort ?? '');
|
||||
return gameCreatorConfigView(stored);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen: async () => () => undefined },
|
||||
});
|
||||
render(
|
||||
React.createElement(ComposerReasoningEffortSelect, {
|
||||
disabled: false,
|
||||
presentation: 'section',
|
||||
}),
|
||||
);
|
||||
const slider = (await screen.findByRole('slider', {
|
||||
name: '思维强度',
|
||||
})) as HTMLInputElement;
|
||||
await waitFor(() =>
|
||||
expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
|
||||
);
|
||||
|
||||
// 外部改档(另一处写完配置会派发这个事件):组件重读并回显成「最高」。
|
||||
stored = 'max';
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(slider.getAttribute('aria-valuetext')).toBe('最高'),
|
||||
);
|
||||
|
||||
// 用户改选「高」:仍然要真落盘一次(档位枚举 0=默认 1=低 2=中 3=高 4=最高)。
|
||||
fireEvent.input(slider, { target: { value: '3' } });
|
||||
fireEvent.keyUp(slider);
|
||||
await waitFor(() => expect(writes).toEqual(['high']));
|
||||
expect(stored).toBe('high');
|
||||
});
|
||||
|
||||
it('strength slider: pointercancel commits the pending preview once', async () => {
|
||||
// 触屏取消(pointercancel)与「松手即提交」同一口径:预览值不能滞留到下一次提交或卸载 flush。
|
||||
// jsdom 没有 PointerEvent,这里派发同名原生事件(React 的合成 handler 按事件名分发)。
|
||||
const writes: string[] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'select_game_creator_reasoning_effort') {
|
||||
writes.push(String(args?.effort ?? ''));
|
||||
return gameCreatorConfigView(String(args?.effort ?? ''));
|
||||
}
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return gameCreatorConfigView('default');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen: async () => () => undefined },
|
||||
});
|
||||
render(
|
||||
React.createElement(ComposerReasoningEffortSelect, {
|
||||
disabled: false,
|
||||
presentation: 'section',
|
||||
}),
|
||||
);
|
||||
const slider = (await screen.findByRole('slider', {
|
||||
name: '思维强度',
|
||||
})) as HTMLInputElement;
|
||||
await waitFor(() =>
|
||||
expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
|
||||
);
|
||||
|
||||
fireEvent.input(slider, { target: { value: '2' } });
|
||||
expect(writes, '拖动过程中不落盘').toEqual([]);
|
||||
await act(async () => {
|
||||
slider.dispatchEvent(new Event('pointercancel', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(writes).toEqual(['medium']));
|
||||
});
|
||||
|
||||
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
|
||||
const { surface } = await openDirectCodexSurface({
|
||||
load_game_creator_llm_models: () => {
|
||||
|
||||
Reference in New Issue
Block a user