diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 62a2a0b0b..3689fa8f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -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::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index e8d536ae1..656ec3373 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -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 { + entries: HashMap, +} + +impl DirectCodexActiveTurnTable { + 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>>, +> = OnceLock::new(); + +fn direct_codex_active_turns( +) -> &'static std::sync::Mutex>> { + 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, +} + +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, +) -> 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 = 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 = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 4e28e1de7..e2608eb19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -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, +) -> 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 { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index af5c97c77..7f1d60fdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index fd12809a6..703ebcaf7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -148,6 +148,7 @@ import { type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; +import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { agentConversationReadDraftsFromManifest, @@ -220,6 +221,15 @@ import { isMissingProjectFileError, parseAgentRunTrace, } from './features/project-workspace/agentRunTrace'; +import { + chatQueueFullNotice, + createQueuedChatTurn, + dequeueChatTurn, + enqueueChatTurn, + isChatTurnQueueFull, + type QueuedChatTurn, + removeQueuedChatTurn, +} from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { @@ -472,6 +482,8 @@ function directCodexTurnIdFromAssistantMessageId(messageId: string) { ); } +export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; + export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -479,6 +491,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) { .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); } +/** + * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 + * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 + * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + */ +export function isDirectCodexTurnInterruptedError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes('turn 已中断') || message.includes('已终止本次回合'); +} + function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; @@ -747,7 +769,35 @@ export function App({ : '', ); const [chatReferences, setChatReferences] = useState([]); + /** + * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 + * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, + * 提交后即清空——后端协议不变。 + */ + const [chatAttachments, setChatAttachments] = useState< + DirectCodexTurnAttachment[] + >([]); + const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); + /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ + const [chatTurnQueue, setChatTurnQueue] = useState([]); + const chatTurnQueueRef = useRef([]); + chatTurnQueueRef.current = chatTurnQueue; + const [chatComposerNotice, setChatComposerNotice] = useState(''); + const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = + useState(false); + const queuedChatTurnSequenceRef = useRef(0); const chatComposerRef = useRef(null); + /** + * 切项目即清空只属于上一个项目的输入盒状态:待发附件的 `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, ) { 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 ( void handleCancelDirectCodexTurn()} + onRemoveAttachment={removeChatComposerAttachment} + onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} + queuedTurns={chatTurnQueue} + turnCancelling={directCodexTurnCancelling} composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index dca3466fe..6b2078d3a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -87,6 +87,51 @@ async function suggestAutomaticProjectName( } } +/** + * 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。 + * + * 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后, + * 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。 + */ +export async function uploadLocalFilesAsAttachments( + invoke: TauriInvoke, + nextProjectPath: string, + files: readonly File[], +): Promise { + const imported: LauncherImportedAttachment[] = []; + for (const file of files) { + const mediaType = file.type || 'application/octet-stream'; + try { + const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); + const result = await invoke( + 'upload_local_asset', + { + projectPath: nextProjectPath, + fileName: file.name, + mediaType, + bytes, + }, + ); + imported.push({ + fileName: file.name, + mediaType, + localPath: result.localPath, + status: 'imported', + size: file.size, + }); + } catch (error) { + imported.push({ + fileName: file.name, + mediaType, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + size: file.size, + }); + } + } + return imported; +} + export function useHomeProjectCreation({ setStatus, setLauncherView, @@ -245,40 +290,11 @@ export function useHomeProjectCreation({ nextProjectPath: string, attachments: HomeAttachmentDraft[], ) { - const imported: LauncherImportedAttachment[] = []; - for (const attachment of attachments) { - const mediaType = attachment.file.type || 'application/octet-stream'; - try { - const bytes = Array.from( - new Uint8Array(await attachment.file.arrayBuffer()), - ); - const result = await invoke( - 'upload_local_asset', - { - projectPath: nextProjectPath, - fileName: attachment.file.name, - mediaType, - bytes, - }, - ); - imported.push({ - fileName: attachment.file.name, - mediaType, - localPath: result.localPath, - status: 'imported', - size: attachment.file.size, - }); - } catch (error) { - imported.push({ - fileName: attachment.file.name, - mediaType, - status: 'failed', - error: error instanceof Error ? error.message : String(error), - size: attachment.file.size, - }); - } - } - return imported; + return uploadLocalFilesAsAttachments( + invoke, + nextProjectPath, + attachments.map((attachment) => attachment.file), + ); } async function enterCreatedHomeProject( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx new file mode 100644 index 000000000..ddf8bce7b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -0,0 +1,463 @@ +/** + * 输入盒控件(Codex 观感):左 `+`(上传本地文件 / 引用项目素材)、右侧推理强度 + + * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 + * + * 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`, + * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + */ +import { FileUp, Images, Mic, MicOff, Plus, Square, X } from 'lucide-react'; +import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { + GameCreatorAppConfigView, + GameCreatorLlmReasoningEffort, +} from '../../app/types'; +import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; +import type { QueuedChatTurn } from './chatComposerQueue'; +import { queuedChatTurnLabel } from './chatComposerQueue'; +import { + resolveSpeechRecognitionCtor, + speechEventTranscript, + type SpeechRecognitionCtor, + speechRecognitionErrorMessage, + speechRecognitionLang, + type SpeechRecognitionLike, + VOICE_INPUT_UNSUPPORTED_MESSAGE, +} from './chatComposerVoice'; +import { + composerReasoningEffortOptions, + DEFAULT_COMPOSER_REASONING_EFFORT, + normalizeComposerReasoningEffort, +} from './composerReasoningEffort'; + +type ComposerAttachmentMenuProps = { + disabled: boolean; + onPickFiles: (files: readonly File[]) => void; + onOpenReferencePicker: () => void; +}; + +/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */ +export function ComposerAttachmentMenu({ + disabled, + onPickFiles, + onOpenReferencePicker, +}: ComposerAttachmentMenuProps) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + function handleOutsidePointerDown(event: MouseEvent) { + const target = event.target as Node | null; + if (anchorRef.current && !anchorRef.current.contains(target)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + document.addEventListener('mousedown', handleOutsidePointerDown); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + + return ( +
+ { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) { + onPickFiles(files); + } + }} + /> + + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + +/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */ +export function ComposerPendingAttachments({ + attachments, + onRemove, +}: { + attachments: readonly DirectCodexTurnAttachment[]; + onRemove: (index: number) => void; +}) { + if (attachments.length === 0) { + return null; + } + return ( +
    + {attachments.map((attachment, index) => ( +
  • + + {attachment.name} + + +
  • + ))} +
+ ); +} + +/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +export function ComposerTurnQueue({ + turns, + onCancel, +}: { + turns: readonly QueuedChatTurn[]; + onCancel: (id: string) => void; +}) { + if (turns.length === 0) { + return null; + } + return ( +
    + {turns.map((turn, index) => ( +
  1. + + {index + 1} + + + {queuedChatTurnLabel(turn)} + + +
  2. + ))} +
+ ); +} + +type ComposerVoiceButtonProps = { + disabled: boolean; + onTranscript: (text: string) => void; + onNotice: (message: string) => void; +}; + +/** + * 麦克风:只在运行时确实提供 SpeechRecognition 时可用;否则按钮禁用并直接说明原因 + * (aria-label/title 都是那句提示,不假装能用)。录音态用 `is-recording` 做视觉反馈。 + */ +export function ComposerVoiceButton({ + disabled, + onTranscript, + onNotice, +}: ComposerVoiceButtonProps) { + const ctorRef: RefObject = useRef( + resolveSpeechRecognitionCtor( + typeof window === 'undefined' ? null : (window as unknown as object), + ), + ); + const ctor = ctorRef.current; + const supported = Boolean(ctor); + const [recording, setRecording] = useState(false); + const recognitionRef = useRef(null); + const onTranscriptRef = useRef(onTranscript); + const onNoticeRef = useRef(onNotice); + useEffect(() => { + onTranscriptRef.current = onTranscript; + onNoticeRef.current = onNotice; + }, [onNotice, onTranscript]); + useEffect( + () => () => { + recognitionRef.current?.abort?.(); + recognitionRef.current = null; + }, + [], + ); + + const unsupportedHint = VOICE_INPUT_UNSUPPORTED_MESSAGE; + const activeLabel = recording ? '停止语音输入' : '语音输入'; + + function startRecognition() { + if (!ctor) { + onNoticeRef.current(unsupportedHint); + return; + } + try { + const recognition = new ctor(); + recognition.lang = speechRecognitionLang(navigator?.language); + recognition.continuous = true; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + recognition.onresult = (event) => { + const transcript = speechEventTranscript(event); + if (transcript) { + onTranscriptRef.current(transcript); + } + }; + recognition.onerror = (event) => { + const message = speechRecognitionErrorMessage(event?.error); + setRecording(false); + if (message) { + onNoticeRef.current(message); + } + }; + recognition.onend = () => { + setRecording(false); + recognitionRef.current = null; + }; + recognitionRef.current = recognition; + recognition.start(); + setRecording(true); + } catch (error) { + recognitionRef.current = null; + setRecording(false); + onNoticeRef.current( + error instanceof Error && error.message + ? error.message + : '语音输入启动失败,请稍后重试', + ); + } + } + + return ( + + ); +} + +/** + * 推理强度:原生 select,紧挨模型选择器。读取/写回都走客户端配置通道 + * (`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)。 + */ +export function ComposerReasoningEffortSelect({ + disabled, +}: { + disabled: boolean; +}) { + const [effort, setEffort] = useState( + DEFAULT_COMPOSER_REASONING_EFFORT, + ); + const [saving, setSaving] = useState(false); + const [notice, setNotice] = useState(''); + const writeChainRef = useRef>(Promise.resolve()); + const mountedRef = useRef(true); + const options = composerReasoningEffortOptions(); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const invoke = resolveTauriInvoke(); + if (!invoke) { + return undefined; + } + void invoke('read_game_creator_app_config') + .then((view) => { + if (cancelled || !mountedRef.current) return; + setEffort( + normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), + ); + }) + .catch(() => { + if (cancelled || !mountedRef.current) return; + setNotice('推理档读取失败'); + }); + return () => { + cancelled = true; + }; + }, []); + + function selectEffort(next: GameCreatorLlmReasoningEffort) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setNotice('需要在 Tauri App 内运行'); + return; + } + const previous = effort; + setEffort(next); + setNotice(''); + setSaving(true); + const write = () => + invoke('select_game_creator_reasoning_effort', { + effort: next, + }); + const run = writeChainRef.current.then(write, write); + writeChainRef.current = run.then( + () => undefined, + () => undefined, + ); + void run + .then((view) => { + if (!mountedRef.current) return; + // 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。 + setEffort( + normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), + ); + }) + .catch(() => { + if (!mountedRef.current) return; + setEffort(previous); + setNotice('推理档保存失败'); + }) + .finally(() => { + if (mountedRef.current) { + setSaving(false); + } + }); + } + + return ( + + + {notice ? ( + + {notice} + + ) : null} + + ); +} + +/** 发送按钮位置在回合运行中显示的终止钮(Codex 的停止方块)。 */ +export function ComposerStopButton({ + cancelling, + onCancel, +}: { + cancelling: boolean; + onCancel: () => void; +}) { + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index af2336e9a..db1855d67 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -3,7 +3,6 @@ import { AtSign, Loader2, MessageSquareDashed, - Plus, Settings, } from 'lucide-react'; import type { @@ -39,8 +38,18 @@ import { ProjectSupervisorRuntimePanel, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; +import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import type { QueuedChatTurn } from './chatComposerQueue'; +import { + ComposerAttachmentMenu, + ComposerPendingAttachments, + ComposerReasoningEffortSelect, + ComposerStopButton, + ComposerTurnQueue, + ComposerVoiceButton, +} from './ComposerControls'; import { ConversationModelSelect, type ConversationModelSelectHandle, @@ -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; onShowEarlierMessages: () => void; onSubmit: FormEventHandler; @@ -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('strict'); const [approvalNotice, setApprovalNotice] = useState(''); @@ -589,6 +624,14 @@ export function ProjectSupervisorView({ void validateModel(); }} > + onCancelQueuedTurn?.(id)} + /> + onRemoveAttachment?.(index)} + />
- + onPickFiles={(files) => onUploadFiles?.(files)} + onOpenReferencePicker={() => + composerRef?.current?.openPicker() + } + />
+ {/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端 + 配置,只影响后续回合;当前回合的行为不受影响。 */} + - {submitButton} + + composerRef?.current?.insertText(text) + } + onNotice={setVoiceNotice} + /> + {submitting && onCancelTurn ? ( + + ) : ( + submitButton + )}
) : ( submitButton )} + {directCodex && + (composerNotice || attachmentNotice || voiceNotice) ? ( +

+ {composerNotice || attachmentNotice || voiceNotice} +

+ ) : null} {directCodex ? null : ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 1971660b5..9283f7f4a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -156,6 +156,8 @@ function createResourcePickerScopeStates(): Record< export type ResourceReferenceInputHandle = { insertReferences: (references: ChatReference[]) => void; + /** 追加纯文本(语音识别结果):写在当前光标处,且不覆盖用户已输入的内容。 */ + insertText: (text: string) => void; openPicker: () => void; focus: () => void; }; @@ -569,14 +571,43 @@ function ResourceReferenceEditor({ setPickerOpen(true); }, []); + const insertText = useCallback( + (text: string) => { + const insert = text.replace(/\s+$/u, ''); + if (!insert.trim()) return; + editor.update(() => { + let selection = $getSelection(); + // 选区失效(跨会话恢复草稿后常见)时回落到草稿末尾,与 `insertReferences` 同口径。 + if ( + !$isRangeSelection(selection) || + !selection.anchor.getNode().isAttached() + ) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + if ($isRangeSelection(selection)) { + // 认领的光标处的已有内容保持不变:这里只插入,不删除任何节点。 + const rootText = $getRoot().getTextContent(); + if (rootText && !/\s$/u.test(rootText)) { + selection.insertText(' '); + } + selection.insertText(insert); + } + }); + editor.focus(); + }, + [editor], + ); + useImperativeHandle( composerRef, () => ({ insertReferences, + insertText, openPicker, focus: () => editor.focus(), }), - [editor, insertReferences, openPicker], + [editor, insertReferences, insertText, openPicker], ); useEffect(() => { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts new file mode 100644 index 000000000..0ece3ae9e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -0,0 +1,89 @@ +/** + * 输入盒本地消息队列(纯前端状态,不改后端协议)。 + * + * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 + * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 + */ +import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; +import type { ChatReference } from './resourceReferences'; + +/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ +export const MAX_QUEUED_CHAT_TURNS = 5; + +export type QueuedChatTurn = { + id: string; + prompt: string; + attachments: DirectCodexTurnAttachment[]; + references: ChatReference[]; + createdAt: number; +}; + +export function createQueuedChatTurn(input: { + id: string; + prompt: string; + attachments?: readonly DirectCodexTurnAttachment[]; + references?: readonly ChatReference[]; + createdAt: number; +}): QueuedChatTurn { + return { + id: input.id, + prompt: input.prompt, + attachments: [...(input.attachments ?? [])], + references: [...(input.references ?? [])], + createdAt: input.createdAt, + }; +} + +/** 队尾追加。同一 id 已在队列里时原样返回,避免重复入队把同一条消息发两遍。 */ +export function enqueueChatTurn( + queue: readonly QueuedChatTurn[], + turn: QueuedChatTurn, +): QueuedChatTurn[] { + if (queue.some((item) => item.id === turn.id)) { + return [...queue]; + } + return [...queue, turn]; +} + +/** 取队首(FIFO)。队列为空时 `next` 为 `null`,`rest` 保持空数组。 */ +export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): { + next: QueuedChatTurn | null; + rest: QueuedChatTurn[]; +} { + if (queue.length === 0) { + return { next: null, rest: [] }; + } + const [next, ...rest] = queue; + return { next: next ?? null, rest }; +} + +/** 单条取消:按 id 移除,其余项保持原有顺序。 */ +export function removeQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, +): QueuedChatTurn[] { + return queue.filter((item) => item.id !== id); +} + +export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean { + return queue.length >= MAX_QUEUED_CHAT_TURNS; +} + +export function chatQueueFullNotice(): string { + return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; +} + +/** 队列 chip 上显示的文字:单行、有长度上限。 */ +export function queuedChatTurnLabel(turn: QueuedChatTurn): string { + const text = turn.prompt.trim().replace(/\s+/gu, ' '); + if (text) { + return text.length > 24 ? `${text.slice(0, 24)}…` : text; + } + if (turn.attachments.length > 0) { + return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`; + } + if (turn.references.length > 0) { + return '素材引用'; + } + return '未命名消息'; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts new file mode 100644 index 000000000..0d3a4f474 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts @@ -0,0 +1,144 @@ +/** + * 输入盒语音输入:只做能力探测、文本拼接与错误文案,不依赖 React。 + * + * WebView 是否提供 `SpeechRecognition` / `webkitSpeechRecognition` 由运行时决定; + * 拿不到构造器时必须禁用按钮并给出可读提示,不允许"看起来能用"。 + */ + +export const VOICE_INPUT_UNSUPPORTED_MESSAGE = '当前运行环境不支持语音输入'; +export const VOICE_INPUT_PERMISSION_MESSAGE = + '语音输入未获得麦克风权限,请在系统设置中允许后重试'; +export const VOICE_INPUT_SERVICE_MESSAGE = + '当前运行环境的语音识别服务不可用,请改用键盘输入'; +export const VOICE_INPUT_DEFAULT_ERROR_MESSAGE = '语音输入失败,请稍后重试'; + +export type SpeechRecognitionAlternativeLike = { + transcript: string; +}; + +export type SpeechRecognitionResultLike = { + isFinal: boolean; + length: number; + [index: number]: SpeechRecognitionAlternativeLike; +}; + +export type SpeechRecognitionResultListLike = { + length: number; + [index: number]: SpeechRecognitionResultLike; +}; + +export type SpeechRecognitionEventLike = { + resultIndex: number; + results: SpeechRecognitionResultListLike; +}; + +export type SpeechRecognitionErrorEventLike = { + error?: string; + message?: string; +}; + +export type SpeechRecognitionLike = { + lang: string; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + start: () => void; + stop: () => void; + abort: () => void; + onresult: ((event: SpeechRecognitionEventLike) => void) | null; + onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; + onend: (() => void) | null; +}; + +export type SpeechRecognitionCtor = new () => SpeechRecognitionLike; + +type SpeechScope = { + SpeechRecognition?: SpeechRecognitionCtor; + webkitSpeechRecognition?: SpeechRecognitionCtor; +}; + +/** 取当前运行时的语音识别构造器;两个厂商前缀都没有时返回 `null`(降级)。 */ +export function resolveSpeechRecognitionCtor( + scope: unknown, +): SpeechRecognitionCtor | null { + if (!scope || typeof scope !== 'object') { + return null; + } + const candidate = scope as SpeechScope; + return ( + candidate.SpeechRecognition ?? candidate.webkitSpeechRecognition ?? null + ); +} + +export function speechRecognitionLang(locale: string | undefined): string { + const normalized = locale?.trim(); + if (!normalized) { + return 'zh-CN'; + } + return normalized; +} + +/** + * 识别文本追加到输入框已有内容之后——**不覆盖**用户已经输入的部分。 + * + * 中文/日文这类无空格语言直接拼接;纯 ASCII 字母数字开头的识别结果补一个空格, + * 避免英文单词粘在一起。 + */ +export function appendDictationText( + current: string, + transcript: string, +): string { + const text = transcript.trim(); + if (!text) { + return current; + } + if (!current) { + return text; + } + if (/\s$/u.test(current)) { + return `${current}${text}`; + } + return /^[A-Za-z0-9]/u.test(text) + ? `${current} ${text}` + : `${current}${text}`; +} + +/** 识别结果的最终文本(同一轮里可能有多段 interim,取本次事件里的最终段)。 */ +export function speechEventTranscript( + event: SpeechRecognitionEventLike, +): string { + let transcript = ''; + for ( + let index = event.resultIndex; + index < event.results.length; + index += 1 + ) { + const result = event.results[index]; + if (!result) continue; + const alternative = result[0]; + if (alternative?.transcript) { + transcript += alternative.transcript; + } + } + return transcript; +} + +export function speechRecognitionErrorMessage( + error: string | undefined, +): string { + switch (error?.trim()) { + case 'not-allowed': + case 'service-not-allowed': + return VOICE_INPUT_PERMISSION_MESSAGE; + case 'network': + return VOICE_INPUT_SERVICE_MESSAGE; + case 'audio-capture': + return '未检测到可用的麦克风设备'; + case 'no-speech': + return '没有识别到语音,请重试'; + case 'aborted': + return ''; + default: + return VOICE_INPUT_DEFAULT_ERROR_MESSAGE; + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts b/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts new file mode 100644 index 000000000..e8c520b88 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/composerReasoningEffort.ts @@ -0,0 +1,48 @@ +/** + * 输入盒推理强度控件:把运行配置里的 `llm.reasoningEffort` 搬到输入盒这一排。 + * + * 配置写入走既有客户端配置通道,只影响**后续**回合:每个回合开始时 Rust 侧都会 + * 重新读取一次客户端配置,因此改档不会改变正在跑的回合。 + */ +import type { GameCreatorLlmReasoningEffort } from '../../app/types'; +import { gameCreatorLlmReasoningEfforts } from '../../app/types'; + +export const DEFAULT_COMPOSER_REASONING_EFFORT: GameCreatorLlmReasoningEffort = + 'default'; + +const reasoningEffortLabels: Record = { + default: '默认', + low: '低', + medium: '中', + high: '高', + max: '最高', +}; + +export function reasoningEffortLabel( + effort: GameCreatorLlmReasoningEffort, +): string { + return reasoningEffortLabels[effort]; +} + +/** 配置值可能是缺字段 / 大小写不一致;只有契约内的档位才认,其余回落默认档。 */ +export function normalizeComposerReasoningEffort( + value: unknown, +): GameCreatorLlmReasoningEffort { + if (typeof value !== 'string') { + return DEFAULT_COMPOSER_REASONING_EFFORT; + } + const normalized = value.trim().toLowerCase(); + return gameCreatorLlmReasoningEfforts.find((effort) => effort === normalized) + ? (normalized as GameCreatorLlmReasoningEffort) + : DEFAULT_COMPOSER_REASONING_EFFORT; +} + +export function composerReasoningEffortOptions(): { + value: GameCreatorLlmReasoningEffort; + label: string; +}[] { + return gameCreatorLlmReasoningEfforts.map((effort) => ({ + value: effort, + label: reasoningEffortLabel(effort), + })); +} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index da0075a02..289a0cfbb 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -794,26 +794,8 @@ export function RuntimeConfigDialog({ {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> - + {/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效), + 设置里不再重复一份。 */}