diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..3d69a9dc9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -65,12 +65,28 @@ export function ConversationModelSelect({ onReady, projectPath, ref, + errorPresentation = 'inline', + onUnavailable, + onModelLabelChange, }: { className?: string; disabled: boolean; onReady?: (ready: boolean) => void; projectPath?: string; ref?: Ref; + /** + * 目录 / 选择失败的呈现方式。 + * + * `inline`(默认)把文案渲染在控件里,供首页、策划输入盒、画布等宿主沿用; + * `toast` 不渲染常驻文案,只把消息交给宿主的 `onUnavailable`——输入盒那一排控件 + * 容不下第二行文字:一旦在控件里排一行提示,整颗控件会变成两行高,在 + * `align-items: center` 的工具条里把触发钮顶到行线上方(#600 复核实测的错位)。 + */ + errorPresentation?: 'inline' | 'toast'; + /** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */ + onUnavailable?: (message: string) => void; + /** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */ + onModelLabelChange?: (label: string) => void; }) { const initialCatalog = cachedLlmModelCatalog(); const [models, setModels] = useState( @@ -101,6 +117,36 @@ export function ConversationModelSelect({ onReadyRef.current = onReady; }, [onReady]); + const onUnavailableRef = useRef(onUnavailable); + const onModelLabelChangeRef = useRef(onModelLabelChange); + useEffect(() => { + onUnavailableRef.current = onUnavailable; + onModelLabelChangeRef.current = onModelLabelChange; + }, [onModelLabelChange, onUnavailable]); + + /** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */ + const reportedErrorRef = useRef(''); + useEffect(() => { + if (errorPresentation !== 'toast') { + return; + } + if (error === '') { + reportedErrorRef.current = ''; + return; + } + if (reportedErrorRef.current === error) { + return; + } + reportedErrorRef.current = error; + onUnavailableRef.current?.(error); + }, [error, errorPresentation]); + + const selectedModelLabel = + models.find((model) => model.id === selected)?.displayName ?? ''; + useEffect(() => { + onModelLabelChangeRef.current?.(selectedModelLabel); + }, [selectedModelLabel]); + useEffect(() => { mountedRef.current = true; return () => { @@ -396,7 +442,11 @@ export function ConversationModelSelect({ : 'conversation-model-select' } > - {error ? {error} : null} + {/* `toast` 呈现方式下这里不渲染任何常驻文案:控件排容不下第二行, + 失败由宿主的浮层提示承担(见 `errorPresentation` 的说明)。 */} + {error && errorPresentation === 'inline' ? ( + {error} + ) : null} {notice ? {notice} : null} - {open ? ( -
- {options.map((option) => ( - + className={index === activeIndex ? 'is-active' : undefined} + /> ))} -
- ) : null} - + + + {`当前:${activeLabel}`} + + + ) : null} + {/* 触发钮用固定文案(不显示当前档位):档位在 popover 里看,控件保持一颗的宽度。 */} + + + ); + + return ( + + {presentation === 'slider' ? sliderSelect : dropdownSelect} {notice ? ( {notice} @@ -473,3 +598,66 @@ export function ComposerStopButton({ ); } + +/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(6s)。 */ +const COMPOSER_MODEL_NOTICE_MILLIS = 6000; + +export type ComposerModelNotice = { + /** 每次提示自增:同一条文案再出现时也会重新弹一次。 */ + id: number; + message: string; +}; + +/** + * 输入区自己的浮层提示(模型服务不可用等)。 + * + * 输入区**不留常驻状态行**:那一排控件容不下第二行文字,一旦在控件里排一行提示, + * 整颗控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位)。所以失败只走 + * 浮层——复用外壳同一枚 `PlatformRuntimeStatusToast` 与同一套定位/时长约定 + * (见 `LauncherNoticeToast`、`TemplateLibraryToast`)。 + */ +export function ComposerModelNoticeToast({ + notice, + onDismiss, +}: { + notice: ComposerModelNotice | null; + onDismiss: () => void; +}) { + /** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */ + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout( + () => onDismissRef.current(), + COMPOSER_MODEL_NOTICE_MILLIS, + ); + return () => { + window.clearTimeout(timer); + }; + }, [notice]); + + if (!notice) { + return null; + } + return createPortal( +
+ + {notice.message} + +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 4092a9939..eb68cedac 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -27,10 +27,12 @@ import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { UserContentPart } from '../../generated/UserContentPart'; import { ComposerAttachmentMenu, + ComposerModelNoticeToast, ComposerReasoningEffortSelect, ComposerStopButton, ComposerTurnQueue, ComposerVoiceButton, + type ComposerModelNotice, } from './ComposerControls'; /** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */ @@ -114,6 +116,16 @@ export function DirectProjectComposer({ const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); const [voiceNotice, setVoiceNotice] = useState(''); + /* + 模型服务的失败只走浮层提示:输入区那一排控件不留常驻状态行(会把它顶成两行高)。 + 同一条文案再次出现时也能再弹一次,所以按自增 id 记这一条。 + */ + const [modelNotice, setModelNotice] = useState( + null, + ); + const modelNoticeIdRef = useRef(0); + /** 当前选中模型的显示名(目录没读完时是空串):只在强度浮层里只读展示。 */ + const [modelLabel, setModelLabel] = useState(''); const [modelReady, setModelReady] = useState(false); const [modelValidating, setModelValidating] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); @@ -166,6 +178,12 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /** 记一条浮层提示:id 自增,保证重复文案也会重新弹一次并重起计时。 */ + const pushModelNotice = useCallback((message: string) => { + modelNoticeIdRef.current += 1; + setModelNotice({ id: modelNoticeIdRef.current, message }); + }, []); + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( @@ -242,12 +260,19 @@ export function DirectProjectComposer({ />
- + + {/* + 输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下 + 第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。 + */} + setModelNotice(null)} + /> ); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3d875e617..e471cc645 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,5 +1,10 @@ import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect'; import * as platformSession from '../../src/services/platformSession'; +import { + LlmModelCatalogConfigError, + resetLlmModelCatalogCacheForTest, +} from '../../src/services/llmModelCatalog'; import { appendDictationText, resolveSpeechRecognitionCtor, @@ -1033,7 +1038,7 @@ export function registerChatComposerControlTests() { ).not.toBeNull(); }); - it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { + it('puts the reasoning strength in a popover slider and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ select_game_creator_reasoning_effort: (args) => { @@ -1042,37 +1047,168 @@ export function registerChatComposerControlTests() { }, }); - const select = within(surface).getByRole('button', { name: '推理档' }); + // 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。 + const trigger = within(surface).getByRole('button', { name: /选择强度/ }); + expect(trigger.textContent).toContain('选择强度'); await waitFor(() => { - expect(select.textContent).toContain('高'); + expect(trigger.getAttribute('aria-label')).toContain('当前 高'); }); - // 档位就在模型选择器这一排(同一控制排容器里)。 + expect(trigger.getAttribute('title')).toContain('当前:高'); + // 触发钮就在模型选择器这一排(同一控制排容器里),关闭态没有滑块。 expect( within(surface).getByRole('button', { name: '对话模型' }), ).not.toBeNull(); - expect(select.closest('.project-chat-composer-controls')).not.toBeNull(); + expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull(); + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); - fireEvent.click(select); - fireEvent.click(within(surface).getByRole('option', { name: '低' })); + // 点开 popover:滑块报出 role/值域与中文档位名。 + fireEvent.click(trigger); + const slider = within(surface).getByRole('slider', { + name: '思维强度', + }) as HTMLInputElement; + expect(slider.getAttribute('aria-valuemin')).toBe('0'); + expect(slider.getAttribute('aria-valuemax')).toBe('4'); + expect(slider.getAttribute('aria-valuenow')).toBe('3'); + expect(slider.getAttribute('aria-valuetext')).toBe('高'); + expect(within(surface).getByText('当前:高')).not.toBeNull(); + + // 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。 + fireEvent.change(slider, { target: { value: '1' } }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'select_game_creator_reasoning_effort', { effort: 'low' }, ); }); - // 以落盘后的回读值为准。 + // 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。 await waitFor(() => { expect( - within(surface).getByRole('button', { name: '推理档' }).textContent, - ).toContain('低'); + within(surface) + .getByRole('button', { name: /选择强度/ }) + .getAttribute('aria-label'), + ).toContain('当前 低'); }); expect(stored).toBe('low'); + expect( + within(surface).getByRole('slider', { name: '思维强度' }), + ).not.toBeNull(); // 只影响后续回合:当前没有发出任何新一轮直接对话。 expect( invoke.mock.calls.some( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toBe(false); + + // Esc 关闭;再点开、点外部也关闭。 + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => { + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); + }); + fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ })); + expect( + within(surface).getByRole('slider', { name: '思维强度' }), + ).not.toBeNull(); + fireEvent.mouseDown(document.body); + await waitFor(() => { + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); + }); + }); + + it('shows model service unavailability as a toast and leaves no persistent line', async () => { + const { surface } = await openDirectCodexSurface({ + load_game_creator_llm_models: () => { + throw new LlmModelCatalogConfigError('模型服务不可用'); + }, + }); + + const toast = await waitFor(() => { + const element = document.querySelector( + '[data-composer-model-toast="true"]', + ); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + expect(toast.textContent).toContain('模型服务不可用'); + // 输入区里不留常驻文案、也没有 [role='alert']:失败只走浮层。 + const composer = surface.querySelector( + '.project-chat-composer.is-direct-codex', + ); + expect(composer).not.toBeNull(); + expect(composer?.querySelector('[role="alert"]')).toBeNull(); + expect(composer?.textContent).not.toContain('模型服务不可用'); + expect(surface.textContent).not.toContain('本地模型服务不可用'); + // 不可用态照旧挡住发送,但不靠一行说明文字表达。 + expect( + ( + within(surface).getByRole('button', { + name: '发送', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('reports one newly appeared model failure and never repeats the same message', async () => { + const onUnavailable = vi.fn(); + let failing = true; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('high'); + } + if (command === 'load_game_creator_llm_models') { + if (failing) { + throw new LlmModelCatalogConfigError('模型服务不可用'); + } + return { + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量', agentMode: 'codex' }, + ], + revision: 7, + }; + } + return null; + }); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ConversationModelSelect, { + disabled: false, + errorPresentation: 'toast', + onUnavailable, + }), + ); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + expect(onUnavailable).toHaveBeenCalledWith('模型服务不可用'); + + // 同一条失败再次同步目录(打开下拉会触发一次)不重复报。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + + // 失败清空后再出现,可以再报一次。 + resetLlmModelCatalogCacheForTest(); + failing = false; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + resetLlmModelCatalogCacheForTest(); + failing = true; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(2); + }); }); it('appends the dictated transcript after the draft typed into the composer', async () => { diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index f50c687aa..436d1ce3d 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -331,6 +331,31 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(paddingBottom).toBeLessThan(total); }); + it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => { + const trigger = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-trigger`, + COMPOSER_TRIGGER, + COMPOSER_TRIGGER_BASE, + ); + const anchor = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength`, + ); + const popover = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-popover`, + ); + const range = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-range`, + ); + + // 触发钮沿用工具条那颗 30px 控件的高度和类:工具条行高仍是 30(见上面的算式)。 + expect(pixelValue(trigger, 'height')).toBe(30); + // 浮层锚在强度控件上、从工具条上方展开,不占工具条宽度;滑块占满浮层宽度。 + expect(declaration(anchor, 'position')).toBe('relative'); + expect(declaration(popover, 'position')).toBe('absolute'); + expect(declaration(popover, 'bottom')).toBe('calc(100% + 8px)'); + expect(declaration(range, 'width')).toBe('100%'); + }); + it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { const conversation = mobileDeclarations(CONVERSATION); const list = mobileDeclarations(...LIST_SELECTORS); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d5bacb57b..a57ec0757 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,22 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-04 输入区不放常驻状态行:那一排控件容不下第二行文字 + +- **现象**:AGC 工作台输入区里模型失败提示常驻一行红字,工具条上还浮着一个半透明的「模型 ⌄」压在 `@ + 默认 ⌄` 那一排上;用户复核口径是"完全没对齐",并要求改成 toast。 +- **成因**:`ConversationModelSelect` 的 `[role='alert']` 曾被从 `bottom: 36px` 浮层收回控件排内(`flex: 1 1 100%` 独占一行),整颗模型控件因此变成两行高;工具条是 `align-items: center` 的 flex 行、行高由最高项决定,于是触发钮被顶到行线上方、提示落到行线下方——**不是"某个元素绝对定位压过来",是行内多了一行文字**。 +- **处理(现行口径)**:输入区**不留常驻状态行**,与输入区同一排的失败提示一律走浮层。`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 `inline`,首页 / 策划 / 画布原样不动),Direct 输入盒传 `'toast'`,由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s 自动收起);触发时机是失败消息**新出现**时一次(同条去重,清空后可再报),不做每次渲染都弹;不可用态照旧挡住发送,但不靠文字行表达。文案同步去掉误解性的「本地」前缀(`llmModelCatalog.ts` / `clientApi.ts` → 「模型服务不可用」)。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`——不可用时产出 toast,且输入区子树里既没有 `[role='alert']` 也没有旧文案;模型失败只报一次、清空后再出现才再报。 +- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 + +## 2026-10-04 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽 + +- 触发钮固定文案「选择强度」,当前档位只出现在 `aria-label` / `title` 与浮层里的「当前:最高」;滑块直接用原生 ``:`role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End 都是原生行为,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 +- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**,只换表现形式;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`,策划输入盒继续走下拉(零语义变化,差分审计仍为 0 处声明变化)。 +- 点档位后浮层**保持打开**(连续调节不用重开),关闭走 Esc 与点击外部;浮层几何沿用 `.project-chat-attachment-menu` 同一套约定,颜色只取 `--platform-*`。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts` 的滑块用例(打开 / 取值 / 持久化 / 关闭路径)+ `tests/chatDialogFrameLayout.test.ts` 的浮层几何断言。 +- **关联**:`view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx`、`src/styles.css`。 + ## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位 - **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。