修复 AGC 首页与项目对话的模型选择入口和交互
首页聊天框架右下角新增模型选择入口,复用项目右侧对话的模型选择器,目录按需加载且不阻塞开启创作 修复首次进入项目时右侧模型选择器点不动的问题:选择器不再因目录加载或对话进行中被禁用,切换模型只影响后续轮次 为模型选择器菜单增加加载占位与统一弹层定位,首页组件使用相对定位容器 同步更新 AGC 后台模型别名与对话选择技术方案文档 补充首页模型选择与首次进入项目可交互的回归测试
This commit is contained in:
+34
-11
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { GameCreatorAppConfigView } from '../../app/types';
|
||||
@@ -9,21 +9,26 @@ import {
|
||||
} from '../../services/clientApi';
|
||||
|
||||
export function ConversationModelSelect({
|
||||
className,
|
||||
disabled,
|
||||
lazy = false,
|
||||
onReady,
|
||||
}: {
|
||||
className?: string;
|
||||
disabled: boolean;
|
||||
onReady: (ready: boolean) => void;
|
||||
lazy?: boolean;
|
||||
onReady?: (ready: boolean) => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<ClientLlmModel[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [busy, setBusy] = useState(!lazy);
|
||||
const [error, setError] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
onReady(false);
|
||||
onReady?.(false);
|
||||
try {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('Native host unavailable');
|
||||
@@ -43,7 +48,7 @@ export function ConversationModelSelect({
|
||||
if (saved.config.selectedModelId !== id)
|
||||
throw new Error('Default selection was not saved');
|
||||
}
|
||||
onReady(available);
|
||||
onReady?.(available);
|
||||
if (!available) setError('请选择可用模型');
|
||||
} catch {
|
||||
setModels([]);
|
||||
@@ -54,11 +59,12 @@ export function ConversationModelSelect({
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lazy) return;
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
}, [refresh, lazy]);
|
||||
|
||||
async function select(id: string) {
|
||||
onReady(false);
|
||||
onReady?.(false);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -71,7 +77,7 @@ export function ConversationModelSelect({
|
||||
if (result.config.selectedModelId !== id)
|
||||
throw new Error('Selection was not saved');
|
||||
setSelected(id);
|
||||
onReady(true);
|
||||
onReady?.(true);
|
||||
} catch {
|
||||
setError('模型选择保存失败');
|
||||
} finally {
|
||||
@@ -80,7 +86,13 @@ export function ConversationModelSelect({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conversation-model-select">
|
||||
<div
|
||||
className={
|
||||
className
|
||||
? `conversation-model-select ${className}`
|
||||
: 'conversation-model-select'
|
||||
}
|
||||
>
|
||||
{error ? <span role="alert">{error}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -88,8 +100,14 @@ export function ConversationModelSelect({
|
||||
aria-label="对话模型"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (lazy && !initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
void refresh();
|
||||
}
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
@@ -107,6 +125,11 @@ export function ConversationModelSelect({
|
||||
role="listbox"
|
||||
aria-label="对话模型"
|
||||
>
|
||||
{busy && models.length === 0 ? (
|
||||
<span className="conversation-model-menu-loading">
|
||||
正在读取模型
|
||||
</span>
|
||||
) : null}
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
|
||||
@@ -339,7 +339,9 @@ export function ProjectSupervisorView({
|
||||
/>
|
||||
{directCodex ? (
|
||||
<ConversationModelSelect
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
|
||||
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
|
||||
disabled={needsUserInput}
|
||||
onReady={setModelReady}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8397,7 +8397,11 @@ iframe.preview-frame {
|
||||
max-width: calc(100% - 64px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.project-supervisor-surface.is-direct-codex .conversation-model-trigger {
|
||||
.home-input-model-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.conversation-model-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -8439,8 +8443,8 @@ iframe.preview-frame {
|
||||
}
|
||||
.conversation-model-menu {
|
||||
position: absolute;
|
||||
right: 46px;
|
||||
bottom: 43px;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
@@ -8478,6 +8482,14 @@ iframe.preview-frame {
|
||||
color: var(--platform-text-strong, #111827);
|
||||
outline: none;
|
||||
}
|
||||
.conversation-model-menu-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
color: var(--platform-text-soft, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRef, useState } from 'react';
|
||||
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import type { ProjectStartMode } from '../../app/types';
|
||||
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
|
||||
import RichInputArea, { UploadButton } from './components/RichInputArea';
|
||||
import {
|
||||
richTextToAttachments,
|
||||
@@ -240,7 +241,14 @@ export default function HomeView({
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<UploadButton />
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<UploadButton />
|
||||
<ConversationModelSelect
|
||||
className="home-input-model-select"
|
||||
disabled={homeCreationBusy}
|
||||
lazy
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
type="submit"
|
||||
|
||||
@@ -62,6 +62,46 @@ function ApprovedGddStartHarness() {
|
||||
}
|
||||
|
||||
export function registerClientHomeTests() {
|
||||
it('adds a model selector to the home composer and lazily loads the catalog on open', async () => {
|
||||
const invoke = vi.fn(async (command: string, args?: unknown) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return { config: { selectedModelId: 'quality' } };
|
||||
}
|
||||
if (command === 'select_game_creator_model') {
|
||||
return {
|
||||
config: { selectedModelId: (args as { modelId: string }).modelId },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
// Lazy: the home composer must not read config or load models on mount.
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'read_game_creator_app_config',
|
||||
expect.anything(),
|
||||
);
|
||||
const modelTrigger = await screen.findByRole('button', {
|
||||
name: '对话模型',
|
||||
});
|
||||
const createButton = screen.getByRole('button', { name: '开启创作' });
|
||||
expect(createButton).toHaveProperty('disabled', false);
|
||||
|
||||
fireEvent.click(modelTrigger);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('option', { name: '快速' }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'fast',
|
||||
}),
|
||||
);
|
||||
expect(modelTrigger.textContent).toContain('快速');
|
||||
expect(createButton).toHaveProperty('disabled', false);
|
||||
});
|
||||
|
||||
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
|
||||
@@ -4695,6 +4695,50 @@ export function registerUserSurfaceBoundaryTests() {
|
||||
}
|
||||
|
||||
export function registerProjectSupervisorSurfaceTests() {
|
||||
it('allows selecting the model on the first direct-project entry', async () => {
|
||||
const projectPath = '/tmp/first-entry-model-select';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'first-entry-model-select',
|
||||
'首次进入模型选择',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
const trigger = within(surface).getByRole('button', { name: '对话模型' });
|
||||
await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false));
|
||||
fireEvent.click(trigger);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(surface).getByRole('option', { name: '快速' }),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
fireEvent.click(within(surface).getByRole('option', { name: '快速' }));
|
||||
await waitFor(() => expect(trigger.textContent).toContain('快速'));
|
||||
});
|
||||
|
||||
it('runs a top workbench play request without a second confirmation', async () => {
|
||||
const projectPath = '/tmp/top-play-request';
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- `GET /api/llm/models` 返回启用项的 `id/displayName` 和 `defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
|
||||
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
|
||||
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
|
||||
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
|
||||
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
|
||||
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
|
||||
|
||||
## 验收
|
||||
|
||||
Reference in New Issue
Block a user