优化AGC客户端同步LLM配置
- GET /api/llm/models 增加目录 revision,客户端据此做条件刷新 - AGC 客户端在项目切换、对话表面挂载、下拉展开、窗口聚焦时按 revision 条件刷新 - 模型目录请求同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择 - 发起对话前校验所选模型,已停用或删除时回退默认模型并提示 - 新增模型目录缓存模块与定向测试,同步技术方案与后端架构文档
This commit is contained in:
+172
-44
@@ -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>
|
||||
|
||||
+18
-5
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user