优化AGC客户端同步LLM配置 #306
@@ -2007,13 +2007,16 @@ pub(crate) fn write_game_creator_app_config(
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
config.selected_model_id = load_game_creator_app_config()?.selected_model_id;
|
||||
let stored = load_game_creator_app_config()?;
|
||||
config.selected_model_id = stored.selected_model_id;
|
||||
config.selected_model_is_default = stored.selected_model_is_default;
|
||||
persist_game_creator_app_config(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn select_game_creator_model(
|
||||
model_id: String,
|
||||
is_default: bool,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
@@ -2028,6 +2031,7 @@ pub(crate) fn select_game_creator_model(
|
||||
}
|
||||
let mut config = load_game_creator_app_config()?;
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
persist_game_creator_app_config(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -3623,6 +3623,9 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
if let Some(selected_model_id) = file_config.selected_model_id {
|
||||
config.selected_model_id = selected_model_id;
|
||||
}
|
||||
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
|
||||
config.selected_model_is_default = selected_model_is_default;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1022,6 +1022,8 @@ struct GameCreatorAppConfigFile {
|
||||
planning: Option<GameCreatorPlanningConfigFile>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -1084,6 +1086,8 @@ struct GameCreatorAppConfig {
|
||||
planning: GameCreatorPlanningConfig,
|
||||
#[serde(default)]
|
||||
selected_model_id: String,
|
||||
#[serde(default)]
|
||||
selected_model_is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -1609,6 +1613,7 @@ impl Default for GameCreatorAppConfig {
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
selected_model_id: String::new(),
|
||||
selected_model_is_default: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +738,7 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
agent_llm,
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
})
|
||||
.expect("write runtime config");
|
||||
|
||||
@@ -806,6 +807,28 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_selection_default_flag_round_trips() {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(&root).expect("runtime config dir");
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
|
||||
let saved =
|
||||
select_game_creator_model("quality".to_string(), true).expect("select default model");
|
||||
assert_eq!(saved.config.selected_model_id, "quality");
|
||||
assert!(saved.config.selected_model_is_default);
|
||||
|
||||
let saved =
|
||||
select_game_creator_model("fast".to_string(), false).expect("select explicit model");
|
||||
assert_eq!(saved.config.selected_model_id, "fast");
|
||||
assert!(!saved.config.selected_model_is_default);
|
||||
|
||||
let reloaded = read_game_creator_app_config().expect("read runtime config");
|
||||
assert_eq!(reloaded.config.selected_model_id, "fast");
|
||||
assert!(!reloaded.config.selected_model_is_default);
|
||||
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_config_write_rejects_invalid_api_kind() {
|
||||
let root = unique_project_path();
|
||||
@@ -824,6 +847,7 @@ fn app_config_write_rejects_invalid_api_kind() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -850,6 +874,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -876,6 +901,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
|
||||
@@ -719,6 +719,7 @@ export interface GameCreatorAppConfig {
|
||||
apiKey: string;
|
||||
};
|
||||
selectedModelId?: string;
|
||||
selectedModelIsDefault?: boolean;
|
||||
planning?: {
|
||||
capabilityEnabled: boolean;
|
||||
};
|
||||
|
||||
+253
-60
@@ -1,75 +1,251 @@
|
||||
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, 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,
|
||||
import type {
|
||||
ClientLlmModel,
|
||||
ClientLlmModelCatalog,
|
||||
} from '../../services/clientApi';
|
||||
import {
|
||||
cachedLlmModelCatalog,
|
||||
refreshLlmModelCatalog,
|
||||
} from '../../services/llmModelCatalog';
|
||||
|
||||
export type ConversationModelSelectHandle = {
|
||||
/** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */
|
||||
ensureUsable: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
|
||||
class ModelSelectionConfigError extends Error {}
|
||||
|
||||
export function ConversationModelSelect({
|
||||
className,
|
||||
disabled,
|
||||
onReady,
|
||||
projectPath,
|
||||
ref,
|
||||
}: {
|
||||
className?: string;
|
||||
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 [defaultModelId, setDefaultModelId] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [defaultModelId, setDefaultModelId] = useState(
|
||||
initialCatalog?.defaultModelId ?? '',
|
||||
);
|
||||
const [busy, setBusy] = useState(!initialCatalog);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
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);
|
||||
setDefaultModelId(catalog.defaultModelId);
|
||||
const savedSelection = config.config.selectedModelId;
|
||||
// 优先沿用用户已选且仍可用的模型;已选模型被停用/移除时回退到默认模型,
|
||||
// 避免下拉出现「请选择模型」的空态。默认模型由后台强制配置,缺失时才走错误提示。
|
||||
const savedAvailable = savedSelection
|
||||
? catalog.models.some((model) => model.id === savedSelection)
|
||||
: false;
|
||||
const id =
|
||||
savedAvailable && savedSelection
|
||||
? savedSelection
|
||||
: catalog.defaultModelId;
|
||||
setSelected(id);
|
||||
const available = catalog.models.some((model) => model.id === id);
|
||||
if (available && savedSelection !== id) {
|
||||
const saved = await invoke<GameCreatorAppConfigView>(
|
||||
'select_game_creator_model',
|
||||
{ modelId: id },
|
||||
);
|
||||
if (saved.config.selectedModelId !== id)
|
||||
throw new Error('Model 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 busyTokenRef = useRef(0);
|
||||
const configWriteChainRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
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 queueConfigWrite = useCallback(
|
||||
(write: () => Promise<GameCreatorAppConfigView>) => {
|
||||
const run = configWriteChainRef.current.then(write, write);
|
||||
configWriteChainRef.current = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const waitForConfigWrite = useCallback(
|
||||
() => configWriteChainRef.current.then(() => undefined),
|
||||
[],
|
||||
);
|
||||
|
||||
const applyCatalog = useCallback(
|
||||
async (
|
||||
catalog: ClientLlmModelCatalog,
|
||||
showBusy: boolean,
|
||||
epochAtRequest: number,
|
||||
) => {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
(appliedRevisionRef.current !== catalog.revision ||
|
||||
!Number.isFinite(catalog.revision))
|
||||
) {
|
||||
appliedRevisionRef.current = catalog.revision;
|
||||
setModels(catalog.models);
|
||||
setDefaultModelId(catalog.defaultModelId);
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new ModelSelectionConfigError('读取客户端配置失败');
|
||||
// 有在途写回时先等它结束,避免读到旧配置、也避免用旧快照覆盖新选择。
|
||||
await waitForConfigWrite();
|
||||
if (selectionEpochRef.current !== epochAtRequest)
|
||||
return Boolean(selectedRef.current);
|
||||
const config = await invoke<GameCreatorAppConfigView>(
|
||||
'read_game_creator_app_config',
|
||||
).catch(() => null);
|
||||
if (!config) throw new ModelSelectionConfigError('读取客户端配置失败');
|
||||
if (selectionEpochRef.current !== epochAtRequest)
|
||||
return Boolean(selectedRef.current);
|
||||
const saved = config.config.selectedModelId;
|
||||
const followsDefault = config.config.selectedModelIsDefault === true;
|
||||
const enabled = (id: string) =>
|
||||
catalog.models.some((model) => model.id === id);
|
||||
const defaultEnabled = enabled(catalog.defaultModelId);
|
||||
let next = '';
|
||||
let nextIsDefault = false;
|
||||
let nextNotice = '';
|
||||
// 跟随默认项的选择会随后台默认模型变化;用户手动选择后不再被覆盖。
|
||||
if (followsDefault && defaultEnabled) {
|
||||
next = catalog.defaultModelId;
|
||||
nextIsDefault = true;
|
||||
if (saved && saved !== next)
|
||||
nextNotice = '默认模型已更新,已切换为新的默认模型';
|
||||
} else if (!followsDefault && saved && enabled(saved)) {
|
||||
next = saved;
|
||||
}
|
||||
// 已选模型被停用/移除时回退默认模型,避免下拉出现「请选择模型」的空态。
|
||||
if (!next && defaultEnabled) {
|
||||
next = catalog.defaultModelId;
|
||||
nextIsDefault = true;
|
||||
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
|
||||
}
|
||||
if (next && (next !== saved || nextIsDefault !== followsDefault)) {
|
||||
let persisted: GameCreatorAppConfigView;
|
||||
try {
|
||||
persisted = await queueConfigWrite(() =>
|
||||
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
|
||||
modelId: next,
|
||||
isDefault: nextIsDefault,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new ModelSelectionConfigError('模型选择保存失败');
|
||||
}
|
||||
if (
|
||||
persisted.config.selectedModelId !== next ||
|
||||
persisted.config.selectedModelIsDefault !== nextIsDefault
|
||||
)
|
||||
throw new ModelSelectionConfigError('模型选择保存失败');
|
||||
}
|
||||
// 写回期间用户又做了新选择:保留新选择,不用本次快照覆盖界面。
|
||||
if (selectionEpochRef.current !== epochAtRequest)
|
||||
return Boolean(selectedRef.current);
|
||||
const ready = Boolean(next);
|
||||
if (!mountedRef.current) return ready;
|
||||
selectedRef.current = next;
|
||||
setSelected(next);
|
||||
setNotice(nextNotice);
|
||||
setError(ready ? '' : '请选择可用模型');
|
||||
markReady(ready);
|
||||
return ready;
|
||||
},
|
||||
[markReady, queueConfigWrite, waitForConfigWrite],
|
||||
);
|
||||
|
||||
const syncCatalog = useCallback(
|
||||
async (showBusy: boolean) => {
|
||||
const busyToken = showBusy
|
||||
? ++busyTokenRef.current
|
||||
: busyTokenRef.current;
|
||||
if (showBusy) {
|
||||
if (mountedRef.current) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
}
|
||||
markReady(false);
|
||||
}
|
||||
const epochAtRequest = selectionEpochRef.current;
|
||||
try {
|
||||
let catalog: ClientLlmModelCatalog;
|
||||
let usingCachedCatalog = false;
|
||||
try {
|
||||
catalog = await refreshLlmModelCatalog();
|
||||
} catch {
|
||||
const cached = cachedLlmModelCatalog();
|
||||
if (!cached) {
|
||||
if (mountedRef.current) setError('模型列表加载失败');
|
||||
markReady(false);
|
||||
return false;
|
||||
}
|
||||
catalog = cached;
|
||||
usingCachedCatalog = true;
|
||||
}
|
||||
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
|
||||
if (usingCachedCatalog && mountedRef.current)
|
||||
setError('模型列表加载失败');
|
||||
return ready;
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
error instanceof ModelSelectionConfigError
|
||||
? error.message
|
||||
: '模型列表加载失败',
|
||||
);
|
||||
}
|
||||
markReady(false);
|
||||
return false;
|
||||
} finally {
|
||||
// 只有最新的 showBusy 同步负责收起加载态,失败路径也必须恢复可交互。
|
||||
if (
|
||||
showBusy &&
|
||||
mountedRef.current &&
|
||||
busyTokenRef.current === busyToken
|
||||
) {
|
||||
setBusy(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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -92,25 +268,37 @@ export function ConversationModelSelect({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]);
|
||||
|
||||
async function select(id: string) {
|
||||
onReady?.(false);
|
||||
selectionEpochRef.current += 1;
|
||||
markReady(false);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('Native host unavailable');
|
||||
const result = await invoke<GameCreatorAppConfigView>(
|
||||
'select_game_creator_model',
|
||||
{ modelId: id },
|
||||
const result = await queueConfigWrite(() =>
|
||||
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
|
||||
modelId: id,
|
||||
isDefault: false,
|
||||
}),
|
||||
);
|
||||
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);
|
||||
if (mountedRef.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +312,7 @@ export function ConversationModelSelect({
|
||||
}
|
||||
>
|
||||
{error ? <span role="alert">{error}</span> : null}
|
||||
{notice ? <span role="status">{notice}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
@@ -131,7 +320,11 @@ export function ConversationModelSelect({
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onClick={() => {
|
||||
const nextOpen = !open;
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) void syncCatalog(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
@@ -183,7 +376,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>
|
||||
|
||||
+32
-7
@@ -7,7 +7,7 @@ import type {
|
||||
SetStateAction,
|
||||
UIEventHandler,
|
||||
} from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AgentStatusCard,
|
||||
@@ -28,7 +28,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,
|
||||
@@ -146,6 +149,9 @@ export function ProjectSupervisorView({
|
||||
: '发送';
|
||||
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
|
||||
const [modelReady, setModelReady] = useState(false);
|
||||
const [modelValidating, setModelValidating] = useState(false);
|
||||
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
return (
|
||||
<section
|
||||
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
|
||||
@@ -322,16 +328,33 @@ export function ProjectSupervisorView({
|
||||
<form
|
||||
className="project-supervisor-composer"
|
||||
onSubmit={(event) => {
|
||||
if (directCodex && !modelReady) {
|
||||
event.preventDefault();
|
||||
if (!directCodex) {
|
||||
onSubmit(event);
|
||||
return;
|
||||
}
|
||||
onSubmit(event);
|
||||
event.preventDefault();
|
||||
if (modelValidateInFlightRef.current) return;
|
||||
modelValidateInFlightRef.current = true;
|
||||
setModelValidating(true);
|
||||
const validateModel = async () => {
|
||||
try {
|
||||
const ready = modelSelectRef.current
|
||||
? await modelSelectRef.current.ensureUsable()
|
||||
: modelReady;
|
||||
if (ready) onSubmit(event);
|
||||
} finally {
|
||||
modelValidateInFlightRef.current = false;
|
||||
setModelValidating(false);
|
||||
}
|
||||
};
|
||||
void validateModel();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
aria-label={directCodex ? '陶泥儿对话内容' : '项目需求'}
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
disabled={
|
||||
runtimePanelProps.controlBusy || needsUserInput || modelValidating
|
||||
}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
placeholder={
|
||||
@@ -353,10 +376,12 @@ export function ProjectSupervisorView({
|
||||
/>
|
||||
{directCodex ? (
|
||||
<ConversationModelSelect
|
||||
ref={modelSelectRef}
|
||||
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
|
||||
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
|
||||
disabled={needsUserInput}
|
||||
onReady={setModelReady}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
@@ -366,7 +391,7 @@ export function ProjectSupervisorView({
|
||||
disabled={
|
||||
runtimePanelProps.controlBusy ||
|
||||
needsUserInput ||
|
||||
(directCodex && !modelReady)
|
||||
(directCodex && (!modelReady || modelValidating))
|
||||
}
|
||||
>
|
||||
{submitting ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8632,3 +8632,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,
|
||||
|
||||
@@ -96,6 +96,7 @@ export function registerClientHomeTests() {
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'fast',
|
||||
isDefault: false,
|
||||
}),
|
||||
);
|
||||
expect(modelTrigger.textContent).toContain('快速');
|
||||
|
||||
@@ -711,6 +711,8 @@ export function registerRuntimeSettingsTests() {
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
// 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。
|
||||
await waitFor(() => expect(readCount).toBe(1));
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -7,18 +8,26 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} 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() }));
|
||||
const invoke = vi.fn();
|
||||
let savedModelId = 'quality';
|
||||
let savedModelIsDefault = true;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetLlmModelCatalogCacheForTest();
|
||||
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
@@ -26,13 +35,22 @@ beforeEach(() => {
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
revision: 1,
|
||||
});
|
||||
savedModelId = 'quality';
|
||||
savedModelIsDefault = true;
|
||||
invoke.mockImplementation(async (command, input) => {
|
||||
if (command === 'select_game_creator_model') {
|
||||
savedModelId = String(input.modelId);
|
||||
savedModelIsDefault = Boolean(input.isDefault);
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: savedModelId,
|
||||
selectedModelIsDefault: savedModelIsDefault,
|
||||
},
|
||||
};
|
||||
});
|
||||
invoke.mockImplementation(async (command, input) => ({
|
||||
config: {
|
||||
selectedModelId:
|
||||
command === 'select_game_creator_model' ? input.modelId : 'quality',
|
||||
},
|
||||
}));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -46,6 +64,7 @@ test('only displays aliases and persists selection through the native command',
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'fast',
|
||||
isDefault: false,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
@@ -61,14 +80,20 @@ test('falls back to the default model when the saved selection was removed', asy
|
||||
command === 'select_game_creator_model'
|
||||
? (input as { modelId: string }).modelId
|
||||
: 'private-old-model',
|
||||
selectedModelIsDefault:
|
||||
command === 'select_game_creator_model'
|
||||
? Boolean((input as { isDefault: boolean }).isDefault)
|
||||
: false,
|
||||
},
|
||||
}));
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('所选模型已停用,已切换为默认模型');
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect(screen.queryByText('private-old-model')).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'quality',
|
||||
isDefault: true,
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
@@ -89,7 +114,9 @@ test('failed catalog can be refreshed without enabling submission', async () =>
|
||||
test('a failed save keeps submission unavailable', async () => {
|
||||
invoke.mockImplementation(async (command) => {
|
||||
if (command === 'select_game_creator_model') throw new Error('disk full');
|
||||
return { config: { selectedModelId: 'quality' } };
|
||||
return {
|
||||
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
|
||||
};
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
@@ -140,13 +167,15 @@ test('marks the default model in the menu', async () => {
|
||||
|
||||
test('keeps model options disabled while a selection save is in flight', async () => {
|
||||
let resolveSave: ((value: unknown) => void) | undefined;
|
||||
invoke.mockImplementation(async (command, input) => {
|
||||
invoke.mockImplementation(async (command) => {
|
||||
if (command === 'select_game_creator_model') {
|
||||
return new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
}
|
||||
return { config: { selectedModelId: 'quality' } };
|
||||
return {
|
||||
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
|
||||
};
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
@@ -169,6 +198,8 @@ test('keeps model options disabled while a selection save is in flight', async (
|
||||
);
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
|
||||
// 配置写回按队列落盘,保存请求在下一个微任务才发出。
|
||||
await waitFor(() => expect(resolveSave).toBeDefined());
|
||||
resolveSave?.({ config: { selectedModelId: 'fast' } });
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
|
||||
@@ -176,3 +207,266 @@ test('keeps model options disabled while a selection save is in flight', async (
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
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';
|
||||
let savedModelIsDefault = false;
|
||||
invoke.mockImplementation(async (command, input) => {
|
||||
if (command === 'select_game_creator_model') {
|
||||
savedModelId = String(input.modelId);
|
||||
savedModelIsDefault = Boolean(input.isDefault);
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: savedModelId,
|
||||
selectedModelIsDefault: savedModelIsDefault,
|
||||
},
|
||||
};
|
||||
});
|
||||
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('follows the new server default when the saved selection was the default', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'fast',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
revision: 2,
|
||||
});
|
||||
fireEvent(window, new Event('focus'));
|
||||
await waitFor(() => expect(savedModelId).toBe('fast'));
|
||||
expect(savedModelIsDefault).toBe(true);
|
||||
expect(
|
||||
screen.getByText('默认模型已更新,已切换为新的默认模型'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
).toContain('快速');
|
||||
});
|
||||
|
||||
test('keeps an explicit selection when the server default changes', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect 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'));
|
||||
expect(savedModelIsDefault).toBe(false);
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
revision: 2,
|
||||
});
|
||||
fireEvent(window, new Event('focus'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
).toContain('快速'),
|
||||
);
|
||||
expect(savedModelId).toBe('fast');
|
||||
expect(savedModelIsDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('recovers the selector when reading the native config fails', async () => {
|
||||
invoke.mockRejectedValueOnce(new Error('config unreadable'));
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('读取客户端配置失败');
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false));
|
||||
|
||||
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
expect(
|
||||
screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect(screen.queryByText('读取客户端配置失败')).toBeNull();
|
||||
});
|
||||
|
||||
test('pre-send validation waits for an in-flight selection save', async () => {
|
||||
let resolveSave: (() => void) | undefined;
|
||||
invoke.mockImplementation(async (command, input) => {
|
||||
if (command === 'select_game_creator_model') {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveSave = () => {
|
||||
savedModelId = String(input.modelId);
|
||||
savedModelIsDefault = Boolean(input.isDefault);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: savedModelId,
|
||||
selectedModelIsDefault: savedModelIsDefault,
|
||||
},
|
||||
};
|
||||
});
|
||||
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: '快速' }));
|
||||
|
||||
let settled = false;
|
||||
let pending!: Promise<boolean>;
|
||||
await act(async () => {
|
||||
pending = ref.current!.ensureUsable().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
resolveSave?.();
|
||||
await act(async () => {
|
||||
await expect(pending).resolves.toBe(true);
|
||||
});
|
||||
expect(savedModelId).toBe('fast');
|
||||
expect(savedModelIsDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('reuses a single in-flight catalog request', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect(loadClientLlmModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent(window, new Event('focus'));
|
||||
fireEvent(window, new Event('focus'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
|
||||
expect(loadClientLlmModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('keeps refreshing when the server omits the catalog revision', async () => {
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
revision: undefined as unknown as number,
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
|
||||
// 旧服务端不返回 revision 时,两次响应的 revision 都是 undefined,
|
||||
// 不能因此判定「未变化」而停止刷新。
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'vision', displayName: '视觉' },
|
||||
],
|
||||
revision: undefined as unknown as number,
|
||||
});
|
||||
fireEvent(window, new Event('focus'));
|
||||
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('applies a catalog when the revision goes backwards', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
|
||||
// 服务端目录重建后 revision 可能回退,仍要按「已变化」处理。
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'vision', displayName: '视觉' },
|
||||
],
|
||||
revision: 0,
|
||||
});
|
||||
fireEvent(window, new Event('focus'));
|
||||
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the applied catalog when the revision is unchanged', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
|
||||
// revision 未变化时不更新界面,沿用已应用的目录。
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'vision', displayName: '视觉' },
|
||||
],
|
||||
revision: 1,
|
||||
});
|
||||
fireEvent(window, new Event('focus'));
|
||||
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
expect(screen.queryByRole('option', { name: '视觉' })).toBeNull();
|
||||
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
- 后台 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 作为兜底文案。
|
||||
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
|
||||
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
|
||||
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
|
||||
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口与项目对话共用同一份目录缓存、挂载即加载(失败时沿用上一次成功目录),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
|
||||
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
|
||||
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
|
||||
|
||||
@@ -16,4 +18,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