输入盒对标 Codex 补齐上传、终止、队列、语音与推理档

文件上传:+ 改成添加入口(上传本地文件 / 引用项目素材),上传走既有 upload_local_asset 链路并落成 DirectCodexTurnAttachment,随下次提交进入回合 attachments,提交后清空。

终止:回合运行中发送钮位置显示终止方块,调用新增 cancel_direct_codex_turn;中断回流被识别为主动终止,回合 finally 复位输入盒,不再写运行错误与诊断。

队列:回合运行中再次发送进本地 FIFO 队列并在输入盒上方以 chip 展示、可单条取消,回合结束后按序自动发出;输入区在 direct-codex 回合运行中保持可编辑(其余面板维持原行为)。

语音:用 SpeechRecognition/webkitSpeechRecognition,缺失时按钮禁用并直接给出不支持提示;录音态有 is-recording 视觉反馈,识别文本追加进草稿(ResourceReferenceInput 新增 insertText)。

推理档:移到模型选择器旁,读 read_game_creator_app_config、写 select_game_creator_reasoning_effort,只影响后续回合。

新增 tests/appSurface/chat-composer.suite.ts(10 条:队列 FIFO/取消、终止复位、上传后附件进提交、推理档写入回读、语音降级与录音态、追加不覆盖)并更新 + 与推理档相关的既有断言。
This commit is contained in:
2026-09-15 18:18:23 +08:00
parent 1a2331bdb1
commit a766ff9634
12 changed files with 2062 additions and 66 deletions
+266 -17
View File
@@ -148,6 +148,7 @@ import {
type WorkspaceLauncherProps,
writeRecentWorkspace,
} from './features/app-shell/model';
import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation';
import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher';
import {
agentConversationReadDraftsFromManifest,
@@ -220,6 +221,15 @@ import {
isMissingProjectFileError,
parseAgentRunTrace,
} from './features/project-workspace/agentRunTrace';
import {
chatQueueFullNotice,
createQueuedChatTurn,
dequeueChatTurn,
enqueueChatTurn,
isChatTurnQueueFull,
type QueuedChatTurn,
removeQueuedChatTurn,
} from './features/project-workspace/chatComposerQueue';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
@@ -472,6 +482,8 @@ function directCodexTurnIdFromAssistantMessageId(messageId: string) {
);
}
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -479,6 +491,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
.startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX);
}
/**
* "终止" await app-server
* `Codex app-server turn 已中断`
* "已终止本次回合"
*/
export function isDirectCodexTurnInterruptedError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message.includes('turn 已中断') || message.includes('已终止本次回合');
}
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
if (!message.runtimeOwned) {
return false;
@@ -747,6 +769,23 @@ export function App({
: '',
);
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
/**
* direct-codex chip
* `chat_with_game_creator_direct_codex` `attachments`
*
*/
const [chatAttachments, setChatAttachments] = useState<
DirectCodexTurnAttachment[]
>([]);
const [chatAttachmentNotice, setChatAttachmentNotice] = useState('');
/** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */
const [chatTurnQueue, setChatTurnQueue] = useState<QueuedChatTurn[]>([]);
const chatTurnQueueRef = useRef<QueuedChatTurn[]>([]);
chatTurnQueueRef.current = chatTurnQueue;
const [chatComposerNotice, setChatComposerNotice] = useState('');
const [directCodexTurnCancelling, setDirectCodexTurnCancelling] =
useState(false);
const queuedChatTurnSequenceRef = useRef(0);
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
@@ -6462,6 +6501,18 @@ export function App({
if (localProjectPathRef.current !== directProjectPath) {
return;
}
if (isDirectCodexTurnInterruptedError(error)) {
// 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
setDirectCodexStatus('failed');
setDirectCodexProgress('');
setProjectSupervisorRuntimeError('');
setChatComposerNotice('已终止本次回合');
setMessages((current) =>
appendDirectAssistantMessage(current, '已终止本次回合。'),
);
return;
}
void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID);
const message =
error instanceof Error ? error.message : String(error);
@@ -6487,6 +6538,7 @@ export function App({
}
} finally {
setChatAgentBusy(false);
setDirectCodexTurnCancelling(false);
setDirectCodexProgress('');
const activeTurn = activeDirectCodexTurnRef.current;
if (
@@ -6496,6 +6548,8 @@ export function App({
) {
resetDirectCodexTurn();
}
// 队列:本回合确实结束后,按 FIFO 自动发出下一条(不丢、不乱序)。
dispatchNextQueuedChatTurn();
}
}
return;
@@ -11733,12 +11787,181 @@ export function App({
}
}, [agentStatusCards, selectedAgent]);
/**
* direct-codex
* / id/
*/
function startDirectCodexConversationTurn(input: {
prompt: string;
attachments?: DirectCodexTurnAttachment[];
references?: ChatReference[];
}) {
const clientTurnId = createDirectCodexConversationTurnId();
supervisorChatShouldFollowLatestRef.current = true;
setMessages((current) => [
...current,
{
role: 'user',
text: input.prompt,
runtimeOwned: true,
messageId: directCodexConversationMessageId(clientTurnId, 'user'),
updatedAt: Date.now(),
},
]);
void executeChatAgentReply({
prompt: input.prompt,
clientTurnId,
attachments: input.attachments?.length ? input.attachments : undefined,
references: input.references,
});
}
/**
* `upload_local_asset`
* **** Rust
*/
async function handleChatComposerUploadFiles(files: readonly File[]) {
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke || !nextProjectPath) {
setChatAttachmentNotice('需要先打开本地项目,才能上传文件');
return;
}
const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length;
const accepted = files.slice(0, Math.max(remaining, 0));
if (accepted.length === 0) {
setChatAttachmentNotice(
`最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`,
);
return;
}
setChatAttachmentNotice('正在上传文件');
try {
const imported = await uploadLocalFilesAsAttachments(
invoke,
nextProjectPath,
accepted,
);
const attachments = toDirectCodexTurnAttachments(imported);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
setChatAttachments((current) =>
[...current, ...attachments].slice(0, MAX_CHAT_COMPOSER_ATTACHMENTS),
);
const failed = attachments.filter(
(attachment) => attachment.status === 'failed',
);
setChatAttachmentNotice(
failed.length > 0
? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}`
: `已上传 ${attachments.length} 个文件,将在下次发送时作为本轮附件`,
);
void refreshManifest(nextProjectPath);
} catch (error) {
if (localProjectPathRef.current === nextProjectPath) {
setChatAttachmentNotice(
error instanceof Error ? error.message : String(error),
);
}
}
}
function removeChatComposerAttachment(index: number) {
setChatAttachments((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
);
}
/** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */
function enqueueChatTurnForRunningTurn(input: {
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
}): boolean {
if (isChatTurnQueueFull(chatTurnQueueRef.current)) {
setChatComposerNotice(chatQueueFullNotice());
return false;
}
queuedChatTurnSequenceRef.current += 1;
const turn = createQueuedChatTurn({
id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`,
prompt: input.prompt,
attachments: input.attachments,
references: input.references,
createdAt: Date.now(),
});
const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn);
chatTurnQueueRef.current = nextQueue;
setChatTurnQueue(nextQueue);
setChatComposerNotice('已加入发送队列,当前回合结束后自动发送');
return true;
}
function cancelQueuedChatTurn(id: string) {
const nextQueue = removeQueuedChatTurn(chatTurnQueueRef.current, id);
chatTurnQueueRef.current = nextQueue;
setChatTurnQueue(nextQueue);
if (nextQueue.length === 0) {
setChatComposerNotice('');
}
}
/** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */
function dispatchNextQueuedChatTurn() {
const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current);
if (!next) {
return;
}
chatTurnQueueRef.current = rest;
setChatTurnQueue(rest);
if (rest.length === 0) {
setChatComposerNotice('');
}
startDirectCodexConversationTurn({
prompt: next.prompt,
attachments: next.attachments,
references: next.references,
});
}
/** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */
async function handleCancelDirectCodexTurn() {
if (directCodexTurnCancelling) {
return;
}
const invoke = resolveTauriInvoke();
const activeTurn = activeDirectCodexTurnRef.current;
const directProjectPath =
activeTurn?.projectPath ?? resolveChatProjectPath(localProject);
if (!invoke || !directProjectPath || !activeTurn) {
setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。');
return;
}
setDirectCodexTurnCancelling(true);
setChatComposerNotice('正在终止当前回合');
try {
await invoke('cancel_direct_codex_turn', {
projectPath: directProjectPath,
clientTurnId: activeTurn.turnId,
});
setDirectCodexProgress('正在终止当前回合');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setProjectSupervisorRuntimeError(`终止失败:${message}`);
setChatComposerNotice('');
} finally {
setDirectCodexTurnCancelling(false);
}
}
function handleProjectSupervisorOnlySubmit(
event: FormEvent<HTMLFormElement>,
) {
event.preventDefault();
const prompt = chatInput.trim();
const references = chatReferences;
const pendingAttachments = chatAttachments;
if (
!directCodexProductRuntime &&
supervisorChatOnly &&
@@ -11754,7 +11977,25 @@ export function App({
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return;
}
if ((!prompt && references.length === 0) || chatAgentBusy) {
if (!prompt && references.length === 0 && pendingAttachments.length === 0) {
return;
}
if (chatAgentBusy) {
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
if (directCodexProductRuntime) {
const enqueued = enqueueChatTurnForRunningTurn({
prompt,
attachments: pendingAttachments,
references,
});
if (enqueued) {
setChatInput('');
setChatReferences([]);
setChatAttachments([]);
setChatAttachmentNotice('');
}
}
return;
}
if (directCodexProductRuntime && prompt === '/history') {
@@ -11790,9 +12031,20 @@ export function App({
if (supervisorChatOnly || directCodexProductRuntime) {
supervisorChatShouldFollowLatestRef.current = true;
}
const directConversationTurnId = directCodexProductRuntime
? createDirectCodexConversationTurnId()
: undefined;
if (directCodexProductRuntime) {
// 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。
setChatInput('');
setChatReferences([]);
setChatAttachments([]);
setChatAttachmentNotice('');
setChatComposerNotice('');
startDirectCodexConversationTurn({
prompt,
attachments: pendingAttachments,
references,
});
return;
}
setChatInput('');
setChatReferences([]);
setMessages((current) => [
@@ -11801,22 +12053,10 @@ export function App({
role: 'user',
text: prompt,
runtimeOwned: true,
...(directConversationTurnId
? {
messageId: directCodexConversationMessageId(
directConversationTurnId,
'user',
),
}
: {}),
updatedAt: Date.now(),
},
]);
void executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
references,
});
void executeChatAgentReply({ prompt, references });
}
const visibleProfessionalAgentCards = agentStatusCards.filter(
@@ -11879,8 +12119,17 @@ export function App({
return (
<ProjectSupervisorView
activeVersionId={chatActiveVersionId}
attachments={chatAttachments}
attachmentNotice={chatAttachmentNotice}
chatInput={chatInput}
chatReferences={chatReferences}
composerNotice={chatComposerNotice}
onCancelQueuedTurn={cancelQueuedChatTurn}
onCancelTurn={() => void handleCancelDirectCodexTurn()}
onRemoveAttachment={removeChatComposerAttachment}
onUploadFiles={(files) => void handleChatComposerUploadFiles(files)}
queuedTurns={chatTurnQueue}
turnCancelling={directCodexTurnCancelling}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
@@ -87,6 +87,51 @@ async function suggestAutomaticProjectName(
}
}
/**
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
*
* 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后,
* 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。
*/
export async function uploadLocalFilesAsAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
files: readonly File[],
): Promise<LauncherImportedAttachment[]> {
const imported: LauncherImportedAttachment[] = [];
for (const file of files) {
const mediaType = file.type || 'application/octet-stream';
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: file.size,
});
} catch (error) {
imported.push({
fileName: file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: file.size,
});
}
}
return imported;
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -245,40 +290,11 @@ export function useHomeProjectCreation({
nextProjectPath: string,
attachments: HomeAttachmentDraft[],
) {
const imported: LauncherImportedAttachment[] = [];
for (const attachment of attachments) {
const mediaType = attachment.file.type || 'application/octet-stream';
try {
const bytes = Array.from(
new Uint8Array(await attachment.file.arrayBuffer()),
);
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: attachment.file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: attachment.file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: attachment.file.size,
});
} catch (error) {
imported.push({
fileName: attachment.file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: attachment.file.size,
});
}
}
return imported;
return uploadLocalFilesAsAttachments(
invoke,
nextProjectPath,
attachments.map((attachment) => attachment.file),
);
}
async function enterCreatedHomeProject(
@@ -0,0 +1,463 @@
/**
* 输入盒控件(Codex 观感):左 `+`(上传本地文件 / 引用项目素材)、右侧推理强度 +
* 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。
*
* 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`
* 队列由 `chatComposerQueue.ts` 的纯函数维护。
*/
import { FileUp, Images, Mic, MicOff, Plus, Square, X } from 'lucide-react';
import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
GameCreatorAppConfigView,
GameCreatorLlmReasoningEffort,
} from '../../app/types';
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { QueuedChatTurn } from './chatComposerQueue';
import { queuedChatTurnLabel } from './chatComposerQueue';
import {
resolveSpeechRecognitionCtor,
speechEventTranscript,
type SpeechRecognitionCtor,
speechRecognitionErrorMessage,
speechRecognitionLang,
type SpeechRecognitionLike,
VOICE_INPUT_UNSUPPORTED_MESSAGE,
} from './chatComposerVoice';
import {
composerReasoningEffortOptions,
DEFAULT_COMPOSER_REASONING_EFFORT,
normalizeComposerReasoningEffort,
} from './composerReasoningEffort';
type ComposerAttachmentMenuProps = {
disabled: boolean;
onPickFiles: (files: readonly File[]) => void;
onOpenReferencePicker: () => void;
};
/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */
export function ComposerAttachmentMenu({
disabled,
onPickFiles,
onOpenReferencePicker,
}: ComposerAttachmentMenuProps) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
function handleOutsidePointerDown(event: MouseEvent) {
const target = event.target as Node | null;
if (anchorRef.current && !anchorRef.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]);
return (
<div
ref={anchorRef}
className="project-supervisor-attachment-anchor"
data-composer-attachment-menu={open ? 'open' : 'closed'}
>
<input
ref={fileInputRef}
type="file"
multiple
className="project-supervisor-upload-input"
data-chat-composer-upload="true"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPickFiles(files);
}
}}
/>
<button
type="button"
className="project-supervisor-attachment-trigger"
aria-label="添加文件"
title="添加文件"
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
>
<Plus size={15} aria-hidden="true" />
</button>
{open ? (
<div
className="project-supervisor-attachment-menu"
role="menu"
aria-label="添加文件"
>
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
fileInputRef.current?.click();
}}
>
<FileUp size={14} aria-hidden="true" />
<span></span>
</button>
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
onOpenReferencePicker();
}}
>
<Images size={14} aria-hidden="true" />
<span></span>
</button>
</div>
) : null}
</div>
);
}
/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */
export function ComposerPendingAttachments({
attachments,
onRemove,
}: {
attachments: readonly DirectCodexTurnAttachment[];
onRemove: (index: number) => void;
}) {
if (attachments.length === 0) {
return null;
}
return (
<ul
className="project-supervisor-composer-attachments"
aria-label="待发送附件"
>
{attachments.map((attachment, index) => (
<li
key={`${attachment.name}-${index}`}
data-attachment-status={attachment.status ?? 'ready'}
>
<span title={attachment.localPath ?? attachment.name}>
{attachment.name}
</span>
<button
type="button"
aria-label={`移除附件 ${attachment.name}`}
title={`移除附件 ${attachment.name}`}
onClick={() => onRemove(index)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
</ul>
);
}
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({
turns,
onCancel,
}: {
turns: readonly QueuedChatTurn[];
onCancel: (id: string) => void;
}) {
if (turns.length === 0) {
return null;
}
return (
<ol
className="project-supervisor-composer-queue"
aria-label="待发送消息队列"
>
{turns.map((turn, index) => (
<li key={turn.id} data-queue-index={index}>
<span className="project-supervisor-composer-queue-order">
{index + 1}
</span>
<span className="project-supervisor-composer-queue-text">
{queuedChatTurnLabel(turn)}
</span>
<button
type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
title="取消这条排队消息"
onClick={() => onCancel(turn.id)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
</ol>
);
}
type ComposerVoiceButtonProps = {
disabled: boolean;
onTranscript: (text: string) => void;
onNotice: (message: string) => void;
};
/**
* 麦克风:只在运行时确实提供 SpeechRecognition 时可用;否则按钮禁用并直接说明原因
* aria-label/title 都是那句提示,不假装能用)。录音态用 `is-recording` 做视觉反馈。
*/
export function ComposerVoiceButton({
disabled,
onTranscript,
onNotice,
}: ComposerVoiceButtonProps) {
const ctorRef: RefObject<SpeechRecognitionCtor | null> = useRef(
resolveSpeechRecognitionCtor(
typeof window === 'undefined' ? null : (window as unknown as object),
),
);
const ctor = ctorRef.current;
const supported = Boolean(ctor);
const [recording, setRecording] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const onTranscriptRef = useRef(onTranscript);
const onNoticeRef = useRef(onNotice);
useEffect(() => {
onTranscriptRef.current = onTranscript;
onNoticeRef.current = onNotice;
}, [onNotice, onTranscript]);
useEffect(
() => () => {
recognitionRef.current?.abort?.();
recognitionRef.current = null;
},
[],
);
const unsupportedHint = VOICE_INPUT_UNSUPPORTED_MESSAGE;
const activeLabel = recording ? '停止语音输入' : '语音输入';
function startRecognition() {
if (!ctor) {
onNoticeRef.current(unsupportedHint);
return;
}
try {
const recognition = new ctor();
recognition.lang = speechRecognitionLang(navigator?.language);
recognition.continuous = true;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
const transcript = speechEventTranscript(event);
if (transcript) {
onTranscriptRef.current(transcript);
}
};
recognition.onerror = (event) => {
const message = speechRecognitionErrorMessage(event?.error);
setRecording(false);
if (message) {
onNoticeRef.current(message);
}
};
recognition.onend = () => {
setRecording(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
setRecording(true);
} catch (error) {
recognitionRef.current = null;
setRecording(false);
onNoticeRef.current(
error instanceof Error && error.message
? error.message
: '语音输入启动失败,请稍后重试',
);
}
}
return (
<button
type="button"
className={`project-supervisor-voice-trigger${recording ? ' is-recording' : ''}`}
aria-label={supported ? activeLabel : unsupportedHint}
title={supported ? activeLabel : unsupportedHint}
aria-pressed={supported ? recording : undefined}
disabled={disabled || !supported}
onClick={() => {
if (recording) {
recognitionRef.current?.stop();
setRecording(false);
return;
}
startRecognition();
}}
>
{supported ? (
<Mic size={15} aria-hidden="true" />
) : (
<MicOff size={15} aria-hidden="true" />
)}
</button>
);
}
/**
* 推理强度:原生 select,紧挨模型选择器。读取/写回都走客户端配置通道
* `read_game_creator_app_config` / `select_game_creator_reasoning_effort`)。
*/
export function ComposerReasoningEffortSelect({
disabled,
}: {
disabled: boolean;
}) {
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
DEFAULT_COMPOSER_REASONING_EFFORT,
);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
const mountedRef = useRef(true);
const options = composerReasoningEffortOptions();
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
let cancelled = false;
const invoke = resolveTauriInvoke();
if (!invoke) {
return undefined;
}
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
.then((view) => {
if (cancelled || !mountedRef.current) return;
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
);
})
.catch(() => {
if (cancelled || !mountedRef.current) return;
setNotice('推理档读取失败');
});
return () => {
cancelled = true;
};
}, []);
function selectEffort(next: GameCreatorLlmReasoningEffort) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice('需要在 Tauri App 内运行');
return;
}
const previous = effort;
setEffort(next);
setNotice('');
setSaving(true);
const write = () =>
invoke<GameCreatorAppConfigView>('select_game_creator_reasoning_effort', {
effort: next,
});
const run = writeChainRef.current.then(write, write);
writeChainRef.current = run.then(
() => undefined,
() => undefined,
);
void run
.then((view) => {
if (!mountedRef.current) return;
// 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
);
})
.catch(() => {
if (!mountedRef.current) return;
setEffort(previous);
setNotice('推理档保存失败');
})
.finally(() => {
if (mountedRef.current) {
setSaving(false);
}
});
}
return (
<span className="project-supervisor-reasoning-effort">
<select
aria-label="推理档"
className="project-supervisor-reasoning-effort-select"
value={effort}
disabled={disabled || saving}
onChange={(event) =>
selectEffort(
normalizeComposerReasoningEffort(event.currentTarget.value),
)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{notice ? (
<span role="status" className="project-supervisor-composer-notice">
{notice}
</span>
) : null}
</span>
);
}
/** 发送按钮位置在回合运行中显示的终止钮(Codex 的停止方块)。 */
export function ComposerStopButton({
cancelling,
onCancel,
}: {
cancelling: boolean;
onCancel: () => void;
}) {
return (
<button
type="button"
className="project-supervisor-submit-button project-supervisor-stop-button"
aria-label={cancelling ? '正在终止' : '终止'}
title={cancelling ? '正在终止' : '终止当前回合'}
disabled={cancelling}
onClick={onCancel}
>
<Square size={14} aria-hidden="true" />
</button>
);
}
@@ -3,7 +3,6 @@ import {
AtSign,
Loader2,
MessageSquareDashed,
Plus,
Settings,
} from 'lucide-react';
import type {
@@ -39,8 +38,18 @@ import {
ProjectSupervisorRuntimePanel,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerAttachmentMenu,
ComposerPendingAttachments,
ComposerReasoningEffortSelect,
ComposerStopButton,
ComposerTurnQueue,
ComposerVoiceButton,
} from './ComposerControls';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
@@ -93,6 +102,10 @@ function directStatusTitle(status: string | null | undefined) {
type ProjectSupervisorViewProps = RuntimePanelProps & {
activeVersionId?: string | null;
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
attachments?: DirectCodexTurnAttachment[];
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
attachmentNotice?: string;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
@@ -109,6 +122,17 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
onChatInputChange: (draft: ChatComposerDraft) => void;
onConfirmConfirmation: () => void;
onConfirmPendingCommand: () => void;
/** 回合运行中点"终止":只取消当前回合,不改后端协议。 */
onCancelTurn?: () => void;
onCancelQueuedTurn?: (id: string) => void;
onRemoveAttachment?: (index: number) => void;
onUploadFiles?: (files: readonly File[]) => void;
/** 队列里待发的消息(回合运行中再次发送时入队)。 */
queuedTurns?: QueuedChatTurn[];
/** 输入盒下方的通用提示(队列满、上传失败等)。 */
composerNotice?: string;
/** 终止请求在途:终止钮进入禁用的"正在终止"态。 */
turnCancelling?: boolean;
onScroll: UIEventHandler<HTMLDivElement>;
onShowEarlierMessages: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
@@ -150,6 +174,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
export function ProjectSupervisorView({
activeVersionId = null,
attachments = [],
attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets,
@@ -166,6 +192,13 @@ export function ProjectSupervisorView({
onChatInputChange,
onConfirmConfirmation,
onConfirmPendingCommand,
onCancelTurn,
onCancelQueuedTurn,
onRemoveAttachment,
onUploadFiles,
queuedTurns = [],
composerNotice = '',
turnCancelling = false,
onScroll,
onShowEarlierMessages,
onSubmit,
@@ -218,6 +251,8 @@ export function ProjectSupervisorView({
const modelValidateInFlightRef = useRef(false);
// 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。
const [settingsOpen, setSettingsOpen] = useState(false);
// 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。
const [voiceNotice, setVoiceNotice] = useState('');
const [approvalOpen, setApprovalOpen] = useState(false);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
@@ -557,6 +592,14 @@ export function ProjectSupervisorView({
void validateModel();
}}
>
<ComposerTurnQueue
turns={directCodex ? queuedTurns : []}
onCancel={(id) => onCancelQueuedTurn?.(id)}
/>
<ComposerPendingAttachments
attachments={directCodex ? attachments : []}
onRemove={(index) => onRemoveAttachment?.(index)}
/>
<ResourceReferenceInput
ref={composerRef}
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目需求'}
@@ -565,7 +608,9 @@ export function ProjectSupervisorView({
assets={chatProjectAssets}
projectPath={projectPath}
disabled={
runtimePanelProps.controlBusy ||
// direct-codex 回合运行中输入区保持可编辑:用户能继续写下一条消息进本地队列,
// 回合结束后(Enter 提交)自动依次发出。其它面板维持"运行中不接受输入"。
(runtimePanelProps.controlBusy && !directCodex) ||
needsUserInput ||
modelValidating ||
Boolean(designView?.session.pendingApproval) ||
@@ -585,16 +630,13 @@ export function ProjectSupervisorView({
{directCodex ? (
<div className="project-supervisor-composer-controls">
<div className="project-supervisor-composer-controls-left">
<button
type="button"
className="project-supervisor-attachment-trigger"
aria-label="添加素材引用"
title="添加素材引用"
<ComposerAttachmentMenu
disabled={runtimePanelProps.controlBusy || needsUserInput}
onClick={() => composerRef?.current?.openPicker()}
>
<Plus size={15} aria-hidden="true" />
</button>
onPickFiles={(files) => onUploadFiles?.(files)}
onOpenReferencePicker={() =>
composerRef?.current?.openPicker()
}
/>
<button
type="button"
className="project-supervisor-reference-trigger"
@@ -607,6 +649,9 @@ export function ProjectSupervisorView({
</button>
</div>
<div className="project-supervisor-composer-controls-right">
{/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端
配置,只影响后续回合;当前回合的行为不受影响。 */}
<ComposerReasoningEffortSelect disabled={needsUserInput} />
<ConversationModelSelect
ref={modelSelectRef}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
@@ -615,12 +660,36 @@ export function ProjectSupervisorView({
onReady={setModelReady}
projectPath={projectPath}
/>
{submitButton}
<ComposerVoiceButton
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
modelValidating
}
onTranscript={(text) =>
composerRef?.current?.insertText(text)
}
onNotice={setVoiceNotice}
/>
{submitting && onCancelTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
onCancel={onCancelTurn}
/>
) : (
submitButton
)}
</div>
</div>
) : (
submitButton
)}
{directCodex &&
(composerNotice || attachmentNotice || voiceNotice) ? (
<p className="project-supervisor-composer-notice" role="status">
{composerNotice || attachmentNotice || voiceNotice}
</p>
) : null}
</form>
{directCodex ? null : (
<small className="project-supervisor-workspace-status">
@@ -156,6 +156,8 @@ function createResourcePickerScopeStates(): Record<
export type ResourceReferenceInputHandle = {
insertReferences: (references: ChatReference[]) => void;
/** 追加纯文本(语音识别结果):写在当前光标处,且不覆盖用户已输入的内容。 */
insertText: (text: string) => void;
openPicker: () => void;
focus: () => void;
};
@@ -569,14 +571,43 @@ function ResourceReferenceEditor({
setPickerOpen(true);
}, []);
const insertText = useCallback(
(text: string) => {
const insert = text.replace(/\s+$/u, '');
if (!insert.trim()) return;
editor.update(() => {
let selection = $getSelection();
// 选区失效(跨会话恢复草稿后常见)时回落到草稿末尾,与 `insertReferences` 同口径。
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
// 认领的光标处的已有内容保持不变:这里只插入,不删除任何节点。
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) {
selection.insertText(' ');
}
selection.insertText(insert);
}
});
editor.focus();
},
[editor],
);
useImperativeHandle(
composerRef,
() => ({
insertReferences,
insertText,
openPicker,
focus: () => editor.focus(),
}),
[editor, insertReferences, openPicker],
[editor, insertReferences, insertText, openPicker],
);
useEffect(() => {
@@ -0,0 +1,89 @@
/**
* 输入盒本地消息队列(纯前端状态,不改后端协议)。
*
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { ChatReference } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
prompt: string;
attachments?: readonly DirectCodexTurnAttachment[];
references?: readonly ChatReference[];
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
prompt: input.prompt,
attachments: [...(input.attachments ?? [])],
references: [...(input.references ?? [])],
createdAt: input.createdAt,
};
}
/** 队尾追加。同一 id 已在队列里时原样返回,避免重复入队把同一条消息发两遍。 */
export function enqueueChatTurn(
queue: readonly QueuedChatTurn[],
turn: QueuedChatTurn,
): QueuedChatTurn[] {
if (queue.some((item) => item.id === turn.id)) {
return [...queue];
}
return [...queue, turn];
}
/** 取队首(FIFO)。队列为空时 `next` 为 `null``rest` 保持空数组。 */
export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): {
next: QueuedChatTurn | null;
rest: QueuedChatTurn[];
} {
if (queue.length === 0) {
return { next: null, rest: [] };
}
const [next, ...rest] = queue;
return { next: next ?? null, rest };
}
/** 单条取消:按 id 移除,其余项保持原有顺序。 */
export function removeQueuedChatTurn(
queue: readonly QueuedChatTurn[],
id: string,
): QueuedChatTurn[] {
return queue.filter((item) => item.id !== id);
}
export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean {
return queue.length >= MAX_QUEUED_CHAT_TURNS;
}
export function chatQueueFullNotice(): string {
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
}
/** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text;
}
if (turn.attachments.length > 0) {
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
}
if (turn.references.length > 0) {
return '素材引用';
}
return '未命名消息';
}
@@ -0,0 +1,144 @@
/**
* 输入盒语音输入:只做能力探测、文本拼接与错误文案,不依赖 React。
*
* WebView 是否提供 `SpeechRecognition` / `webkitSpeechRecognition` 由运行时决定;
* 拿不到构造器时必须禁用按钮并给出可读提示,不允许"看起来能用"。
*/
export const VOICE_INPUT_UNSUPPORTED_MESSAGE = '当前运行环境不支持语音输入';
export const VOICE_INPUT_PERMISSION_MESSAGE =
'语音输入未获得麦克风权限,请在系统设置中允许后重试';
export const VOICE_INPUT_SERVICE_MESSAGE =
'当前运行环境的语音识别服务不可用,请改用键盘输入';
export const VOICE_INPUT_DEFAULT_ERROR_MESSAGE = '语音输入失败,请稍后重试';
export type SpeechRecognitionAlternativeLike = {
transcript: string;
};
export type SpeechRecognitionResultLike = {
isFinal: boolean;
length: number;
[index: number]: SpeechRecognitionAlternativeLike;
};
export type SpeechRecognitionResultListLike = {
length: number;
[index: number]: SpeechRecognitionResultLike;
};
export type SpeechRecognitionEventLike = {
resultIndex: number;
results: SpeechRecognitionResultListLike;
};
export type SpeechRecognitionErrorEventLike = {
error?: string;
message?: string;
};
export type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
};
export type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
type SpeechScope = {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
/** 取当前运行时的语音识别构造器;两个厂商前缀都没有时返回 `null`(降级)。 */
export function resolveSpeechRecognitionCtor(
scope: unknown,
): SpeechRecognitionCtor | null {
if (!scope || typeof scope !== 'object') {
return null;
}
const candidate = scope as SpeechScope;
return (
candidate.SpeechRecognition ?? candidate.webkitSpeechRecognition ?? null
);
}
export function speechRecognitionLang(locale: string | undefined): string {
const normalized = locale?.trim();
if (!normalized) {
return 'zh-CN';
}
return normalized;
}
/**
* 识别文本追加到输入框已有内容之后——**不覆盖**用户已经输入的部分。
*
* 中文/日文这类无空格语言直接拼接;纯 ASCII 字母数字开头的识别结果补一个空格,
* 避免英文单词粘在一起。
*/
export function appendDictationText(
current: string,
transcript: string,
): string {
const text = transcript.trim();
if (!text) {
return current;
}
if (!current) {
return text;
}
if (/\s$/u.test(current)) {
return `${current}${text}`;
}
return /^[A-Za-z0-9]/u.test(text)
? `${current} ${text}`
: `${current}${text}`;
}
/** 识别结果的最终文本(同一轮里可能有多段 interim,取本次事件里的最终段)。 */
export function speechEventTranscript(
event: SpeechRecognitionEventLike,
): string {
let transcript = '';
for (
let index = event.resultIndex;
index < event.results.length;
index += 1
) {
const result = event.results[index];
if (!result) continue;
const alternative = result[0];
if (alternative?.transcript) {
transcript += alternative.transcript;
}
}
return transcript;
}
export function speechRecognitionErrorMessage(
error: string | undefined,
): string {
switch (error?.trim()) {
case 'not-allowed':
case 'service-not-allowed':
return VOICE_INPUT_PERMISSION_MESSAGE;
case 'network':
return VOICE_INPUT_SERVICE_MESSAGE;
case 'audio-capture':
return '未检测到可用的麦克风设备';
case 'no-speech':
return '没有识别到语音,请重试';
case 'aborted':
return '';
default:
return VOICE_INPUT_DEFAULT_ERROR_MESSAGE;
}
}
@@ -0,0 +1,48 @@
/**
* 输入盒推理强度控件:把运行配置里的 `llm.reasoningEffort` 搬到输入盒这一排。
*
* 配置写入走既有客户端配置通道,只影响**后续**回合:每个回合开始时 Rust 侧都会
* 重新读取一次客户端配置,因此改档不会改变正在跑的回合。
*/
import type { GameCreatorLlmReasoningEffort } from '../../app/types';
import { gameCreatorLlmReasoningEfforts } from '../../app/types';
export const DEFAULT_COMPOSER_REASONING_EFFORT: GameCreatorLlmReasoningEffort =
'default';
const reasoningEffortLabels: Record<GameCreatorLlmReasoningEffort, string> = {
default: '默认',
low: '低',
medium: '中',
high: '高',
max: '最高',
};
export function reasoningEffortLabel(
effort: GameCreatorLlmReasoningEffort,
): string {
return reasoningEffortLabels[effort];
}
/** 配置值可能是缺字段 / 大小写不一致;只有契约内的档位才认,其余回落默认档。 */
export function normalizeComposerReasoningEffort(
value: unknown,
): GameCreatorLlmReasoningEffort {
if (typeof value !== 'string') {
return DEFAULT_COMPOSER_REASONING_EFFORT;
}
const normalized = value.trim().toLowerCase();
return gameCreatorLlmReasoningEfforts.find((effort) => effort === normalized)
? (normalized as GameCreatorLlmReasoningEffort)
: DEFAULT_COMPOSER_REASONING_EFFORT;
}
export function composerReasoningEffortOptions(): {
value: GameCreatorLlmReasoningEffort;
label: string;
}[] {
return gameCreatorLlmReasoningEfforts.map((effort) => ({
value: effort,
label: reasoningEffortLabel(effort),
}));
}
+280
View File
@@ -10736,6 +10736,286 @@ button.design-workspace-tree__entry:hover,
color: var(--platform-button-primary-text);
}
/* ===== 输入盒新增控件`+` 上传弹层 / 语音 / 推理档 / 终止钮 / 附件与队列 chip=====
全部是**追加**规则上面那三条共用尺寸/配色规则`+``@`发送钮保持原样
这里只给新增元素自己的几何与配色 */
/* `+` 弹层的包含块:弹层是 absolute,锚点必须自己带定位,否则会挂到整只 composer 上。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-attachment-anchor {
position: relative;
display: grid;
flex: 0 0 auto;
place-items: center;
}
/* 原生文件选择器只作为 `+` 弹层的落点,不放进版式。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-upload-input {
display: none;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 3;
display: grid;
min-width: 168px;
padding: 4px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-input-fill);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--platform-text-base);
font-size: 12px;
text-align: left;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button:focus-visible {
background: var(--platform-button-ghost-fill);
}
/* 语音钮与终止钮沿用控制排方钮尺寸这里补 `position: static` 等重置
免得继承文件里更早那条广播式 `button` 规则 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-voice-trigger,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-stop-button {
position: static !important;
right: auto !important;
bottom: auto !important;
display: grid;
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
border: 0;
border-radius: 8px;
flex: 0 0 28px;
place-items: center;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger {
background: transparent;
color: var(--platform-text-soft);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
/* 录音态:实心强调色 + 呼吸光环,配上 aria-pressed 让"正在录"一眼可见。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger.is-recording {
border-radius: 999px;
background: var(--platform-accent, #c7653d);
color: var(--platform-button-primary-text);
animation: composer-voice-recording-pulse 1.4s ease-in-out infinite;
}
@keyframes composer-voice-recording-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgb(199 101 61 / 45%);
}
50% {
box-shadow: 0 0 0 4px rgb(199 101 61 / 0%);
}
}
/* 终止钮:发送钮的圆角几何不变,只用中性填充区分"这一步在停止"而不是"发送"。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-stop-button {
border-radius: 999px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
gap: 6px;
}
/* 推理档紧挨模型选择器:控件位只放当前档位,选项在原生菜单里。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select {
max-width: 76px;
padding: 2px 4px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--platform-text-soft);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
/* 待发附件 / 队列 chip:压在输入区上方,横向排布、超出换行。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0 0 6px;
padding: 0;
list-style: none;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
li,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
li {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 6px;
border-radius: 999px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-soft);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
li
span,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
.project-supervisor-composer-queue-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
button {
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: inherit;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue-order {
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-notice {
margin: 4px 0 0;
color: var(--platform-text-soft);
font-size: 12px;
}
/* 消息列表的最终几何故意放在文件靠后的位置它与上面那条同选择器同权重
`.game-workbench-chat .project-supervisor-surface.is-direct-codex
.project-supervisor-message-list`"后写胜出"把两处会顶掉它的规则压回去
@@ -3,6 +3,7 @@ import { vi } from 'vitest';
import { registerAgentRuntimeCommandTests } from './appSurface/agent-runtime.suite';
import { registerAuthTests } from './appSurface/auth.suite';
import { registerChatComposerControlTests } from './appSurface/chat-composer.suite';
import { registerDesignAgentSurfaceTests } from './appSurface/design-agent.suite';
import {
registerDeveloperAgentWindowTests,
@@ -74,4 +75,5 @@ describe('AI 游戏创作 App 界面边界', () => {
registerCanvasAssetTests();
registerPlanGddApprovalTests();
registerDesignAgentSurfaceTests();
registerChatComposerControlTests();
});
File diff suppressed because it is too large Load Diff
@@ -8495,11 +8495,25 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(composer as HTMLElement).queryByText('launcher-codex-panel-game'),
).toBeNull();
expect(
// `+` 现在是"添加入口"(上传本地文件 / 引用项目素材),`@` 仍直接打开素材引用选择器。
fireEvent.click(
within(composer as HTMLElement).getByRole('button', {
name: '添加素材引用',
name: '添加文件',
}),
);
const attachmentMenu = within(composer as HTMLElement).getByRole('menu', {
name: '添加文件',
});
expect(
within(attachmentMenu).getByRole('menuitem', { name: '上传本地文件' }),
).not.toBeNull();
expect(
within(attachmentMenu).getByRole('menuitem', { name: '引用项目素材' }),
).not.toBeNull();
fireEvent.keyDown(document, { key: 'Escape' });
expect(
within(composer as HTMLElement).queryByRole('menu', { name: '添加文件' }),
).toBeNull();
expect(
within(composer as HTMLElement).getByRole('button', {
name: '插入素材引用',