复核反馈修复:思维强度改 popover 滑块,模型不可用改 toast(#600)
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m25s
Project CI / Repository checks (pull_request) Failing after 35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m43s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m31s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m25s
Project CI / Repository checks (pull_request) Failing after 35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m43s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m31s
- 思维强度:`ComposerReasoningEffortSelect` 新增 `presentation: 'dropdown' | 'slider'`(默认 dropdown,策划输入盒原样不动),Direct 输入盒走 slider —— 触发钮固定文案「选择强度」(当前档位在 `aria-label`/`title` 与浮层里的「当前:最高」),档位选择收进 popover 里的原生 `<input type="range" min=0 max=4 step=1>`:自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道/拖拽按 step 吸附;Esc 与点击外部关闭,改档后浮层保持打开 - 档位枚举、默认值、持久化通道(`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)与旧下拉完全一致,只换表现形式 - 模型不可用:`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 inline,首页/策划/画布不变)与 `onUnavailable`,toast 模式下不再渲染常驻 `[role='alert']`;Direct 输入盒由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s)提示,失败消息新出现时一次、同条去重、清空后可再报 - 文案:`llmModelCatalog.ts` / `clientApi.ts` 的「本地模型服务不可用」改为「模型服务不可用」;不可用态照旧禁用发送,但不再留文字行 - 错位根因与改法:上一轮把模型提示收回控件排内使整颗控件变两行高、在 `align-items: center` 的工具条里把触发钮顶到行线上方;现在该元素不再渲染,`styles.css` 里对应的 composer 作用域 `[role='alert']` 规则按「删死重」删除 - `styles.css`:新增强度 popover / 滑块 / 档位点样式(几何沿用 `.project-chat-attachment-menu` 约定,颜色只取 `--platform-*`) - 测试:`tests/appSurface/chat-composer.suite.ts` 的档位用例改为滑块(打开、aria 值域、持久化、Esc/点外部关闭),新增 toast 用例与「只报一次」用例;`tests/chatDialogFrameLayout.test.ts` 新增 popover 几何断言;策划面差分审计复跑 0 处声明变化(对收口前提交仍报 62 处,证明检查不空转) - pitfalls 补两条:输入区不留常驻状态行(用 toast)、档位选择用 popover + 滑块
This commit is contained in:
+51
-1
@@ -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<ConversationModelSelectHandle>;
|
||||
/**
|
||||
* 目录 / 选择失败的呈现方式。
|
||||
*
|
||||
* `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<ClientLlmModel[]>(
|
||||
@@ -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 ? <span role="alert">{error}</span> : null}
|
||||
{/* `toast` 呈现方式下这里不渲染任何常驻文案:控件排容不下第二行,
|
||||
失败由宿主的浮层提示承担(见 `errorPresentation` 的说明)。 */}
|
||||
{error && errorPresentation === 'inline' ? (
|
||||
<span role="alert">{error}</span>
|
||||
) : null}
|
||||
{notice ? <span role="status">{notice}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,7 +25,7 @@ type ClientLlmModelCatalogWire = Omit<ClientLlmModelCatalog, 'models'> & {
|
||||
|
||||
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('本地模型服务不可用');
|
||||
if (!invoke) throw new Error('模型服务不可用');
|
||||
const catalog = await invoke<ClientLlmModelCatalogWire>(
|
||||
'load_game_creator_llm_models',
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ export function notifyLlmConfigChanged() {
|
||||
async function loadEffectiveCatalog(epoch: number) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new LlmModelCatalogConfigError('本地模型服务不可用');
|
||||
throw new LlmModelCatalogConfigError('模型服务不可用');
|
||||
}
|
||||
let config: GameCreatorAppConfigView | undefined;
|
||||
try {
|
||||
|
||||
@@ -13149,12 +13149,10 @@ details.design-agent-reasoning[open]
|
||||
要么用更长的选择器链严格盖过前文,把整块挪到文件中间结果也不变。
|
||||
① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行
|
||||
让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠;
|
||||
② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` /
|
||||
`[role='status']`)从"浮在输入区上方"改成"控件排内独占一行":它原来的
|
||||
`bottom: 36px` 正好把错误文案画在输入内容区第 3~4 行文字上(实测 70×36px
|
||||
与编辑器相交)——这条覆盖的是全局规则,靠更长的选择器链(多带
|
||||
`.project-chat-composer-controls`)取胜,不依赖源码顺序;
|
||||
③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 */
|
||||
② 输入区不留常驻状态行:模型服务的失败走 `ComposerModelNoticeToast` 浮层提示
|
||||
(控件排容不下第二行文字,实测会把触发钮顶到行线上方,见下面 ② 段);
|
||||
③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距;
|
||||
④ 思维强度改成 popover + 滑块(AGC 自己的浮层约定 + 原生 `<input type="range">`)。 */
|
||||
|
||||
/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号。 */
|
||||
.game-workbench-chat
|
||||
@@ -13197,29 +13195,211 @@ details.design-agent-reasoning[open]
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ② 模型控件的错误 / 状态提示:从绝对定位浮层改为占一行,跟随控件换行。 */
|
||||
/* ② 模型控件的错误 / 状态提示:**不在控件里渲染**。
|
||||
这一段原来把 `.conversation-model-select [role='alert']` 从 `bottom: 36px` 浮层收回
|
||||
控件排内独占一行;实测这样会让整颗模型控件变成两行高,在 `align-items: center` 的
|
||||
工具条里把触发钮顶到行线上方、提示落到行线下方(用户复核反馈的"完全没对齐")。
|
||||
现在 Direct 输入盒改用 `errorPresentation="toast"`:控件排内不再有这两颗元素,
|
||||
本条规则恒无命中对象,已删;文案走输入区自己的浮层提示
|
||||
(`ComposerModelNoticeToast`)。 */
|
||||
|
||||
/* ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距:
|
||||
队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */
|
||||
|
||||
/* ④ 思维强度:触发钮固定文案「选择强度」,档位选择收进 popover 里的滑块。
|
||||
popover 的定位 / 底色 / 描边 / 阴影沿用 `.project-chat-attachment-menu` 同一套浮层
|
||||
约定,颜色全部走 `--platform-*`(不引入新色值);滑块是原生 `<input type="range">`,
|
||||
自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext`
|
||||
报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-controls
|
||||
.conversation-model-select
|
||||
[role='alert'],
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-controls
|
||||
.conversation-model-select
|
||||
[role='status'] {
|
||||
position: static;
|
||||
order: 1;
|
||||
flex: 1 1 100%;
|
||||
.project-chat-composer-strength {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 触发钮只比其它方钮宽一点(文案 + 箭头),不再显示当前档位。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-trigger {
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 224px;
|
||||
max-width: min(280px, calc(100vw - 40px));
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 12px;
|
||||
background: var(--platform-input-fill);
|
||||
box-shadow: 0 12px 28px rgb(0 0 0 / 16%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-model {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--platform-button-ghost-fill);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: -6px;
|
||||
appearance: none;
|
||||
border: 2px solid var(--platform-button-primary-fill);
|
||||
border-radius: 999px;
|
||||
background: var(--platform-input-fill);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range:hover:not(
|
||||
:disabled
|
||||
)::-webkit-slider-thumb {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range:active:not(
|
||||
:disabled
|
||||
)::-webkit-slider-thumb {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Firefox 的滑块伪元素与 WebKit 不同名,同一套取值再来一份。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--platform-button-ghost-fill);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-range::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--platform-button-primary-fill);
|
||||
border-radius: 999px;
|
||||
background: var(--platform-input-fill);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 档位点:纯装饰(命中与吸附由原生 range 负责),个数跟着档位契约走,
|
||||
所以用 `grid-auto-flow: column` 而不是写死 5 列。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-stops {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
justify-items: center;
|
||||
margin-top: -12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-stops
|
||||
span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--platform-surface-border);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-stops
|
||||
span.is-active {
|
||||
background: var(--platform-button-primary-fill);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-strength-value {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
+234
-46
@@ -18,6 +18,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveTauriInvoke } from '../../../../../app/tauri';
|
||||
@@ -310,8 +313,18 @@ export function ComposerVoiceButton({
|
||||
*/
|
||||
export function ComposerReasoningEffortSelect({
|
||||
disabled,
|
||||
presentation = 'dropdown',
|
||||
modelLabel,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
/**
|
||||
* `dropdown`(默认)保持原来的下拉列表,供策划输入盒沿用;
|
||||
* `slider` 换成 popover + 滑块(Codex 观感),供 Direct 输入盒用——那一排容不下
|
||||
* 第二个下拉,滑块把选择过程收进浮层,触发钮只占一颗控件的宽度。
|
||||
*/
|
||||
presentation?: 'dropdown' | 'slider';
|
||||
/** `slider` 呈现方式下 popover 头部只读展示的当前模型名(目录没读完时为空)。 */
|
||||
modelLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
|
||||
@@ -321,7 +334,37 @@ export function ComposerReasoningEffortSelect({
|
||||
const [notice, setNotice] = useState('');
|
||||
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const mountedRef = useRef(true);
|
||||
const popoverRef = useRef<HTMLSpanElement | null>(null);
|
||||
const options = composerReasoningEffortOptions();
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === effort),
|
||||
);
|
||||
const activeLabel = options[activeIndex]?.label ?? effort;
|
||||
|
||||
// 滑块浮层的关闭语义与 `+` 弹层一致:Esc 与点击外部;下拉形态保持原行为不变。
|
||||
useEffect(() => {
|
||||
if (presentation !== 'slider' || !open) {
|
||||
return;
|
||||
}
|
||||
function handleOutsidePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (popoverRef.current && !popoverRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsidePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsidePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open, presentation]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -391,58 +434,140 @@ export function ComposerReasoningEffortSelect({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="project-chat-reasoning-effort">
|
||||
{/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式,
|
||||
不再用原生 `<select>`(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */}
|
||||
<div className="conversation-model-select">
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
/*
|
||||
下拉形态(策划等宿主原样沿用):与模型选择器同一套观感,复用 `conversation-model-*`
|
||||
的触发钮与浮层样式。
|
||||
*/
|
||||
const dropdownSelect = (
|
||||
<div className="conversation-model-select">
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
aria-label="推理档"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">{activeLabel}</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="推理档"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === effort}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
selectEffort(option.value);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
{option.value === effort ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
滑块形态(Direct 输入盒):档位收进浮层,触发钮只占一颗控件的宽度;
|
||||
原生 `<input type="range">` 自带 `role="slider"` 与 ←/→/Home/End 键盘行为,
|
||||
点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名。
|
||||
*/
|
||||
const sliderSelect = (
|
||||
<div className="project-chat-composer-strength">
|
||||
{open ? (
|
||||
<div
|
||||
className="project-chat-composer-strength-popover"
|
||||
role="group"
|
||||
aria-label="思维强度"
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">
|
||||
{options.find((option) => option.value === effort)?.label ?? effort}
|
||||
</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="推理档"
|
||||
className="project-chat-composer-strength-model"
|
||||
title={modelLabel || undefined}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
{/* 只读展示:选模型仍在工具条那颗模型钮上,这里只回答「给哪个模型调强度」。 */}
|
||||
{modelLabel || '模型'}
|
||||
</span>
|
||||
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
|
||||
中文档位名,读屏念的是「最高」而不是「4」。 */}
|
||||
<input
|
||||
type="range"
|
||||
className="project-chat-composer-strength-range"
|
||||
min={0}
|
||||
max={options.length - 1}
|
||||
step={1}
|
||||
value={activeIndex}
|
||||
aria-label="思维强度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={options.length - 1}
|
||||
aria-valuenow={activeIndex}
|
||||
aria-valuetext={activeLabel}
|
||||
disabled={disabled || saving}
|
||||
onChange={(event) => {
|
||||
const next = options[Number(event.currentTarget.value)];
|
||||
if (next && next.value !== effort) {
|
||||
selectEffort(next.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="project-chat-composer-strength-stops"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<span
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === effort}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
selectEffort(option.value);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
{option.value === effort ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
className={index === activeIndex ? 'is-active' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
<span className="project-chat-composer-strength-value">
|
||||
{`当前:${activeLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 触发钮用固定文案(不显示当前档位):档位在 popover 里看,控件保持一颗的宽度。 */}
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger project-chat-composer-strength-trigger"
|
||||
aria-label={`选择强度,当前 ${activeLabel}`}
|
||||
title={`选择思维强度(当前:${activeLabel})`}
|
||||
aria-expanded={open}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">选择强度</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="project-chat-reasoning-effort" ref={popoverRef}>
|
||||
{presentation === 'slider' ? sliderSelect : dropdownSelect}
|
||||
{notice ? (
|
||||
<span role="status" className="project-chat-composer-notice">
|
||||
{notice}
|
||||
@@ -473,3 +598,66 @@ export function ComposerStopButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(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(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] w-max max-w-[min(680px,calc(100vw-2rem))] -translate-x-1/2"
|
||||
data-composer-model-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone="error"
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
+34
-1
@@ -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<ConversationModelSelectHandle>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
const [voiceNotice, setVoiceNotice] = useState('');
|
||||
/*
|
||||
模型服务的失败只走浮层提示:输入区那一排控件不留常驻状态行(会把它顶成两行高)。
|
||||
同一条文案再次出现时也能再弹一次,所以按自增 id 记这一条。
|
||||
*/
|
||||
const [modelNotice, setModelNotice] = useState<ComposerModelNotice | null>(
|
||||
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({
|
||||
/>
|
||||
</div>
|
||||
<div className="project-chat-composer-controls-right">
|
||||
<ComposerReasoningEffortSelect disabled={busy} />
|
||||
<ComposerReasoningEffortSelect
|
||||
disabled={busy}
|
||||
presentation="slider"
|
||||
modelLabel={modelLabel}
|
||||
/>
|
||||
<ConversationModelSelect
|
||||
ref={modelSelectRef}
|
||||
disabled={busy}
|
||||
onReady={setModelReady}
|
||||
projectPath={projectPath ?? ''}
|
||||
errorPresentation="toast"
|
||||
onUnavailable={pushModelNotice}
|
||||
onModelLabelChange={setModelLabel}
|
||||
/>
|
||||
<ComposerVoiceButton
|
||||
disabled={busy || modelValidating}
|
||||
@@ -284,6 +309,14 @@ export function DirectProjectComposer({
|
||||
}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
{/*
|
||||
输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下
|
||||
第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。
|
||||
*/}
|
||||
<ComposerModelNoticeToast
|
||||
notice={modelNotice}
|
||||
onDismiss={() => setModelNotice(null)}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` 与浮层里的「当前:最高」;滑块直接用原生 `<input type="range" min=0 max=4 step=1>`:`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)。
|
||||
|
||||
Reference in New Issue
Block a user