Compare commits

...

1 Commits

Author SHA1 Message Date
kdletters dc8e03988f 修复AGC模型列表刷新无反馈与响应体无限等待
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m20s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m8s
Project CI / AI game creator shell Rust crates (push) Successful in 3m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m4s
Project CI / Repository checks (push) Successful in 3m27s
Project CI / Frontend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 8m47s
ConversationModelSelect:手动刷新立即显示「刷新中…」,真实刷新成功后显示「模型列表已刷新」,revision 未变化也有反馈
ConversationModelSelect:刷新失败按 HTTP 状态与超时给出可辨认提示,沿用有效缓存时不报告刷新成功,已选模型与挂载/聚焦语义不变
clientApi:成功与错误响应体统一走 readClientHttpResponseText 的 15 秒上限,响应体卡住时结束等待并释放模型目录在途请求
测试:补齐模型目录响应体超时后保留缓存并可重试、迟到响应不覆盖新目录、手动刷新进行中/同版本成功/失败反馈用例
文档:同步 AGC 模型目录刷新反馈与响应体超时合同,并在共享记忆记录该排障口径
2026-09-15 14:41:57 +08:00
6 changed files with 253 additions and 15 deletions
@@ -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」拆分,且分片必须自校验覆盖
@@ -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 检查。