From a766ff9634a24a72fa0f211dc4a34200cec51504 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:18:23 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BE=93=E5=85=A5=E7=9B=92=E5=AF=B9=E6=A0=87?= =?UTF-8?q?=20Codex=20=E8=A1=A5=E9=BD=90=E4=B8=8A=E4=BC=A0=E3=80=81?= =?UTF-8?q?=E7=BB=88=E6=AD=A2=E3=80=81=E9=98=9F=E5=88=97=E3=80=81=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E4=B8=8E=E6=8E=A8=E7=90=86=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件上传:+ 改成添加入口(上传本地文件 / 引用项目素材),上传走既有 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/取消、终止复位、上传后附件进提交、推理档写入回读、语音降级与录音态、追加不覆盖)并更新 + 与推理档相关的既有断言。 --- apps/ai-game-creator-shell/src/App.tsx | 283 ++++++++- .../app-shell/useHomeProjectCreation.ts | 84 ++- .../project-workspace/ComposerControls.tsx | 463 ++++++++++++++ .../ProjectSupervisorView.tsx | 93 ++- .../ResourceReferenceInput.tsx | 33 +- .../project-workspace/chatComposerQueue.ts | 89 +++ .../project-workspace/chatComposerVoice.ts | 144 +++++ .../composerReasoningEffort.ts | 48 ++ apps/ai-game-creator-shell/src/styles.css | 280 +++++++++ .../tests/appSurface.test.ts | 2 + .../tests/appSurface/chat-composer.suite.ts | 591 ++++++++++++++++++ .../appSurface/project-development.suite.ts | 18 +- 12 files changed, 2062 insertions(+), 66 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts create mode 100644 apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index fd12809a6..b1e65d5fc 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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([]); + /** + * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 + * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, + * 提交后即清空——后端协议不变。 + */ + const [chatAttachments, setChatAttachments] = useState< + DirectCodexTurnAttachment[] + >([]); + const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); + /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ + const [chatTurnQueue, setChatTurnQueue] = useState([]); + const chatTurnQueueRef = useRef([]); + chatTurnQueueRef.current = chatTurnQueue; + const [chatComposerNotice, setChatComposerNotice] = useState(''); + const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = + useState(false); + const queuedChatTurnSequenceRef = useRef(0); const chatComposerRef = useRef(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, ) { 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 ( void handleCancelDirectCodexTurn()} + onRemoveAttachment={removeChatComposerAttachment} + onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} + queuedTurns={chatTurnQueue} + turnCancelling={directCodexTurnCancelling} composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index dca3466fe..6b2078d3a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -87,6 +87,51 @@ async function suggestAutomaticProjectName( } } +/** + * 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。 + * + * 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后, + * 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。 + */ +export async function uploadLocalFilesAsAttachments( + invoke: TauriInvoke, + nextProjectPath: string, + files: readonly File[], +): Promise { + 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( + '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( - '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( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx new file mode 100644 index 000000000..ddf8bce7b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -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(null); + const fileInputRef = useRef(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 ( +
+ { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) { + onPickFiles(files); + } + }} + /> + + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + +/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */ +export function ComposerPendingAttachments({ + attachments, + onRemove, +}: { + attachments: readonly DirectCodexTurnAttachment[]; + onRemove: (index: number) => void; +}) { + if (attachments.length === 0) { + return null; + } + return ( +
    + {attachments.map((attachment, index) => ( +
  • + + {attachment.name} + + +
  • + ))} +
+ ); +} + +/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +export function ComposerTurnQueue({ + turns, + onCancel, +}: { + turns: readonly QueuedChatTurn[]; + onCancel: (id: string) => void; +}) { + if (turns.length === 0) { + return null; + } + return ( +
    + {turns.map((turn, index) => ( +
  1. + + {index + 1} + + + {queuedChatTurnLabel(turn)} + + +
  2. + ))} +
+ ); +} + +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 = 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(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 ( + + ); +} + +/** + * 推理强度:原生 select,紧挨模型选择器。读取/写回都走客户端配置通道 + * (`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)。 + */ +export function ComposerReasoningEffortSelect({ + disabled, +}: { + disabled: boolean; +}) { + const [effort, setEffort] = useState( + DEFAULT_COMPOSER_REASONING_EFFORT, + ); + const [saving, setSaving] = useState(false); + const [notice, setNotice] = useState(''); + const writeChainRef = useRef>(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('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('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 ( + + + {notice ? ( + + {notice} + + ) : null} + + ); +} + +/** 发送按钮位置在回合运行中显示的终止钮(Codex 的停止方块)。 */ +export function ComposerStopButton({ + cancelling, + onCancel, +}: { + cancelling: boolean; + onCancel: () => void; +}) { + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index d285bcb7e..30053197f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -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; onShowEarlierMessages: () => void; onSubmit: FormEventHandler; @@ -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('strict'); const [approvalNotice, setApprovalNotice] = useState(''); @@ -557,6 +592,14 @@ export function ProjectSupervisorView({ void validateModel(); }} > + onCancelQueuedTurn?.(id)} + /> + onRemoveAttachment?.(index)} + />
- + onPickFiles={(files) => onUploadFiles?.(files)} + onOpenReferencePicker={() => + composerRef?.current?.openPicker() + } + />
+ {/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端 + 配置,只影响后续回合;当前回合的行为不受影响。 */} + - {submitButton} + + composerRef?.current?.insertText(text) + } + onNotice={setVoiceNotice} + /> + {submitting && onCancelTurn ? ( + + ) : ( + submitButton + )}
) : ( submitButton )} + {directCodex && + (composerNotice || attachmentNotice || voiceNotice) ? ( +

+ {composerNotice || attachmentNotice || voiceNotice} +

+ ) : null} {directCodex ? null : ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 1971660b5..9283f7f4a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -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(() => { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts new file mode 100644 index 000000000..0ece3ae9e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -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 '未命名消息'; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts new file mode 100644 index 000000000..0d3a4f474 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts @@ -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; + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts b/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts new file mode 100644 index 000000000..e8c520b88 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts @@ -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 = { + 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), + })); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 57b125c85..128ccecfb 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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`),按"后写胜出"把两处会顶掉它的规则压回去—— diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 51f42dffe..8b15f73ce 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -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(); }); 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 new file mode 100644 index 000000000..e77141362 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -0,0 +1,591 @@ +import { + chatQueueFullNotice, + createQueuedChatTurn, + dequeueChatTurn, + enqueueChatTurn, + isChatTurnQueueFull, + removeQueuedChatTurn, +} from '../../src/features/project-workspace/chatComposerQueue'; +import { + appendDictationText, + resolveSpeechRecognitionCtor, + speechRecognitionErrorMessage, + type SpeechRecognitionEventLike, + type SpeechRecognitionLike, + VOICE_INPUT_UNSUPPORTED_MESSAGE, +} from '../../src/features/project-workspace/chatComposerVoice'; +import { ComposerVoiceButton } from '../../src/features/project-workspace/ComposerControls'; +import { + act, + createGameCreationAppManifest, + createProjectSupervisorRuntimeHarness, + expect, + fireEvent, + it, + pickProjectFromLauncher, + React, + render, + renderLauncherProjectsAt, + screen, + setComposerText, + vi, + waitFor, + within, +} from './harness'; + +const DYNAMIC_GAME_PROJECT_PATH = '/tmp/chat-composer-controls-game'; + +type InvokeOverrides = Record< + string, + (args: Record | undefined) => unknown +>; + +function gameCreatorConfigView(reasoningEffort: string) { + return { + path: '/tmp/chat-composer-config.json', + config: { + schemaVersion: 'game-creator-config.v2', + agentMode: 'codex_app_server', + selectedModelId: 'quality', + llm: { + apiKey: '', + baseUrl: '', + model: 'quality', + apiKind: 'openai_responses', + reasoningEffort, + stream: true, + webSearchEnabled: true, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' }, + }, + }; +} + +/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */ +async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath: DYNAMIC_GAME_PROJECT_PATH, + initialSessionExists: false, + }); + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '输入盒控件项目', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + const override = overrides[command]; + if (override) { + return override(args); + } + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath: DYNAMIC_GAME_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'chat-composer-controls', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') return manifest; + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); + const surface = await screen.findByLabelText('陶泥儿项目对话'); + return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; +} + +async function submitDirectTurn( + surface: HTMLElement, + composer: HTMLElement, + text: string, +) { + await setComposerText(composer, text); + fireEvent.click(within(surface).getByRole('button', { name: '发送' })); +} + +/** + * 回合运行中发送钮位置是终止钮,Enter 仍然提交表单(`requestSubmit`), + * 因此"运行中再次发送"走的是表单提交而不是那颗按钮。 + */ +function submitComposerForm(composer: HTMLElement) { + fireEvent.submit(composer.closest('form') as HTMLFormElement); +} + +type FakeSpeechRecognition = SpeechRecognitionLike & { + emitTranscript: (transcript: string) => void; +}; + +function installFakeSpeechRecognition(): { + instances: FakeSpeechRecognition[]; + restore: () => void; +} { + const instances: FakeSpeechRecognition[] = []; + function FakeSpeechRecognitionCtor(this: FakeSpeechRecognition) { + const instance = { + lang: '', + continuous: false, + interimResults: false, + maxAlternatives: 1, + onresult: null, + onerror: null, + onend: null, + start: vi.fn(), + stop: vi.fn(() => instance.onend?.()), + abort: vi.fn(), + emitTranscript: (transcript: string) => { + const event: SpeechRecognitionEventLike = { + resultIndex: 0, + results: { + length: 1, + 0: { isFinal: true, length: 1, 0: { transcript } }, + }, + }; + instance.onresult?.(event); + }, + } as unknown as FakeSpeechRecognition; + instances.push(instance); + return instance; + } + const scope = window as unknown as Record; + scope.webkitSpeechRecognition = FakeSpeechRecognitionCtor; + return { + instances, + restore: () => { + delete scope.webkitSpeechRecognition; + }, + }; +} + +export function registerChatComposerControlTests() { + it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { + const first = createQueuedChatTurn({ + id: 'turn-1', + prompt: '第一条', + createdAt: 1, + }); + const second = createQueuedChatTurn({ + id: 'turn-2', + prompt: '第二条', + createdAt: 2, + }); + const third = createQueuedChatTurn({ + id: 'turn-3', + prompt: '第三条', + createdAt: 3, + }); + + let queue = enqueueChatTurn([], first); + queue = enqueueChatTurn(queue, second); + queue = enqueueChatTurn(queue, third); + // 同一条消息重复入队不得变成两次发送。 + expect(enqueueChatTurn(queue, third)).toHaveLength(3); + + // FIFO:先入先出,不丢、不乱序。 + const firstOut = dequeueChatTurn(queue); + expect(firstOut.next?.prompt).toBe('第一条'); + expect(firstOut.rest.map((turn) => turn.prompt)).toEqual([ + '第二条', + '第三条', + ]); + + // 单条取消只移除那一条,顺序不变。 + expect( + removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => turn.prompt), + ).toEqual(['第三条']); + expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); + + // 空队列出队不报错、也不产生"幽灵消息"。 + expect(dequeueChatTurn([]).next).toBeNull(); + }); + + it('reports a readable reason instead of silently dropping a full queue', () => { + let queue: ReturnType[] = []; + for (let index = 0; index < 5; index += 1) { + queue = enqueueChatTurn( + queue, + createQueuedChatTurn({ + id: `turn-${index}`, + prompt: `第 ${index} 条`, + createdAt: index, + }), + ); + } + expect(isChatTurnQueueFull(queue)).toBe(true); + expect(chatQueueFullNotice()).toContain('队列已满'); + expect(chatQueueFullNotice()).toContain('5'); + expect(removeQueuedChatTurn(queue, 'turn-0')).toHaveLength(4); + }); + + it('degrades the voice input button with a readable hint when speech recognition is missing', async () => { + // jsdom 不提供 SpeechRecognition / webkitSpeechRecognition:必须禁用并说明原因。 + expect( + resolveSpeechRecognitionCtor(window as unknown as object), + ).toBeNull(); + expect( + resolveSpeechRecognitionCtor({ SpeechRecognition: undefined }), + ).toBeNull(); + + const onTranscript = vi.fn(); + const onNotice = vi.fn(); + render( + React.createElement(ComposerVoiceButton, { + disabled: false, + onTranscript, + onNotice, + }), + ); + + const button = screen.getByRole('button', { + name: VOICE_INPUT_UNSUPPORTED_MESSAGE, + }); + expect(button).toHaveProperty('disabled', true); + expect(button.getAttribute('title')).toBe(VOICE_INPUT_UNSUPPORTED_MESSAGE); + fireEvent.click(button); + expect(onTranscript).not.toHaveBeenCalled(); + }); + + it('marks the recording state and only appends dictated text', async () => { + const fake = installFakeSpeechRecognition(); + try { + const onTranscript = vi.fn(); + const onNotice = vi.fn(); + render( + React.createElement(ComposerVoiceButton, { + disabled: false, + onTranscript, + onNotice, + }), + ); + + const button = screen.getByRole('button', { name: '语音输入' }); + expect(button).toHaveProperty('disabled', false); + fireEvent.click(button); + + // 录音态有明确视觉/可访问性反馈。 + const recording = screen.getByRole('button', { name: '停止语音输入' }); + expect(recording.getAttribute('aria-pressed')).toBe('true'); + expect(recording.className).toContain('is-recording'); + expect(fake.instances).toHaveLength(1); + + act(() => { + fake.instances[0]?.emitTranscript('帮我做一个跳跃动作'); + }); + expect(onTranscript).toHaveBeenCalledWith('帮我做一个跳跃动作'); + + fireEvent.click(recording); + expect( + screen + .getByRole('button', { name: '语音输入' }) + .getAttribute('aria-pressed'), + ).toBe('false'); + } finally { + fake.restore(); + } + }); + + it('appends dictated text after the existing draft without overwriting it', () => { + expect(appendDictationText('', '帮我做一个跳跃动作')).toBe( + '帮我做一个跳跃动作', + ); + // 中文直接拼接,用户已输入的内容原样保留在识别结果之前。 + expect(appendDictationText('先做一个主菜单', '再补一个商店')).toBe( + '先做一个主菜单再补一个商店', + ); + // 英文识别结果补一个空格,避免两个单词粘在一起。 + expect(appendDictationText('add jump', 'dash')).toBe('add jump dash'); + expect(appendDictationText('先做一个主菜单', ' ')).toBe('先做一个主菜单'); + expect(speechRecognitionErrorMessage('not-allowed')).toContain( + '麦克风权限', + ); + expect(speechRecognitionErrorMessage('network')).toContain('语音识别服务'); + expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); + }); + + it('uploads a picked file into the project and attaches it to the next direct Codex turn', async () => { + const uploads: Record[] = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + upload_local_asset: (args) => { + uploads.push(args ?? {}); + return { + id: 'asset-upload-1', + localPath: 'assets/uploads/role-reference.png', + absolutePath: `${path}/assets/uploads/role-reference.png`, + manifestPath: `${path}/.agent/manifest.json`, + }; + }, + }); + + // `+` 现在是"添加入口":真正的上传路径与素材引用各占一条。 + fireEvent.click(within(surface).getByRole('button', { name: '添加文件' })); + const menu = within(surface).getByRole('menu', { name: '添加文件' }); + expect( + within(menu).getByRole('menuitem', { name: '上传本地文件' }), + ).not.toBeNull(); + expect( + within(menu).getByRole('menuitem', { name: '引用项目素材' }), + ).not.toBeNull(); + + const uploadInput = surface.querySelector( + '[data-chat-composer-upload]', + ); + expect(uploadInput).not.toBeNull(); + const file = new File(['png'], '角色参考.png', { + type: 'image/png', + lastModified: 1, + }); + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new Uint8Array([1, 2, 3]).buffer, + }); + fireEvent.change(uploadInput as HTMLInputElement, { + target: { files: [file] }, + }); + + expect(await within(surface).findByLabelText('待发送附件')).not.toBeNull(); + expect(uploads[0]?.fileName).toBe('角色参考.png'); + + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '按这个角色做游戏'); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath: path, + prompt: '按这个角色做游戏', + clientTurnId: expect.any(String), + attachments: [ + { + name: '角色参考.png', + mediaType: 'image/png', + size: file.size, + // 附件必须是项目相对路径:绝对路径会被 Rust 侧附件规则判为失败。 + localPath: 'assets/uploads/role-reference.png', + status: 'imported', + }, + ], + }, + ); + }); + // 提交后 chip 清空,同一批附件不会重复挂到下一轮。 + await waitFor(() => { + expect(within(surface).queryByLabelText('待发送附件')).toBeNull(); + }); + }); + + it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { + const pending: Array<{ + resolve: (value: string) => void; + reject: (error: Error) => void; + }> = []; + const { invoke, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve, reject) => { + pending.push({ resolve, reject }); + }), + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '第一条消息'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '第一条消息' }), + ); + }); + // 回合运行中:发送钮位置变成终止钮。 + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + + await setComposerText(composer, '第二条消息'); + submitComposerForm(composer); + await waitFor(() => { + expect(within(surface).getByText('第二条消息')).not.toBeNull(); + }); + await setComposerText(composer, '第三条消息'); + submitComposerForm(composer); + + const queue = await within(surface).findByLabelText('待发送消息队列'); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(2); + }); + expect( + within(queue) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual([ + expect.stringContaining('第二条消息'), + expect.stringContaining('第三条消息'), + ]); + + // 单条取消:只移除第二条,第三条保留。 + fireEvent.click( + within(queue).getByRole('button', { name: '取消排队消息 第二条消息' }), + ); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(1); + }); + + act(() => { + pending[0]?.resolve('第一条回复'); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '第三条消息' }), + ); + }); + // 被取消的那条不会再发出去,队列顺序也不乱。 + expect( + invoke.mock.calls.filter( + ([, args]) => + (args as { prompt?: string } | undefined)?.prompt === '第二条消息', + ), + ).toHaveLength(0); + const prompts = invoke.mock.calls + .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .map(([, args]) => (args as { prompt?: string }).prompt); + expect(prompts).toEqual(['第一条消息', '第三条消息']); + + act(() => { + pending[1]?.resolve('第三条回复'); + }); + await waitFor(() => { + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); + }); + }); + + it('terminates the running turn and returns the composer to the idle state', async () => { + const pending: Array<{ + resolve: (value: string) => void; + reject: (error: Error) => void; + }> = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve, reject) => { + pending.push({ resolve, reject }); + }), + cancel_direct_codex_turn: async () => undefined, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '做一个小游戏'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '做一个小游戏' }), + ); + }); + + const stopButton = await within(surface).findByRole('button', { + name: '终止', + }); + fireEvent.click(stopButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { + projectPath: path, + clientTurnId: expect.any(String), + }); + }); + + // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 + act(() => { + pending[0]?.reject(new Error('Codex app-server turn 已中断')); + }); + await waitFor(() => { + const send = within(surface).getByRole('button', { name: '发送' }); + expect(send).toHaveProperty('disabled', false); + }); + expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); + expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); + }); + + it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { + let stored = 'high'; + const { invoke, surface } = await openDirectCodexSurface({ + select_game_creator_reasoning_effort: (args) => { + stored = String(args?.effort ?? ''); + return gameCreatorConfigView(stored); + }, + }); + + const select = (await within(surface).findByLabelText( + '推理档', + )) as HTMLSelectElement; + await waitFor(() => { + expect(select.value).toBe('high'); + }); + // 档位就在模型选择器这一排(同一控制排容器里)。 + expect( + within(surface).getByRole('button', { name: '对话模型' }), + ).not.toBeNull(); + expect( + select.closest('.project-supervisor-composer-controls'), + ).not.toBeNull(); + + fireEvent.change(select, { target: { value: 'low' } }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'select_game_creator_reasoning_effort', + { effort: 'low' }, + ); + }); + // 以落盘后的回读值为准。 + await waitFor(() => { + expect( + (within(surface).getByLabelText('推理档') as HTMLSelectElement).value, + ).toBe('low'); + }); + expect(stored).toBe('low'); + // 只影响后续回合:当前没有发出任何新一轮直接对话。 + expect( + invoke.mock.calls.some( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toBe(false); + }); + + it('appends the dictated transcript after the draft typed into the composer', async () => { + const fake = installFakeSpeechRecognition(); + try { + const { surface } = await openDirectCodexSurface(); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await setComposerText(composer, '已经写好的需求'); + + fireEvent.click( + within(surface).getByRole('button', { name: '语音输入' }), + ); + await waitFor(() => { + expect(fake.instances).toHaveLength(1); + }); + act(() => { + fake.instances[0]?.emitTranscript('再补一个跳跃动作'); + }); + + await waitFor(() => { + expect(composer.textContent).toContain('已经写好的需求'); + expect(composer.textContent).toContain('再补一个跳跃动作'); + }); + } finally { + fake.restore(); + } + }); +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 5f9601540..081c915e8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -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: '插入素材引用',