Merge remote-tracking branch 'origin/master' into rm/design-v2
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m39s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m27s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / Frontend tests (pull_request) Failing after 3m31s
Project CI / Repository checks (pull_request) Failing after 3m25s
Project CI / Native shell tests (pull_request) Successful in 6m48s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m16s
Project CI / Backend tests (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m39s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m27s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / Frontend tests (pull_request) Failing after 3m31s
Project CI / Repository checks (pull_request) Failing after 3m25s
Project CI / Native shell tests (pull_request) Successful in 6m48s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m16s
Project CI / Backend tests (pull_request) Successful in 7m26s
This commit is contained in:
@@ -1295,7 +1295,7 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
@@ -1307,19 +1307,19 @@ const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
};
|
||||
const expectedBundledWindowsResources = {
|
||||
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
|
||||
'codex/win-x64/bin/codex-code-mode-host.exe',
|
||||
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
|
||||
'resources/codex/win-x64/codex-path/rg.exe':
|
||||
'codex/win-x64/codex-path/rg.exe',
|
||||
'coding-agent/win-x64/codex-path/rg.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
|
||||
'codex/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
|
||||
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'resources/codex/win-x64/codex-package.json':
|
||||
'codex/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
|
||||
'coding-agent/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
|
||||
'resources/plugins': 'plugins',
|
||||
};
|
||||
assert.deepEqual(
|
||||
|
||||
+7
-1
@@ -19,8 +19,14 @@ Use this Skill as the top-level SOP for a new game or a substantial game brief.
|
||||
|
||||
## Stage transitions
|
||||
|
||||
Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and stop or repair at that stage instead of silently substituting a later-stage placeholder.
|
||||
Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and handle it under "Error handling" instead of silently substituting a later-stage placeholder.
|
||||
|
||||
For a small edit to an existing game where the brief and suitable assets are unchanged, use the focused edit path and do not regenerate art. This exception does not apply to a new game or a substantial planning brief.
|
||||
|
||||
## Error handling
|
||||
|
||||
When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output.
|
||||
|
||||
Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered.
|
||||
|
||||
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.13",
|
||||
"version": "2026-08-26.14",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -22,7 +22,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/workflow-contract.md"
|
||||
],
|
||||
"sha256": "91082fdff4123f1e1fcf930af433cbea51a8c9d26991678b19028b344ea49f39"
|
||||
"sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c"
|
||||
},
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
|
||||
@@ -6,8 +6,9 @@ use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str =
|
||||
"coding-agent/win-x64/manifest.json";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [
|
||||
"bin/codex.exe",
|
||||
"bin/codex-code-mode-host.exe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Genarrative AI Game Creator",
|
||||
"productName": "陶泥儿",
|
||||
"version": "0.1.29",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"targets": ["nsis"],
|
||||
"useLocalToolsDir": true,
|
||||
"resources": {
|
||||
"resources/codex/win-x64/bin/codex.exe": "codex/win-x64/bin/codex.exe",
|
||||
"resources/codex/win-x64/bin/codex-code-mode-host.exe": "codex/win-x64/bin/codex-code-mode-host.exe",
|
||||
"resources/codex/win-x64/codex-path/rg.exe": "codex/win-x64/codex-path/rg.exe",
|
||||
"resources/codex/win-x64/codex-resources/codex-command-runner.exe": "codex/win-x64/codex-resources/codex-command-runner.exe",
|
||||
"resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe",
|
||||
"resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json",
|
||||
"resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md",
|
||||
"resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json",
|
||||
"resources/codex/win-x64/bin/codex.exe": "coding-agent/win-x64/bin/codex.exe",
|
||||
"resources/codex/win-x64/bin/codex-code-mode-host.exe": "coding-agent/win-x64/bin/codex-code-mode-host.exe",
|
||||
"resources/codex/win-x64/codex-path/rg.exe": "coding-agent/win-x64/codex-path/rg.exe",
|
||||
"resources/codex/win-x64/codex-resources/codex-command-runner.exe": "coding-agent/win-x64/codex-resources/codex-command-runner.exe",
|
||||
"resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe",
|
||||
"resources/codex/win-x64/codex-package.json": "coding-agent/win-x64/codex-package.json",
|
||||
"resources/codex/win-x64/NOTICE.md": "coding-agent/win-x64/NOTICE.md",
|
||||
"resources/codex/win-x64/manifest.json": "coding-agent/win-x64/manifest.json",
|
||||
"resources/plugins": "plugins"
|
||||
}
|
||||
}
|
||||
|
||||
+33
-7
@@ -14,6 +14,8 @@ import type {
|
||||
ClientLlmModel,
|
||||
ClientLlmModelCatalog,
|
||||
} from '../../services/clientApi';
|
||||
import { ClientAuthRequestError } from '../../services/clientApi';
|
||||
import { ClientHttpTimeoutError } from '../../services/clientHttp';
|
||||
import {
|
||||
cachedLlmModelCatalog,
|
||||
refreshLlmModelCatalog,
|
||||
@@ -27,6 +29,14 @@ export type ConversationModelSelectHandle = {
|
||||
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
|
||||
class ModelSelectionConfigError extends Error {}
|
||||
|
||||
function modelCatalogErrorMessage(error: unknown) {
|
||||
if (error instanceof ClientHttpTimeoutError)
|
||||
return '模型列表请求超时,请重试';
|
||||
if (error instanceof ClientAuthRequestError && error.status)
|
||||
return `模型列表加载失败(HTTP ${error.status})`;
|
||||
return '模型列表加载失败';
|
||||
}
|
||||
|
||||
export function ConversationModelSelect({
|
||||
className,
|
||||
disabled,
|
||||
@@ -51,6 +61,7 @@ export function ConversationModelSelect({
|
||||
const [busy, setBusy] = useState(!initialCatalog);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appliedRevisionRef = useRef<number | null>(
|
||||
@@ -180,7 +191,11 @@ export function ConversationModelSelect({
|
||||
);
|
||||
|
||||
const syncCatalog = useCallback(
|
||||
async (showBusy: boolean) => {
|
||||
async (showBusy: boolean, manualRefresh = false) => {
|
||||
if (manualRefresh && mountedRef.current) {
|
||||
setManualRefreshBusy(true);
|
||||
setNotice('正在刷新模型列表');
|
||||
}
|
||||
const busyToken = showBusy
|
||||
? ++busyTokenRef.current
|
||||
: busyTokenRef.current;
|
||||
@@ -195,12 +210,17 @@ export function ConversationModelSelect({
|
||||
try {
|
||||
let catalog: ClientLlmModelCatalog;
|
||||
let usingCachedCatalog = false;
|
||||
let catalogError: unknown = null;
|
||||
try {
|
||||
catalog = await refreshLlmModelCatalog();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
catalogError = error;
|
||||
const cached = cachedLlmModelCatalog();
|
||||
if (!cached) {
|
||||
if (mountedRef.current) setError('模型列表加载失败');
|
||||
if (mountedRef.current) {
|
||||
setError(modelCatalogErrorMessage(error));
|
||||
setNotice('');
|
||||
}
|
||||
markReady(false);
|
||||
return false;
|
||||
}
|
||||
@@ -209,10 +229,14 @@ export function ConversationModelSelect({
|
||||
}
|
||||
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
|
||||
if (usingCachedCatalog && mountedRef.current)
|
||||
setError('模型列表加载失败');
|
||||
setError(modelCatalogErrorMessage(catalogError));
|
||||
if (manualRefresh && mountedRef.current) {
|
||||
setNotice(usingCachedCatalog ? '' : '模型列表已刷新');
|
||||
}
|
||||
return ready;
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setNotice('');
|
||||
setError(
|
||||
error instanceof ModelSelectionConfigError
|
||||
? error.message
|
||||
@@ -230,6 +254,7 @@ export function ConversationModelSelect({
|
||||
) {
|
||||
setBusy(false);
|
||||
}
|
||||
if (manualRefresh && mountedRef.current) setManualRefreshBusy(false);
|
||||
}
|
||||
},
|
||||
[applyCatalog, markReady],
|
||||
@@ -375,11 +400,12 @@ export function ConversationModelSelect({
|
||||
type="button"
|
||||
className="conversation-model-menu-refresh"
|
||||
aria-label="刷新模型列表"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void syncCatalog(true)}
|
||||
disabled={disabled || busy || manualRefreshBusy}
|
||||
aria-busy={manualRefreshBusy}
|
||||
onClick={() => void syncCatalog(true, true)}
|
||||
>
|
||||
<RefreshCcw size={13} aria-hidden="true" />
|
||||
<span>刷新模型列表</span>
|
||||
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ProfileWalletLedgerResponse,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp } from './clientHttp';
|
||||
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
|
||||
import { captureClientError } from './errorReporting';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
@@ -48,8 +48,12 @@ export function clearStoredAuthAccessToken() {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function readApiErrorMessage(response: Response, fallback: string) {
|
||||
const text = await response.text();
|
||||
async function readApiErrorMessage(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
url: string,
|
||||
) {
|
||||
const text = await readClientHttpResponseText(response, { url });
|
||||
if (!text.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -126,11 +130,11 @@ export async function requestClientApi<T>(
|
||||
if (!response.ok) {
|
||||
captureApiErrorStatus(url, response);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
await readApiErrorMessage(response, fallbackMessage, url),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
const text = await readClientHttpResponseText(response, { url });
|
||||
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
||||
}
|
||||
|
||||
@@ -163,7 +167,7 @@ export async function requestClientApiBytes(
|
||||
if (!response.ok) {
|
||||
captureApiErrorStatus(url, response);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
await readApiErrorMessage(response, fallbackMessage, url),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
getClientAuthRefreshOperation,
|
||||
refreshClientAuthAccessToken,
|
||||
} from '../src/services/clientAuth';
|
||||
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
|
||||
import {
|
||||
cachedLlmModelCatalog,
|
||||
refreshLlmModelCatalog,
|
||||
resetLlmModelCatalogCacheForTest,
|
||||
} from '../src/services/llmModelCatalog';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
@@ -18,6 +24,10 @@ import {
|
||||
} from '../src/services/platformSession';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
|
||||
vi.mock(
|
||||
'../../../packages/shared/src',
|
||||
() => import('../../../packages/shared/src/http'),
|
||||
);
|
||||
vi.mock('../src/services/errorReporting', () => ({
|
||||
captureClientError: vi.fn(),
|
||||
}));
|
||||
@@ -29,6 +39,7 @@ const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), { status });
|
||||
|
||||
beforeEach(async () => {
|
||||
resetLlmModelCatalogCacheForTest();
|
||||
resetPlatformSessionStateForTests();
|
||||
window.localStorage.clear();
|
||||
nativeInvoke.mockClear();
|
||||
@@ -42,12 +53,60 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetLlmModelCatalogCacheForTest();
|
||||
resetPlatformSessionStateForTests();
|
||||
window.localStorage.clear();
|
||||
delete window.__TAURI__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([200, 503])(
|
||||
'模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新',
|
||||
async (status) => {
|
||||
vi.useFakeTimers();
|
||||
const previous = { ...catalog, defaultModelId: 'quality', revision: 1 };
|
||||
const updated = {
|
||||
defaultModelId: 'fast',
|
||||
models: [{ id: 'fast', displayName: '快速' }],
|
||||
revision: 2,
|
||||
};
|
||||
let body!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const stalledResponse = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
body = controller;
|
||||
},
|
||||
}),
|
||||
{ status },
|
||||
);
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(json(previous))
|
||||
.mockResolvedValueOnce(stalledResponse)
|
||||
.mockResolvedValueOnce(json(updated));
|
||||
await expect(refreshLlmModelCatalog()).resolves.toEqual(previous);
|
||||
let failure: unknown;
|
||||
const pending = refreshLlmModelCatalog().catch((error: unknown) => {
|
||||
failure = error;
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS);
|
||||
expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' });
|
||||
await pending;
|
||||
expect(cachedLlmModelCatalog()).toEqual(previous);
|
||||
await expect(refreshLlmModelCatalog()).resolves.toEqual(updated);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
// 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。
|
||||
body.enqueue(new TextEncoder().encode(JSON.stringify(previous)));
|
||||
body.close();
|
||||
await pending;
|
||||
}
|
||||
expect(cachedLlmModelCatalog()).toEqual(updated);
|
||||
},
|
||||
);
|
||||
|
||||
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
|
||||
let refreshCalls = 0;
|
||||
let modelCalls = 0;
|
||||
|
||||
@@ -16,11 +16,36 @@ import {
|
||||
ConversationModelSelect,
|
||||
type ConversationModelSelectHandle,
|
||||
} from '../src/features/project-workspace/ConversationModelSelect';
|
||||
import { loadClientLlmModels } from '../src/services/clientApi';
|
||||
import {
|
||||
ClientAuthRequestError,
|
||||
type ClientLlmModelCatalog,
|
||||
loadClientLlmModels,
|
||||
} from '../src/services/clientApi';
|
||||
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
|
||||
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
|
||||
|
||||
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
|
||||
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
|
||||
const MockClientAuthRequestError = vi.hoisted(
|
||||
() =>
|
||||
class MockClientAuthRequestError extends Error {
|
||||
readonly status: number | null;
|
||||
readonly networkError: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: { status?: number | null; networkError?: boolean } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.status = options.status ?? null;
|
||||
this.networkError = options.networkError ?? false;
|
||||
}
|
||||
},
|
||||
);
|
||||
vi.mock('../src/services/clientApi', () => ({
|
||||
ClientAuthRequestError: MockClientAuthRequestError,
|
||||
loadClientLlmModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
|
||||
const invoke = vi.fn();
|
||||
let savedModelId = 'quality';
|
||||
let savedModelIsDefault = true;
|
||||
@@ -54,6 +79,122 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
async function renderReadyModelMenu() {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
});
|
||||
return onReady;
|
||||
}
|
||||
|
||||
test('shows manual refresh progress immediately without clearing the selected model', async () => {
|
||||
const onReady = await renderReadyModelMenu();
|
||||
let resolveRefresh!: (catalog: ClientLlmModelCatalog) => void;
|
||||
vi.mocked(loadClientLlmModels).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
|
||||
const refreshButton = screen.getByRole('button', { name: '刷新模型列表' });
|
||||
expect(refreshButton.textContent).toBe('刷新中…');
|
||||
expect(refreshButton).toHaveProperty('disabled', true);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
).toContain('高质量');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
defaultModelId: 'quality',
|
||||
models: [{ id: 'quality', displayName: '高质量' }],
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toBe('模型列表已刷新');
|
||||
expect(savedModelId).toBe('quality');
|
||||
expect(onReady).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
test('confirms a manual refresh even when the catalog revision is unchanged', async () => {
|
||||
await renderReadyModelMenu();
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await screen.findByText('模型列表已刷新');
|
||||
expect(loadClientLlmModels).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
screen
|
||||
.getByRole('option', { name: /高质量/ })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
|
||||
'disabled',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
'HTTP 404',
|
||||
new ClientAuthRequestError('private server detail', { status: 404 }),
|
||||
'模型列表加载失败(HTTP 404)',
|
||||
],
|
||||
[
|
||||
'HTTP 401',
|
||||
new ClientAuthRequestError('private server detail', { status: 401 }),
|
||||
'模型列表加载失败(HTTP 401)',
|
||||
],
|
||||
[
|
||||
'timeout',
|
||||
new ClientHttpTimeoutError('https://private.example/models', 15000),
|
||||
'模型列表请求超时,请重试',
|
||||
],
|
||||
['unknown', new Error('private server detail'), '模型列表加载失败'],
|
||||
])(
|
||||
'reports a safe %s failure with cached models and permits retry without claiming success',
|
||||
async (_label, failure, message) => {
|
||||
const onReady = await renderReadyModelMenu();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await screen.findByText('模型列表已刷新');
|
||||
|
||||
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(failure);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toBe(message),
|
||||
);
|
||||
expect(screen.queryByText('模型列表已刷新')).toBeNull();
|
||||
expect(screen.queryByText('正在刷新模型列表')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('private');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
).toContain('高质量');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('option', { name: /高质量/ })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(savedModelId).toBe('quality');
|
||||
expect(onReady).toHaveBeenLastCalledWith(true);
|
||||
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
|
||||
'disabled',
|
||||
false,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await screen.findByText('模型列表已刷新');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('only displays aliases and persists selection through the native command', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
|
||||
Reference in New Issue
Block a user