合并 feat/chat-composer-controls:输入盒对标 Codex 的 5 项能力

- 合并分支:feat/chat-composer-controls(基线 a60c04b53,4 笔提交)
- 冲突文件 apps/ai-game-creator-shell/tests/appSurface.test.ts:两条分支各自注册了自己的新用例集(工具调用折叠块 / 输入盒能力),属纯追加,两侧都保留
- 内容:文件上传(+ 弹层:上传本地文件 / 引用项目素材,走既有 upload_local_asset + 回合附件)、终止(新增 cancel_direct_codex_turn + 前端停止钮)、队列(纯前端 FIFO,含取消)、语音输入(无 SpeechRecognition 时禁用降级)、推理档下移到输入盒并从设置面板移除(新增 select_game_creator_reasoning_effort)
This commit is contained in:
2026-09-15 19:06:41 +08:00
18 changed files with 2298 additions and 95 deletions
@@ -35,7 +35,8 @@ mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
direct_game_creator_home_codex_chat,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -216,6 +216,12 @@ impl CodexTurnStartCancellation {
self.maybe_interrupt();
}
/// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须
/// 明确报错,而不是静默成功让界面以为回合已经停了。
fn app_server_alive(&self) -> bool {
self.inner.strong_count() > 0
}
fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.maybe_interrupt();
@@ -2752,6 +2758,17 @@ impl CodexAppServerConnection {
}
let turn_start_cancellation =
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
let _active_turn_guard = direct_tool_call_turn_id
.as_deref()
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.map(|turn_id| {
register_active_direct_codex_turn(
direct_codex_active_turn_key(history_root),
turn_id,
Arc::clone(&turn_start_cancellation),
)
});
let mut turn_start_guard = CodexTurnStartGuard {
cancellation: Arc::clone(&turn_start_cancellation),
armed: true,
@@ -3059,6 +3076,135 @@ impl Drop for CodexTurnStartGuard {
}
}
/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。
struct DirectCodexActiveTurnTable<T> {
entries: HashMap<std::path::PathBuf, (String, T)>,
}
impl<T> DirectCodexActiveTurnTable<T> {
fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) {
self.entries
.insert(key, (client_turn_id.to_string(), value));
}
/// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。
fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) {
if self
.entries
.get(key)
.is_some_and(|(_, value)| is_same(value))
{
self.entries.remove(key);
}
}
/// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回
/// 可读原因,绝不误伤另一个回合。
fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> {
let active = self
.entries
.get(key)
.ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?;
if let Some(expected) = client_turn_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if active.0 != expected {
return Err("正在运行的是另一个陶泥儿回合,已拒绝终止".to_string());
}
}
Ok(active)
}
}
/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。
///
/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出
/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由
/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。
static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock<
std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>>,
> = OnceLock::new();
fn direct_codex_active_turns(
) -> &'static std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>> {
GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS
.get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new()))
}
/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀),
/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。
fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf {
let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
match canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
{
Some(stripped) => std::path::PathBuf::from(stripped),
None => canonical,
}
}
struct DirectCodexActiveTurnGuard {
key: std::path::PathBuf,
cancellation: Arc<CodexTurnStartCancellation>,
}
impl Drop for DirectCodexActiveTurnGuard {
fn drop(&mut self) {
let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else {
return;
};
let Ok(mut entries) = active_turns.lock() else {
return;
};
let cancellation = Arc::clone(&self.cancellation);
entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation));
}
}
/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。
fn register_active_direct_codex_turn(
key: std::path::PathBuf,
client_turn_id: &str,
cancellation: Arc<CodexTurnStartCancellation>,
) -> DirectCodexActiveTurnGuard {
if let Ok(mut entries) = direct_codex_active_turns().lock() {
entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation));
}
DirectCodexActiveTurnGuard { key, cancellation }
}
/// 终止当前项目正在运行的 Direct 回合。
///
/// 只向正在跑的 Codex app-server 回合发 `turn/interrupt`app-server 随后回
/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回),
/// 不动任何既有事件或命令语义。
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
) -> Result<(), String> {
let key = direct_codex_active_turn_key(root);
let cancellation = {
let entries = direct_codex_active_turns()
.lock()
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
let (_, cancellation) = entries.select(&key, client_turn_id)?;
if !cancellation.app_server_alive() {
return Err("陶泥儿执行进程已退出,无法终止本轮;请重新发送这条消息".to_string());
}
Arc::clone(cancellation)
};
cancellation.cancel();
Ok(())
}
struct CodexThreadLease {
connection: CodexAppServerConnection,
key: CodexNodeThreadKey,
@@ -4071,6 +4217,52 @@ pub(crate) fn build_direct_codex_history_prompt(
mod tests {
use super::*;
/// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要
/// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。
#[test]
fn direct_codex_active_turn_table_selects_only_the_running_turn() {
let mut table: DirectCodexActiveTurnTable<u8> = DirectCodexActiveTurnTable::new();
let key = std::path::PathBuf::from("C:/projects/direct-turn-demo");
assert_eq!(
table.select(&key, None).expect_err("no active turn"),
"当前项目没有正在运行的陶泥儿回合,无法终止"
);
table.register(key.clone(), "turn-a", 1);
assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a");
assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1);
assert_eq!(
table
.select(&key, Some("turn-b"))
.expect_err("another running turn"),
"正在运行的是另一个陶泥儿回合,已拒绝终止"
);
// 句柄已被后来的回合替换:旧回合收尾不得注销新回合。
table.register(key.clone(), "turn-b", 2);
table.unregister(&key, |value| *value == 1);
assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b");
table.unregister(&key, |value| *value == 2);
assert!(table.select(&key, None).is_err());
}
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
#[test]
fn direct_codex_active_turn_key_normalizes_windows_prefix() {
let root = tempfile::tempdir().expect("temp dir");
let canonical = std::fs::canonicalize(root.path()).expect("canonical root");
let expected = canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(std::path::PathBuf::from)
.unwrap_or(canonical);
let key = direct_codex_active_turn_key(root.path());
assert_eq!(key, expected);
// 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。
assert!(!key.to_string_lossy().starts_with("\\\\?\\"));
}
#[test]
fn direct_item_activities_are_closed_safe_categories() {
let allowed = [
@@ -1989,6 +1989,29 @@ pub(crate) fn write_game_creator_app_config(
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn cancel_direct_codex_turn(
project_path: String,
client_turn_id: Option<String>,
) -> Result<(), String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "agent.kill")?;
cancel_direct_codex_turn_at(root, client_turn_id.as_deref())
}
#[tauri::command]
pub(crate) fn select_game_creator_reasoning_effort(
effort: String,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.llm.reasoning_effort = effort;
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
model_id: String,
@@ -2632,6 +2632,8 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,
+277 -17
View File
@@ -148,6 +148,7 @@ import {
type WorkspaceLauncherProps,
writeRecentWorkspace,
} from './features/app-shell/model';
import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation';
import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher';
import {
agentConversationReadDraftsFromManifest,
@@ -220,6 +221,15 @@ import {
isMissingProjectFileError,
parseAgentRunTrace,
} from './features/project-workspace/agentRunTrace';
import {
chatQueueFullNotice,
createQueuedChatTurn,
dequeueChatTurn,
enqueueChatTurn,
isChatTurnQueueFull,
type QueuedChatTurn,
removeQueuedChatTurn,
} from './features/project-workspace/chatComposerQueue';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
@@ -472,6 +482,8 @@ function directCodexTurnIdFromAssistantMessageId(messageId: string) {
);
}
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -479,6 +491,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
.startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX);
}
/**
* "终止" await app-server
* `Codex app-server turn 已中断`
* "已终止本次回合"
*/
export function isDirectCodexTurnInterruptedError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message.includes('turn 已中断') || message.includes('已终止本次回合');
}
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
if (!message.runtimeOwned) {
return false;
@@ -747,7 +769,35 @@ 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);
/**
* `localPath` ****
* A B
*/
useEffect(() => {
setChatAttachments([]);
setChatAttachmentNotice('');
setChatComposerNotice('');
setChatTurnQueue([]);
chatTurnQueueRef.current = [];
}, [localProject?.projectPath]);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [directCodexStatus, setDirectCodexStatus] = useState<
@@ -6462,6 +6512,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 +6549,7 @@ export function App({
}
} finally {
setChatAgentBusy(false);
setDirectCodexTurnCancelling(false);
setDirectCodexProgress('');
const activeTurn = activeDirectCodexTurnRef.current;
if (
@@ -6496,6 +6559,8 @@ export function App({
) {
resetDirectCodexTurn();
}
// 队列:本回合确实结束后,按 FIFO 自动发出下一条(不丢、不乱序)。
dispatchNextQueuedChatTurn();
}
}
return;
@@ -11733,12 +11798,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 +11988,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 +12042,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 +12064,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 +12130,17 @@ export function App({
return (
<ProjectSupervisorView
activeVersionId={chatActiveVersionId}
attachments={chatAttachments}
attachmentNotice={chatAttachmentNotice}
chatInput={chatInput}
chatReferences={chatReferences}
composerNotice={chatComposerNotice}
onCancelQueuedTurn={cancelQueuedChatTurn}
onCancelTurn={() => void handleCancelDirectCodexTurn()}
onRemoveAttachment={removeChatComposerAttachment}
onUploadFiles={(files) => void handleChatComposerUploadFiles(files)}
queuedTurns={chatTurnQueue}
turnCancelling={directCodexTurnCancelling}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
@@ -87,6 +87,51 @@ async function suggestAutomaticProjectName(
}
}
/**
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
*
* 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后,
* 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。
*/
export async function uploadLocalFilesAsAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
files: readonly File[],
): Promise<LauncherImportedAttachment[]> {
const imported: LauncherImportedAttachment[] = [];
for (const file of files) {
const mediaType = file.type || 'application/octet-stream';
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: file.size,
});
} catch (error) {
imported.push({
fileName: file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: file.size,
});
}
}
return imported;
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -245,40 +290,11 @@ export function useHomeProjectCreation({
nextProjectPath: string,
attachments: HomeAttachmentDraft[],
) {
const imported: LauncherImportedAttachment[] = [];
for (const attachment of attachments) {
const mediaType = attachment.file.type || 'application/octet-stream';
try {
const bytes = Array.from(
new Uint8Array(await attachment.file.arrayBuffer()),
);
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: attachment.file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: attachment.file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: attachment.file.size,
});
} catch (error) {
imported.push({
fileName: attachment.file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: attachment.file.size,
});
}
}
return imported;
return uploadLocalFilesAsAttachments(
invoke,
nextProjectPath,
attachments.map((attachment) => attachment.file),
);
}
async function enterCreatedHomeProject(
@@ -0,0 +1,463 @@
/**
* 输入盒控件(Codex 观感):左 `+`(上传本地文件 / 引用项目素材)、右侧推理强度 +
* 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。
*
* 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`
* 队列由 `chatComposerQueue.ts` 的纯函数维护。
*/
import { FileUp, Images, Mic, MicOff, Plus, Square, X } from 'lucide-react';
import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
GameCreatorAppConfigView,
GameCreatorLlmReasoningEffort,
} from '../../app/types';
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { QueuedChatTurn } from './chatComposerQueue';
import { queuedChatTurnLabel } from './chatComposerQueue';
import {
resolveSpeechRecognitionCtor,
speechEventTranscript,
type SpeechRecognitionCtor,
speechRecognitionErrorMessage,
speechRecognitionLang,
type SpeechRecognitionLike,
VOICE_INPUT_UNSUPPORTED_MESSAGE,
} from './chatComposerVoice';
import {
composerReasoningEffortOptions,
DEFAULT_COMPOSER_REASONING_EFFORT,
normalizeComposerReasoningEffort,
} from './composerReasoningEffort';
type ComposerAttachmentMenuProps = {
disabled: boolean;
onPickFiles: (files: readonly File[]) => void;
onOpenReferencePicker: () => void;
};
/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */
export function ComposerAttachmentMenu({
disabled,
onPickFiles,
onOpenReferencePicker,
}: ComposerAttachmentMenuProps) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
function handleOutsidePointerDown(event: MouseEvent) {
const target = event.target as Node | null;
if (anchorRef.current && !anchorRef.current.contains(target)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsidePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsidePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
return (
<div
ref={anchorRef}
className="project-supervisor-attachment-anchor"
data-composer-attachment-menu={open ? 'open' : 'closed'}
>
<input
ref={fileInputRef}
type="file"
multiple
className="project-supervisor-upload-input"
data-chat-composer-upload="true"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPickFiles(files);
}
}}
/>
<button
type="button"
className="project-supervisor-attachment-trigger"
aria-label="添加文件"
title="添加文件"
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
>
<Plus size={15} aria-hidden="true" />
</button>
{open ? (
<div
className="project-supervisor-attachment-menu"
role="menu"
aria-label="添加文件"
>
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
fileInputRef.current?.click();
}}
>
<FileUp size={14} aria-hidden="true" />
<span></span>
</button>
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
onOpenReferencePicker();
}}
>
<Images size={14} aria-hidden="true" />
<span></span>
</button>
</div>
) : null}
</div>
);
}
/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */
export function ComposerPendingAttachments({
attachments,
onRemove,
}: {
attachments: readonly DirectCodexTurnAttachment[];
onRemove: (index: number) => void;
}) {
if (attachments.length === 0) {
return null;
}
return (
<ul
className="project-supervisor-composer-attachments"
aria-label="待发送附件"
>
{attachments.map((attachment, index) => (
<li
key={`${attachment.name}-${index}`}
data-attachment-status={attachment.status ?? 'ready'}
>
<span title={attachment.localPath ?? attachment.name}>
{attachment.name}
</span>
<button
type="button"
aria-label={`移除附件 ${attachment.name}`}
title={`移除附件 ${attachment.name}`}
onClick={() => onRemove(index)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
</ul>
);
}
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({
turns,
onCancel,
}: {
turns: readonly QueuedChatTurn[];
onCancel: (id: string) => void;
}) {
if (turns.length === 0) {
return null;
}
return (
<ol
className="project-supervisor-composer-queue"
aria-label="待发送消息队列"
>
{turns.map((turn, index) => (
<li key={turn.id} data-queue-index={index}>
<span className="project-supervisor-composer-queue-order">
{index + 1}
</span>
<span className="project-supervisor-composer-queue-text">
{queuedChatTurnLabel(turn)}
</span>
<button
type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
title="取消这条排队消息"
onClick={() => onCancel(turn.id)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
</ol>
);
}
type ComposerVoiceButtonProps = {
disabled: boolean;
onTranscript: (text: string) => void;
onNotice: (message: string) => void;
};
/**
* 麦克风:只在运行时确实提供 SpeechRecognition 时可用;否则按钮禁用并直接说明原因
* aria-label/title 都是那句提示,不假装能用)。录音态用 `is-recording` 做视觉反馈。
*/
export function ComposerVoiceButton({
disabled,
onTranscript,
onNotice,
}: ComposerVoiceButtonProps) {
const ctorRef: RefObject<SpeechRecognitionCtor | null> = useRef(
resolveSpeechRecognitionCtor(
typeof window === 'undefined' ? null : (window as unknown as object),
),
);
const ctor = ctorRef.current;
const supported = Boolean(ctor);
const [recording, setRecording] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const onTranscriptRef = useRef(onTranscript);
const onNoticeRef = useRef(onNotice);
useEffect(() => {
onTranscriptRef.current = onTranscript;
onNoticeRef.current = onNotice;
}, [onNotice, onTranscript]);
useEffect(
() => () => {
recognitionRef.current?.abort?.();
recognitionRef.current = null;
},
[],
);
const unsupportedHint = VOICE_INPUT_UNSUPPORTED_MESSAGE;
const activeLabel = recording ? '停止语音输入' : '语音输入';
function startRecognition() {
if (!ctor) {
onNoticeRef.current(unsupportedHint);
return;
}
try {
const recognition = new ctor();
recognition.lang = speechRecognitionLang(navigator?.language);
recognition.continuous = true;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
const transcript = speechEventTranscript(event);
if (transcript) {
onTranscriptRef.current(transcript);
}
};
recognition.onerror = (event) => {
const message = speechRecognitionErrorMessage(event?.error);
setRecording(false);
if (message) {
onNoticeRef.current(message);
}
};
recognition.onend = () => {
setRecording(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
setRecording(true);
} catch (error) {
recognitionRef.current = null;
setRecording(false);
onNoticeRef.current(
error instanceof Error && error.message
? error.message
: '语音输入启动失败,请稍后重试',
);
}
}
return (
<button
type="button"
className={`project-supervisor-voice-trigger${recording ? ' is-recording' : ''}`}
aria-label={supported ? activeLabel : unsupportedHint}
title={supported ? activeLabel : unsupportedHint}
aria-pressed={supported ? recording : undefined}
disabled={disabled || !supported}
onClick={() => {
if (recording) {
recognitionRef.current?.stop();
setRecording(false);
return;
}
startRecognition();
}}
>
{supported ? (
<Mic size={15} aria-hidden="true" />
) : (
<MicOff size={15} aria-hidden="true" />
)}
</button>
);
}
/**
* 推理强度:原生 select,紧挨模型选择器。读取/写回都走客户端配置通道
* `read_game_creator_app_config` / `select_game_creator_reasoning_effort`)。
*/
export function ComposerReasoningEffortSelect({
disabled,
}: {
disabled: boolean;
}) {
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
DEFAULT_COMPOSER_REASONING_EFFORT,
);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
const mountedRef = useRef(true);
const options = composerReasoningEffortOptions();
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
let cancelled = false;
const invoke = resolveTauriInvoke();
if (!invoke) {
return undefined;
}
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
.then((view) => {
if (cancelled || !mountedRef.current) return;
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
);
})
.catch(() => {
if (cancelled || !mountedRef.current) return;
setNotice('推理档读取失败');
});
return () => {
cancelled = true;
};
}, []);
function selectEffort(next: GameCreatorLlmReasoningEffort) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice('需要在 Tauri App 内运行');
return;
}
const previous = effort;
setEffort(next);
setNotice('');
setSaving(true);
const write = () =>
invoke<GameCreatorAppConfigView>('select_game_creator_reasoning_effort', {
effort: next,
});
const run = writeChainRef.current.then(write, write);
writeChainRef.current = run.then(
() => undefined,
() => undefined,
);
void run
.then((view) => {
if (!mountedRef.current) return;
// 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
);
})
.catch(() => {
if (!mountedRef.current) return;
setEffort(previous);
setNotice('推理档保存失败');
})
.finally(() => {
if (mountedRef.current) {
setSaving(false);
}
});
}
return (
<span className="project-supervisor-reasoning-effort">
<select
aria-label="推理档"
className="project-supervisor-reasoning-effort-select"
value={effort}
disabled={disabled || saving}
onChange={(event) =>
selectEffort(
normalizeComposerReasoningEffort(event.currentTarget.value),
)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{notice ? (
<span role="status" className="project-supervisor-composer-notice">
{notice}
</span>
) : null}
</span>
);
}
/** 发送按钮位置在回合运行中显示的终止钮(Codex 的停止方块)。 */
export function ComposerStopButton({
cancelling,
onCancel,
}: {
cancelling: boolean;
onCancel: () => void;
}) {
return (
<button
type="button"
className="project-supervisor-submit-button project-supervisor-stop-button"
aria-label={cancelling ? '正在终止' : '终止'}
title={cancelling ? '正在终止' : '终止当前回合'}
disabled={cancelling}
onClick={onCancel}
>
<Square size={14} aria-hidden="true" />
</button>
);
}
@@ -3,7 +3,6 @@ import {
AtSign,
Loader2,
MessageSquareDashed,
Plus,
Settings,
} from 'lucide-react';
import type {
@@ -39,8 +38,18 @@ import {
ProjectSupervisorRuntimePanel,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerAttachmentMenu,
ComposerPendingAttachments,
ComposerReasoningEffortSelect,
ComposerStopButton,
ComposerTurnQueue,
ComposerVoiceButton,
} from './ComposerControls';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
@@ -103,6 +112,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[];
@@ -119,6 +132,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>;
@@ -160,6 +184,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
export function ProjectSupervisorView({
activeVersionId = null,
attachments = [],
attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets,
@@ -176,6 +202,13 @@ export function ProjectSupervisorView({
onChatInputChange,
onConfirmConfirmation,
onConfirmPendingCommand,
onCancelTurn,
onCancelQueuedTurn,
onRemoveAttachment,
onUploadFiles,
queuedTurns = [],
composerNotice = '',
turnCancelling = false,
onScroll,
onShowEarlierMessages,
onSubmit,
@@ -228,6 +261,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('');
@@ -589,6 +624,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 ? '陶泥儿对话内容' : '项目需求'}
@@ -597,7 +640,9 @@ export function ProjectSupervisorView({
assets={chatProjectAssets}
projectPath={projectPath}
disabled={
runtimePanelProps.controlBusy ||
// direct-codex 回合运行中输入区保持可编辑:用户能继续写下一条消息进本地队列,
// 回合结束后(Enter 提交)自动依次发出。其它面板维持"运行中不接受输入"。
(runtimePanelProps.controlBusy && !directCodex) ||
needsUserInput ||
modelValidating ||
Boolean(designView?.session.pendingApproval) ||
@@ -617,16 +662,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"
@@ -639,6 +681,9 @@ export function ProjectSupervisorView({
</button>
</div>
<div className="project-supervisor-composer-controls-right">
{/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端
配置,只影响后续回合;当前回合的行为不受影响。 */}
<ComposerReasoningEffortSelect disabled={needsUserInput} />
<ConversationModelSelect
ref={modelSelectRef}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
@@ -647,12 +692,36 @@ export function ProjectSupervisorView({
onReady={setModelReady}
projectPath={projectPath}
/>
{submitButton}
<ComposerVoiceButton
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
modelValidating
}
onTranscript={(text) =>
composerRef?.current?.insertText(text)
}
onNotice={setVoiceNotice}
/>
{submitting && onCancelTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
onCancel={onCancelTurn}
/>
) : (
submitButton
)}
</div>
</div>
) : (
submitButton
)}
{directCodex &&
(composerNotice || attachmentNotice || voiceNotice) ? (
<p className="project-supervisor-composer-notice" role="status">
{composerNotice || attachmentNotice || voiceNotice}
</p>
) : null}
</form>
{directCodex ? null : (
<small className="project-supervisor-workspace-status">
@@ -156,6 +156,8 @@ function createResourcePickerScopeStates(): Record<
export type ResourceReferenceInputHandle = {
insertReferences: (references: ChatReference[]) => void;
/** 追加纯文本(语音识别结果):写在当前光标处,且不覆盖用户已输入的内容。 */
insertText: (text: string) => void;
openPicker: () => void;
focus: () => void;
};
@@ -569,14 +571,43 @@ function ResourceReferenceEditor({
setPickerOpen(true);
}, []);
const insertText = useCallback(
(text: string) => {
const insert = text.replace(/\s+$/u, '');
if (!insert.trim()) return;
editor.update(() => {
let selection = $getSelection();
// 选区失效(跨会话恢复草稿后常见)时回落到草稿末尾,与 `insertReferences` 同口径。
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
// 认领的光标处的已有内容保持不变:这里只插入,不删除任何节点。
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) {
selection.insertText(' ');
}
selection.insertText(insert);
}
});
editor.focus();
},
[editor],
);
useImperativeHandle(
composerRef,
() => ({
insertReferences,
insertText,
openPicker,
focus: () => editor.focus(),
}),
[editor, insertReferences, openPicker],
[editor, insertReferences, insertText, openPicker],
);
useEffect(() => {
@@ -0,0 +1,89 @@
/**
* 输入盒本地消息队列(纯前端状态,不改后端协议)。
*
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { ChatReference } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
prompt: string;
attachments?: readonly DirectCodexTurnAttachment[];
references?: readonly ChatReference[];
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
prompt: input.prompt,
attachments: [...(input.attachments ?? [])],
references: [...(input.references ?? [])],
createdAt: input.createdAt,
};
}
/** 队尾追加。同一 id 已在队列里时原样返回,避免重复入队把同一条消息发两遍。 */
export function enqueueChatTurn(
queue: readonly QueuedChatTurn[],
turn: QueuedChatTurn,
): QueuedChatTurn[] {
if (queue.some((item) => item.id === turn.id)) {
return [...queue];
}
return [...queue, turn];
}
/** 取队首(FIFO)。队列为空时 `next` 为 `null``rest` 保持空数组。 */
export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): {
next: QueuedChatTurn | null;
rest: QueuedChatTurn[];
} {
if (queue.length === 0) {
return { next: null, rest: [] };
}
const [next, ...rest] = queue;
return { next: next ?? null, rest };
}
/** 单条取消:按 id 移除,其余项保持原有顺序。 */
export function removeQueuedChatTurn(
queue: readonly QueuedChatTurn[],
id: string,
): QueuedChatTurn[] {
return queue.filter((item) => item.id !== id);
}
export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean {
return queue.length >= MAX_QUEUED_CHAT_TURNS;
}
export function chatQueueFullNotice(): string {
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
}
/** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text;
}
if (turn.attachments.length > 0) {
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
}
if (turn.references.length > 0) {
return '素材引用';
}
return '未命名消息';
}
@@ -0,0 +1,144 @@
/**
* 输入盒语音输入:只做能力探测、文本拼接与错误文案,不依赖 React。
*
* WebView 是否提供 `SpeechRecognition` / `webkitSpeechRecognition` 由运行时决定;
* 拿不到构造器时必须禁用按钮并给出可读提示,不允许"看起来能用"。
*/
export const VOICE_INPUT_UNSUPPORTED_MESSAGE = '当前运行环境不支持语音输入';
export const VOICE_INPUT_PERMISSION_MESSAGE =
'语音输入未获得麦克风权限,请在系统设置中允许后重试';
export const VOICE_INPUT_SERVICE_MESSAGE =
'当前运行环境的语音识别服务不可用,请改用键盘输入';
export const VOICE_INPUT_DEFAULT_ERROR_MESSAGE = '语音输入失败,请稍后重试';
export type SpeechRecognitionAlternativeLike = {
transcript: string;
};
export type SpeechRecognitionResultLike = {
isFinal: boolean;
length: number;
[index: number]: SpeechRecognitionAlternativeLike;
};
export type SpeechRecognitionResultListLike = {
length: number;
[index: number]: SpeechRecognitionResultLike;
};
export type SpeechRecognitionEventLike = {
resultIndex: number;
results: SpeechRecognitionResultListLike;
};
export type SpeechRecognitionErrorEventLike = {
error?: string;
message?: string;
};
export type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
};
export type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
type SpeechScope = {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
/** 取当前运行时的语音识别构造器;两个厂商前缀都没有时返回 `null`(降级)。 */
export function resolveSpeechRecognitionCtor(
scope: unknown,
): SpeechRecognitionCtor | null {
if (!scope || typeof scope !== 'object') {
return null;
}
const candidate = scope as SpeechScope;
return (
candidate.SpeechRecognition ?? candidate.webkitSpeechRecognition ?? null
);
}
export function speechRecognitionLang(locale: string | undefined): string {
const normalized = locale?.trim();
if (!normalized) {
return 'zh-CN';
}
return normalized;
}
/**
* 识别文本追加到输入框已有内容之后——**不覆盖**用户已经输入的部分。
*
* 中文/日文这类无空格语言直接拼接;纯 ASCII 字母数字开头的识别结果补一个空格,
* 避免英文单词粘在一起。
*/
export function appendDictationText(
current: string,
transcript: string,
): string {
const text = transcript.trim();
if (!text) {
return current;
}
if (!current) {
return text;
}
if (/\s$/u.test(current)) {
return `${current}${text}`;
}
return /^[A-Za-z0-9]/u.test(text)
? `${current} ${text}`
: `${current}${text}`;
}
/** 识别结果的最终文本(同一轮里可能有多段 interim,取本次事件里的最终段)。 */
export function speechEventTranscript(
event: SpeechRecognitionEventLike,
): string {
let transcript = '';
for (
let index = event.resultIndex;
index < event.results.length;
index += 1
) {
const result = event.results[index];
if (!result) continue;
const alternative = result[0];
if (alternative?.transcript) {
transcript += alternative.transcript;
}
}
return transcript;
}
export function speechRecognitionErrorMessage(
error: string | undefined,
): string {
switch (error?.trim()) {
case 'not-allowed':
case 'service-not-allowed':
return VOICE_INPUT_PERMISSION_MESSAGE;
case 'network':
return VOICE_INPUT_SERVICE_MESSAGE;
case 'audio-capture':
return '未检测到可用的麦克风设备';
case 'no-speech':
return '没有识别到语音,请重试';
case 'aborted':
return '';
default:
return VOICE_INPUT_DEFAULT_ERROR_MESSAGE;
}
}
@@ -0,0 +1,48 @@
/**
* 输入盒推理强度控件:把运行配置里的 `llm.reasoningEffort` 搬到输入盒这一排。
*
* 配置写入走既有客户端配置通道,只影响**后续**回合:每个回合开始时 Rust 侧都会
* 重新读取一次客户端配置,因此改档不会改变正在跑的回合。
*/
import type { GameCreatorLlmReasoningEffort } from '../../app/types';
import { gameCreatorLlmReasoningEfforts } from '../../app/types';
export const DEFAULT_COMPOSER_REASONING_EFFORT: GameCreatorLlmReasoningEffort =
'default';
const reasoningEffortLabels: Record<GameCreatorLlmReasoningEffort, string> = {
default: '默认',
low: '低',
medium: '中',
high: '高',
max: '最高',
};
export function reasoningEffortLabel(
effort: GameCreatorLlmReasoningEffort,
): string {
return reasoningEffortLabels[effort];
}
/** 配置值可能是缺字段 / 大小写不一致;只有契约内的档位才认,其余回落默认档。 */
export function normalizeComposerReasoningEffort(
value: unknown,
): GameCreatorLlmReasoningEffort {
if (typeof value !== 'string') {
return DEFAULT_COMPOSER_REASONING_EFFORT;
}
const normalized = value.trim().toLowerCase();
return gameCreatorLlmReasoningEfforts.find((effort) => effort === normalized)
? (normalized as GameCreatorLlmReasoningEffort)
: DEFAULT_COMPOSER_REASONING_EFFORT;
}
export function composerReasoningEffortOptions(): {
value: GameCreatorLlmReasoningEffort;
label: string;
}[] {
return gameCreatorLlmReasoningEfforts.map((effort) => ({
value: effort,
label: reasoningEffortLabel(effort),
}));
}
@@ -794,26 +794,8 @@ export function RuntimeConfigDialog({
</div>
{runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<>
<label>
<select
aria-label="推理档"
value={runtimeConfigDraft.llm.reasoningEffort}
onChange={(event) =>
updateRuntimeLlmConfig(
'reasoningEffort',
event.currentTarget
.value as GameCreatorLlmReasoningEffort,
)
}
>
{gameCreatorLlmReasoningEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort}
</option>
))}
</select>
</label>
{/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效),
设置里不再重复一份。 */}
<label className="settings-checkbox">
<input
aria-label="流式输出"
+280
View File
@@ -10736,6 +10736,286 @@ button.design-workspace-tree__entry:hover,
color: var(--platform-button-primary-text);
}
/* ===== 输入盒新增控件`+` 上传弹层 / 语音 / 推理档 / 终止钮 / 附件与队列 chip=====
全部是**追加**规则上面那三条共用尺寸/配色规则`+``@`发送钮保持原样
这里只给新增元素自己的几何与配色 */
/* `+` 弹层的包含块:弹层是 absolute,锚点必须自己带定位,否则会挂到整只 composer 上。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-attachment-anchor {
position: relative;
display: grid;
flex: 0 0 auto;
place-items: center;
}
/* 原生文件选择器只作为 `+` 弹层的落点,不放进版式。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-upload-input {
display: none;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 3;
display: grid;
min-width: 168px;
padding: 4px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-input-fill);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--platform-text-base);
font-size: 12px;
text-align: left;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-attachment-menu
button:focus-visible {
background: var(--platform-button-ghost-fill);
}
/* 语音钮与终止钮沿用控制排方钮尺寸这里补 `position: static` 等重置
免得继承文件里更早那条广播式 `button` 规则 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-voice-trigger,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-stop-button {
position: static !important;
right: auto !important;
bottom: auto !important;
display: grid;
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
border: 0;
border-radius: 8px;
flex: 0 0 28px;
place-items: center;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger {
background: transparent;
color: var(--platform-text-soft);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
/* 录音态:实心强调色 + 呼吸光环,配上 aria-pressed 让"正在录"一眼可见。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-voice-trigger.is-recording {
border-radius: 999px;
background: var(--platform-accent, #c7653d);
color: var(--platform-button-primary-text);
animation: composer-voice-recording-pulse 1.4s ease-in-out infinite;
}
@keyframes composer-voice-recording-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgb(199 101 61 / 45%);
}
50% {
box-shadow: 0 0 0 4px rgb(199 101 61 / 0%);
}
}
/* 终止钮:发送钮的圆角几何不变,只用中性填充区分"这一步在停止"而不是"发送"。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-stop-button {
border-radius: 999px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
gap: 6px;
}
/* 推理档紧挨模型选择器:控件位只放当前档位,选项在原生菜单里。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select {
max-width: 76px;
padding: 2px 4px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--platform-text-soft);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-reasoning-effort-select:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
}
/* 待发附件 / 队列 chip:压在输入区上方,横向排布、超出换行。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0 0 6px;
padding: 0;
list-style: none;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
li,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
li {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 6px;
border-radius: 999px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-soft);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
li
span,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
.project-supervisor-composer-queue-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-attachments
button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue
button {
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: inherit;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-queue-order {
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-notice {
margin: 4px 0 0;
color: var(--platform-text-soft);
font-size: 12px;
}
/* 消息列表的最终几何故意放在文件靠后的位置它与上面那条同选择器同权重
`.game-workbench-chat .project-supervisor-surface.is-direct-codex
.project-supervisor-message-list`"后写胜出"把两处会顶掉它的规则压回去
@@ -3,6 +3,7 @@ import { vi } from 'vitest';
import { registerAgentRuntimeCommandTests } from './appSurface/agent-runtime.suite';
import { registerAuthTests } from './appSurface/auth.suite';
import { registerChatComposerControlTests } from './appSurface/chat-composer.suite';
import { registerDesignAgentSurfaceTests } from './appSurface/design-agent.suite';
import {
registerDeveloperAgentWindowTests,
@@ -76,4 +77,5 @@ describe('AI 游戏创作 App 界面边界', () => {
registerPlanGddApprovalTests();
registerDesignAgentSurfaceTests();
registerToolCallGroupTests();
registerChatComposerControlTests();
});
File diff suppressed because it is too large Load Diff
@@ -8849,11 +8849,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: '插入素材引用',
@@ -660,7 +660,7 @@ export function registerRuntimeSettingsTests() {
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
fireEvent.keyDown(screen.getByLabelText('推理档'), {
fireEvent.keyDown(screen.getByLabelText('联网检索'), {
key: 'Escape',
});
expect(screen.getByRole('dialog', { name: '运行时配置' })).not.toBeNull();
@@ -877,12 +877,8 @@ export function registerPublishedRuntimeSettingsTests() {
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'medium');
expect(
within(screen.getByLabelText('推理档')).getByRole('option', {
name: 'max',
}),
).not.toBeNull();
// 推理档已下移到对话输入盒(模型选择器旁),设置面板里不再有第二个入口。
expect(screen.queryByLabelText('推理档')).toBeNull();
fireEvent.click(screen.getByLabelText('流式输出'));
fireEvent.click(screen.getByLabelText('联网检索'));
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
@@ -1000,7 +996,7 @@ export function registerPublishedRuntimeSettingsTests() {
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'high');
expect(screen.queryByLabelText('推理档')).toBeNull();
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();