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