Merge remote-tracking branch 'origin/master' into opt/design_simplify
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} />);
|
||||
|
||||
@@ -26,6 +26,11 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
|
||||
- **验证**:`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve` 的 `starting backend stack` → `backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。
|
||||
- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。
|
||||
- **关联**:`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`(`readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`、`apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。
|
||||
## 2026-09-15 AGC JSON API 的响应体也必须有等待上限
|
||||
|
||||
- `fetchClientHttp` 的超时只覆盖请求到响应头返回;随后直接等待 `response.text()` 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
|
||||
- 成功 JSON 与错误响应体均复用 `readClientHttpResponseText` 的 15 秒上限;超时后保留最后一次有效目录并释放在途请求,手动重试重新发起请求。迟到的响应不得覆盖重试获得的新目录。
|
||||
- 排查时区分接口未挂载(404)、未授权(401)、网络或响应体超时以及刷新无变化但缺少反馈;不能仅凭客户端启动 IPC 回退警告判断刷新失败原因。
|
||||
|
||||
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
|
||||
|
||||
## AGC DirectProject 与 UI workflow
|
||||
|
||||
- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。
|
||||
|
||||
- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。
|
||||
|
||||
- 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
|
||||
- `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
|
||||
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
|
||||
- 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。
|
||||
- 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。
|
||||
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
|
||||
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
|
||||
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
|
||||
@@ -19,4 +21,5 @@
|
||||
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
|
||||
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
|
||||
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
|
||||
- 响应体超时保留有效缓存、再次刷新重新请求、迟到响应不覆盖新目录;手动刷新进行中、同版本成功与缓存兜底失败反馈。
|
||||
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
|
||||
|
||||
@@ -265,7 +265,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
|
||||
- 模式合同:客户端 AppData 配置新增全局 `agentMode`,只接受 `codex_cli / provider`。缺省和新安装默认使用 `codex_cli`,原有 HTTP LLM Provider 路径完整保留并可显式切回 `provider`;切换只影响下一次节点请求,不新增 Runner、任务图、会话库、配置库或业务事实源。
|
||||
- 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。
|
||||
- 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.147.0` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。
|
||||
- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。
|
||||
- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。
|
||||
- CLI 安全边界:CLI 固定使用 argv 启动,禁止 shell 拼接;工作目录使用本次请求专用的空临时目录,不把游戏项目绝对路径写入 prompt、stdout、stderr 或持久记录。调用固定使用 ephemeral、忽略用户配置和 exec rules、read-only sandbox、never approval,并关闭 Codex shell tool;只继承 CLI 运行和认证所需的最小环境,显式移除宿主 `CODEX_API_KEY`。用户级 Codex 登录态继续由本机 Codex 自己读取,API Key、auth 文件、Cookie、Token、`CODEX_HOME` 私有内容不得复制到项目配置、Runtime sidecar、Agent DB、conversation 或日志;stdout / stderr 无换行时也受硬上限约束,stderr 诊断只记录固定分类、字节数和 SHA-256。
|
||||
- 协议边界:Runtime 把既有 `LlmRunRequest` 的消息和当前函数目录编码为有界 prompt,并从同一函数 JSON Schema 生成 Codex structured-output schema。CLI 输出转换为现有 `LlmRunResponse / LlmToolCall` 后,继续经过 native tool / MCP 参数校验、动作上限、权限、pending、receipt、验证与格式修复链;最终回复仍走唯一提交路径,不新增平行响应协议。
|
||||
- 取消与恢复:Codex 子进程绑定当前 Provider request lifecycle,取消、暂停、Runner draining 或 GUI owner 丢失时终止并回收当前进程;started 后没有可信终态仍沿现有 Provider reconciliation 处理。`agentMode`、CLI 可执行身份和影响输出的 Codex 参数进入 `providerConfigFingerprint`,模式切换不得消费另一模式遗留的 retry/handoff。
|
||||
|
||||
Reference in New Issue
Block a user