输入盒对标 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
+81
-12
@@ -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">
|
||||
|
||||
+32
-1
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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: '插入素材引用',
|
||||
|
||||
Reference in New Issue
Block a user