优化AGC客户端同步LLM配置
Project CI / Frontend tests (pull_request) Successful in 3m54s
Project CI / Repository checks (pull_request) Successful in 5m41s
Project CI / Backend tests (pull_request) Successful in 14m56s
Project CI / Native shell tests (pull_request) Failing after 15m10s

- GET /api/llm/models 增加目录 revision,客户端据此做条件刷新
- AGC 客户端在项目切换、对话表面挂载、下拉展开、窗口聚焦时按 revision 条件刷新
- 模型目录请求同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择
- 发起对话前校验所选模型,已停用或删除时回退默认模型并提示
- 新增模型目录缓存模块与定向测试,同步技术方案与后端架构文档
This commit is contained in:
2026-09-08 16:11:36 +08:00
parent 1498247e9f
commit d60c4ce6ec
11 changed files with 334 additions and 59 deletions
@@ -1,64 +1,181 @@
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { Ref } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorAppConfigView } from '../../app/types';
import {
type ClientLlmModel,
loadClientLlmModels,
type ClientLlmModelCatalog,
} from '../../services/clientApi';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
export type ConversationModelSelectHandle = {
/** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */
ensureUsable: () => Promise<boolean>;
};
export function ConversationModelSelect({
disabled,
onReady,
projectPath,
ref,
}: {
disabled: boolean;
onReady: (ready: boolean) => void;
projectPath?: string;
ref?: Ref<ConversationModelSelectHandle>;
}) {
const [models, setModels] = useState<ClientLlmModel[]>([]);
const initialCatalog = cachedLlmModelCatalog();
const [models, setModels] = useState<ClientLlmModel[]>(
initialCatalog?.models ?? [],
);
const [selected, setSelected] = useState('');
const [busy, setBusy] = useState(true);
const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [open, setOpen] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError('');
onReady(false);
try {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
const [catalog, config] = await Promise.all([
loadClientLlmModels(),
invoke<GameCreatorAppConfigView>('read_game_creator_app_config'),
]);
setModels(catalog.models);
const id = config.config.selectedModelId || catalog.defaultModelId;
setSelected(id);
const available = catalog.models.some((model) => model.id === id);
if (available && !config.config.selectedModelId) {
const saved = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: id },
);
if (saved.config.selectedModelId !== id)
throw new Error('Default selection was not saved');
}
onReady(available);
if (!available) setError('请选择可用模型');
} catch {
setModels([]);
setError('模型列表加载失败');
} finally {
setBusy(false);
}
const appliedRevisionRef = useRef<number | null>(
initialCatalog?.revision ?? null,
);
const selectedRef = useRef('');
const selectionEpochRef = useRef(0);
const saveInFlightRef = useRef(false);
const onReadyRef = useRef(onReady);
const mountedRef = useRef(true);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
void refresh();
}, [refresh]);
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const markReady = useCallback((ready: boolean) => {
onReadyRef.current(ready);
}, []);
const applyCatalog = useCallback(
async (
catalog: ClientLlmModelCatalog,
showBusy: boolean,
epochAtRequest: number,
) => {
if (
mountedRef.current &&
appliedRevisionRef.current !== catalog.revision
) {
appliedRevisionRef.current = catalog.revision;
setModels(catalog.models);
}
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
const config = await invoke<GameCreatorAppConfigView>(
'read_game_creator_app_config',
);
if (
saveInFlightRef.current ||
selectionEpochRef.current !== epochAtRequest
) {
if (mountedRef.current && showBusy) setBusy(false);
return Boolean(selectedRef.current);
}
const saved = config.config.selectedModelId;
const enabled = (id: string) =>
catalog.models.some((model) => model.id === id);
let next = saved && enabled(saved) ? saved : '';
let nextNotice = '';
if (!next && enabled(catalog.defaultModelId)) {
next = catalog.defaultModelId;
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
const persisted = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: next },
);
if (persisted.config.selectedModelId !== next)
throw new Error('Default selection was not saved');
}
const ready = Boolean(next);
if (!mountedRef.current) return ready;
selectedRef.current = next;
setSelected(next);
setNotice(nextNotice);
setError(ready ? '' : '请选择可用模型');
if (showBusy) setBusy(false);
markReady(ready);
return ready;
},
[markReady],
);
const syncCatalog = useCallback(
async (showBusy: boolean) => {
if (showBusy) {
if (mountedRef.current) {
setBusy(true);
setError('');
}
markReady(false);
}
const epochAtRequest = selectionEpochRef.current;
try {
const catalog = await refreshLlmModelCatalog();
return await applyCatalog(catalog, showBusy, epochAtRequest);
} catch {
const cached = cachedLlmModelCatalog();
if (cached) {
const ready = await applyCatalog(
cached,
showBusy,
epochAtRequest,
).catch(() => false);
if (mountedRef.current) setError('模型列表加载失败');
return ready;
}
if (mountedRef.current) {
setError('模型列表加载失败');
if (showBusy) setBusy(false);
}
markReady(false);
return false;
}
},
[applyCatalog, markReady],
);
useEffect(() => {
void syncCatalog(true);
}, [projectPath, syncCatalog]);
useEffect(() => {
function handleWindowFocus() {
void syncCatalog(false);
}
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [syncCatalog]);
const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]);
useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]);
async function select(id: string) {
onReady(false);
selectionEpochRef.current += 1;
saveInFlightRef.current = true;
markReady(false);
setBusy(true);
setError('');
try {
@@ -70,18 +187,25 @@ export function ConversationModelSelect({
);
if (result.config.selectedModelId !== id)
throw new Error('Selection was not saved');
setSelected(id);
onReady(true);
if (mountedRef.current) {
selectedRef.current = id;
setSelected(id);
setNotice('');
}
markReady(true);
} catch {
setError('模型选择保存失败');
if (mountedRef.current) setError('模型选择保存失败');
markReady(false);
} finally {
setBusy(false);
saveInFlightRef.current = false;
if (mountedRef.current) setBusy(false);
}
}
return (
<div className="conversation-model-select">
{error ? <span role="alert">{error}</span> : null}
{notice ? <span role="status">{notice}</span> : null}
<button
type="button"
className="conversation-model-trigger"
@@ -89,7 +213,11 @@ export function ConversationModelSelect({
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || busy}
onClick={() => setOpen((current) => !current)}
onClick={() => {
const nextOpen = !open;
setOpen(nextOpen);
if (nextOpen) void syncCatalog(false);
}}
>
<span
className="conversation-model-trigger-status"
@@ -130,7 +258,7 @@ export function ConversationModelSelect({
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy}
onClick={() => void refresh()}
onClick={() => void syncCatalog(true)}
>
<RefreshCcw size={13} aria-hidden="true" />
<span></span>
@@ -7,7 +7,7 @@ import type {
SetStateAction,
UIEventHandler,
} from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type {
AgentStatusCard,
@@ -27,7 +27,10 @@ import {
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import { ConversationModelSelect } from './ConversationModelSelect';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from './ConversationModelSelect';
import { PlanGddSurface } from './GddApprovalCard';
import {
pendingCommandDetail,
@@ -145,6 +148,7 @@ export function ProjectSupervisorView({
: '发送';
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
const [modelReady, setModelReady] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
return (
<section
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
@@ -308,11 +312,18 @@ export function ProjectSupervisorView({
<form
className="project-supervisor-composer"
onSubmit={(event) => {
if (directCodex && !modelReady) {
event.preventDefault();
if (!directCodex) {
onSubmit(event);
return;
}
onSubmit(event);
event.preventDefault();
const validateModel = async () => {
const ready = modelSelectRef.current
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) onSubmit(event);
};
void validateModel();
}}
>
<textarea
@@ -339,8 +350,10 @@ export function ProjectSupervisorView({
/>
{directCodex ? (
<ConversationModelSelect
ref={modelSelectRef}
disabled={runtimePanelProps.controlBusy || needsUserInput}
onReady={setModelReady}
projectPath={projectPath}
/>
) : null}
<button
@@ -184,8 +184,14 @@ export type ClientLlmModel = {
id: string;
};
export type ClientLlmModelCatalog = {
defaultModelId: string;
models: ClientLlmModel[];
revision: number;
};
export function loadClientLlmModels() {
return requestClientApi<{ models: ClientLlmModel[]; defaultModelId: string }>(
return requestClientApi<ClientLlmModelCatalog>(
'/api/llm/models',
{ method: 'GET' },
'读取可用模型失败',
@@ -0,0 +1,32 @@
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
let cached: ClientLlmModelCatalog | null = null;
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
/** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */
export function cachedLlmModelCatalog() {
return cached;
}
/**
* 读取模型目录;同一时刻只保留一个在途请求,重复触发复用同一结果。
* 读取失败时保留上一次成功目录,不覆盖调用方已生效的选择。
*/
export function refreshLlmModelCatalog() {
if (inFlight) return inFlight;
const request = loadClientLlmModels()
.then((catalog) => {
cached = catalog;
return catalog;
})
.finally(() => {
if (inFlight === request) inFlight = null;
});
inFlight = request;
return request;
}
export function resetLlmModelCatalogCacheForTest() {
cached = null;
inFlight = null;
}
@@ -8602,3 +8602,12 @@ iframe.preview-frame {
background: #fff;
font-size: 12px;
}
.conversation-model-select [role='status'] {
position: absolute;
bottom: 36px;
right: 0;
max-width: 220px;
color: #6b7280;
background: #fff;
font-size: 12px;
}
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlanGddStateViewV1 } from '../../src/app/types';
import * as clientApi from '../../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog';
import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore';
const nativeClipboardMock = vi.hoisted(() => ({
@@ -998,12 +999,14 @@ async function openMainProject(projectPath: string) {
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
@@ -1,16 +1,22 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { resolveTauriInvoke } from '../src/app/tauri';
import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect';
import { loadClientLlmModels } from '../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
@@ -18,6 +24,7 @@ const invoke = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
resetLlmModelCatalogCacheForTest();
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
@@ -25,6 +32,7 @@ beforeEach(() => {
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
invoke.mockImplementation(async (command, input) => ({
config: {
@@ -53,17 +61,86 @@ test('only displays aliases and persists selection through the native command',
).toContain('快速');
});
test('does not mark a removed selection ready or expose the old identifier', async () => {
invoke.mockResolvedValue({
config: { selectedModelId: 'private-old-model' },
});
test('falls back to the default model when the saved selection was removed', async () => {
invoke.mockImplementation(async (command, input) => ({
config: {
selectedModelId:
command === 'select_game_creator_model'
? input.modelId
: 'private-old-model',
},
}));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('请选择可用模型');
expect(onReady).toHaveBeenLastCalledWith(false);
await screen.findByText('所选模型已停用,已切换为默认模型');
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'quality',
}),
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('private-old-model')).toBeNull();
});
test('keeps the last good catalog when a background refresh fails', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
fireEvent(window, new Event('focus'));
await screen.findByText('模型列表加载失败');
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '高质量' })).not.toBeNull();
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('applies a new catalog revision on focus', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
{ id: 'vision', displayName: '视觉' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull();
});
test('pre-send validation falls back when the selected model is disabled', async () => {
let savedModelId = 'quality';
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model')
savedModelId = String(input.modelId);
return { config: { selectedModelId: savedModelId } };
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() => expect(savedModelId).toBe('fast'));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 2,
});
await act(async () => {
await expect(ref.current?.ensureUsable()).resolves.toBe(true);
});
expect(screen.getByText('所选模型已停用,已切换为默认模型')).not.toBeNull();
expect(savedModelId).toBe('quality');
});
test('failed catalog can be refreshed without enabling submission', async () => {
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
const onReady = vi.fn();
@@ -5,7 +5,8 @@
- 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId``enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
@@ -14,4 +15,5 @@
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
@@ -661,7 +661,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 私有单例表,主键 `id=0`,保存 `catalog_json``revision``updated_at`;不存凭据。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识别名。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识别名和目录 `revision`(供条件刷新,不暴露实际模型名)
- Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`
### `error_report`
@@ -39,6 +39,7 @@ mod model_catalog_tests {
#[test]
fn public_catalog_only_exposes_alias_and_stable_id() {
let mut catalog = module_runtime::AgcModelCatalog::default();
catalog.revision = 7;
catalog.models[1].enabled = false;
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
assert_eq!(
@@ -46,6 +47,7 @@ mod model_catalog_tests {
json!([{"id": "quality", "displayName": "高质量"}])
);
assert_eq!(payload["defaultModelId"], "quality");
assert_eq!(payload["revision"], json!(7));
assert!(!payload.to_string().contains("gpt-"));
}
}
@@ -193,6 +195,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
display_name: model.alias,
})
.collect(),
revision: catalog.revision,
}
}
@@ -72,4 +72,6 @@ pub struct LlmModelSummary {
pub struct LlmModelsResponse {
pub default_model_id: String,
pub models: Vec<LlmModelSummary>,
/// 模型目录版本,客户端据此做条件刷新。
pub revision: u64,
}