Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc8e03988f |
+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」拆分,且分片必须自校验覆盖
|
||||
|
||||
|
||||
@@ -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 检查。
|
||||
|
||||
Reference in New Issue
Block a user