Merge remote-tracking branch 'origin/master' into codex/agc-client-mcp-facade

This commit is contained in:
2026-09-04 17:55:41 +08:00
38 changed files with 2578 additions and 593 deletions
@@ -88,10 +88,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
setStatus('');
try {
const updated = await updateAdminErrorReport(token, selected.batchId, {
status: nextStatus,
note: selected.note,
});
setSelected((current) => (current ? { ...current, ...updated } : current));
status: nextStatus,
note: selected.note,
});
setSelected((current) =>
current ? { ...current, ...updated } : current,
);
await loadRef.current();
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
@@ -244,7 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current ? { ...current, note: event.target.value } : current,
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
@@ -1943,17 +1943,23 @@ impl CodexAppServerConnection {
snapshot: &AgentRuntimeProviderRequestSnapshot,
request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<CodexThreadLease, platform_llm::LlmError> {
) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> {
let key = CodexNodeThreadKey::from(snapshot);
let mut threads = self.inner.threads.lock().await;
// The caller holds `turn_gate` while invoking this method. Returning
// the creation bit from the same threads lock keeps the replay
// decision atomic with thread reuse/creation.
if let Some(entry) = threads.get_mut(&key) {
entry.active_uses = entry.active_uses.saturating_add(1);
entry.last_used = next_game_creator_codex_app_server_usage_tick();
return Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
});
return Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
},
false,
));
}
if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX {
let evict_key = threads
@@ -2015,11 +2021,14 @@ impl CodexAppServerConnection {
active_uses: 1,
},
);
Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id,
})
Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id,
},
true,
))
}
async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver<CodexTurnEvent> {
@@ -2085,11 +2094,60 @@ impl CodexAppServerConnection {
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer_and_history(
snapshot,
llm,
request,
None,
None,
on_agent_message_delta,
direct_observer,
audit,
)
.await
}
async fn run_turn_with_direct_observer_and_history(
&self,
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
direct_history_root: Option<&std::path::Path>,
direct_client_turn_id: Option<&str>,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
self.wait_for_initial_client_mcp_startup().await;
let thread_id = thread_lease.thread_id.clone();
let mut request = request;
if thread_created && self.inner.workspace_mode.uses_direct_conversation() {
// DirectProject owns the append-only project history in AGC. The
// replay builder derives a bounded prompt without mutating that
// durable fact source, so a new ephemeral thread can recover the
// newest contiguous context within the model budget.
let current_prompt = direct_codex_current_user_prompt(&request).to_string();
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
let history_prompt = build_direct_codex_history_prompt(
history_root,
direct_client_turn_id.unwrap_or("__none__"),
&current_prompt,
&request,
llm,
)
.map_err(platform_llm::LlmError::InvalidRequest)?;
if let Some(message) = request
.messages
.iter_mut()
.rev()
.find(|message| message.role == LlmMessageRole::User)
{
message.content = history_prompt;
}
}
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_user_prompt(&request)
} else {
@@ -3020,6 +3078,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
user_prompt,
None,
None,
None,
)
.await
}
@@ -3034,6 +3093,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
root,
system_prompt,
user_prompt,
None,
Some(observer),
None,
)
@@ -3090,6 +3150,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
@@ -3129,11 +3190,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
let connection = CodexAppServerConnection::acquire_at_workspace(
&snapshot,
&config.llm,
@@ -3142,13 +3198,176 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
)
.await
.map_err(|error| error.to_string())?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
connection
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
.run_turn_with_direct_observer_and_history(
&snapshot,
&config.llm,
request,
Some(&codex_root),
client_turn_id,
None,
observer,
audit,
)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
}
/// Builds the prompt used when a new DirectProject thread needs replay.
///
/// The JSONL history remains immutable; only the derived prompt is bounded.
/// When the full request exceeds the replay target, the oldest contiguous
/// records are omitted and a plain `system:` marker is prepended.
pub(crate) fn build_direct_codex_history_prompt(
root: &std::path::Path,
client_turn_id: &str,
current_prompt: &str,
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let conversation = read_local_conversation_for_session_at(root, None, None)?;
let current_message_id = format!("direct-codex:{client_turn_id}:user");
// The deliberately simple role-prefix format is part of the Direct
// replay contract. Do not introduce an envelope or implicit escaping
// here without updating the persisted-history compatibility decision.
let lines = conversation
.messages
.iter()
.filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str()))
.map(|message| format!("{}: {}", message.role, message.content))
.collect::<Vec<_>>();
let current_line = format!("user: {}", current_prompt.trim());
let full_prompt = format_direct_codex_replay_prompt(&lines, &current_line, None);
let target_budget = direct_codex_replay_target_budget(llm, base_request)?;
if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? {
return Ok(full_prompt);
}
// The persisted project conversation is immutable. We only derive a
// bounded prompt for this replay, keeping the newest contiguous records.
let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER;
let mut selected_reversed = Vec::new();
for line in lines.iter().rev() {
let mut candidate_reversed = selected_reversed.clone();
candidate_reversed.push(line.as_str());
let candidate_lines = candidate_reversed.iter().rev().copied().collect::<Vec<_>>();
let candidate_prompt = format_direct_codex_replay_prompt(
&candidate_lines,
&current_line,
Some(omission_marker),
);
if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? {
selected_reversed.push(line.as_str());
} else {
break;
}
}
let selected_lines = selected_reversed.iter().rev().copied().collect::<Vec<_>>();
let marked_prompt =
format_direct_codex_replay_prompt(&selected_lines, &current_line, Some(omission_marker));
if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? {
return Ok(marked_prompt);
}
// If the marker itself would push the request over the target, preserve
// the current user request and omit only the marker.
let current_only_prompt =
format_direct_codex_replay_prompt(&[] as &[&str], &current_line, None);
if direct_codex_replay_prompt_fits(base_request, llm, &current_only_prompt, target_budget)? {
return Ok(current_only_prompt);
}
direct_codex_replay_validate_context_budget(base_request, llm, &current_only_prompt)
}
const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str =
"system: Earlier conversation history was omitted due to context budget.";
fn format_direct_codex_replay_prompt(
history_lines: &[impl AsRef<str>],
current_line: &str,
omission_marker: Option<&str>,
) -> String {
let mut lines = Vec::with_capacity(history_lines.len() + 2);
if let Some(marker) = omission_marker {
lines.push(marker.to_string());
}
lines.extend(history_lines.iter().map(|line| line.as_ref().to_string()));
lines.push(current_line.to_string());
lines.join("\n")
}
fn direct_codex_replay_target_budget(
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<u64, String> {
const SAFETY_MARGIN_TOKENS: u64 = 4_096;
let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0));
let hard_budget = llm
.context_window_tokens
.checked_sub(max_output_tokens)
.and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS))
.ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?;
Ok(llm.auto_compact_token_limit.min(hard_budget))
}
fn direct_codex_replay_estimate(
base_request: &LlmRunRequest,
prompt: &str,
) -> Result<LlmRunRequest, String> {
let mut request = base_request.clone();
let user = request
.messages
.iter_mut()
.rev()
.find(|message| message.role == LlmMessageRole::User)
.ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?;
user.content = prompt.to_string();
Ok(request)
}
fn direct_codex_replay_prompt_fits(
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
prompt: &str,
target_budget: u64,
) -> Result<bool, String> {
let request = direct_codex_replay_estimate(base_request, prompt)?;
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
if estimated > target_budget {
return Ok(false);
}
validate_game_creator_llm_request_context_budget(
llm,
&request,
estimated,
"Direct replay 请求",
)?;
Ok(true)
}
fn direct_codex_replay_validate_context_budget(
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
prompt: &str,
) -> Result<String, String> {
let request = direct_codex_replay_estimate(base_request, prompt)?;
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
validate_game_creator_llm_request_context_budget(
llm,
&request,
estimated,
"Direct replay 请求",
)?;
Ok(prompt.to_string())
}
/// Direct home-page chat never binds Codex to a user project. It gets a
/// fresh isolated read-only workspace and a stable in-process thread so a
/// normal conversation can continue without creating a project, assets, a
@@ -3234,6 +3453,119 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
mod tests {
use super::*;
#[test]
fn direct_history_prompt_replays_all_project_messages_in_order() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "history-project", "history").expect("init");
for (role, content, message_id) in [
("user", "hello", "direct-codex:old:user"),
(
"assistant",
"partial\nunexpected interrupt happened here",
"partial-id",
),
("tool", "file-read result", "tool-id"),
("user", "stored raw request", "direct-codex:new-turn:user"),
] {
append_local_conversation_message_for_session_idempotent_at(
root.path(),
None,
None,
LocalConversationMessage {
role: role.to_string(),
content: content.to_string(),
agent_id: None,
},
message_id,
)
.expect("append history");
}
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
let prompt = build_direct_codex_history_prompt(
root.path(),
"new-turn",
"new request",
&request,
&test_llm(),
)
.expect("build prompt");
assert_eq!(
prompt,
"user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request"
);
}
#[test]
fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "window-project", "window").expect("init");
for (role, content, message_id) in [
("user", "old ".repeat(1_000), "old-user"),
("assistant", "middle ".repeat(100), "middle-assistant"),
("tool", "newest ".repeat(100), "newest-tool"),
] {
append_local_conversation_message_for_session_idempotent_at(
root.path(),
None,
None,
LocalConversationMessage {
role: role.to_string(),
content,
agent_id: None,
},
message_id,
)
.expect("append history");
}
let mut llm = test_llm();
llm.auto_compact_token_limit = 800;
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
let before =
std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
.expect("read history");
let prompt = build_direct_codex_history_prompt(
root.path(),
"new-turn",
"new request",
&request,
&llm,
)
.expect("build prompt");
let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
.expect("read history");
assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER));
assert!(prompt.contains("tool: newest"));
assert!(!prompt.contains("user: old"));
assert!(prompt.ends_with("user: new request"));
assert_eq!(before, after);
}
#[test]
fn direct_history_prompt_rejects_current_request_that_exceeds_context() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init");
let mut llm = test_llm();
llm.context_window_tokens = 5_000;
llm.auto_compact_token_limit = 1_000;
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(100);
let error = build_direct_codex_history_prompt(
root.path(),
"new-turn",
&"request ".repeat(10_000),
&request,
&llm,
)
.expect_err("oversized request should fail");
assert!(error.contains("Direct replay 请求"));
}
#[test]
fn direct_item_activities_are_closed_safe_categories() {
let allowed = [
@@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let reply = if let Some(emitter) = turn_emitter {
let client_turn_id = emitter.turn_id().to_string();
let emitter = emitter.clone();
let mut has_streamed = false;
let mut latest_accumulated_text = None;
@@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner(
root,
system_prompt,
prompt.to_string(),
Some(&client_turn_id),
Some(&mut observer),
audit,
)
@@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner(
system_prompt,
prompt.to_string(),
None,
None,
audit,
)
.await
@@ -4189,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at(
.map(|_| ())
}
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: prompt.trim().to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:user"),
)
.map(|_| ())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -4220,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
500,
));
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4261,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
mod tests {
use super::*;
fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
api_kind: "openai_responses".to_string(),
reasoning_effort: "high".to_string(),
stream: false,
web_search_enabled: false,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 10_000,
max_retries: 0,
retry_backoff_ms: 100,
}
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -4326,6 +4377,83 @@ mod tests {
);
}
#[test]
fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘")
.expect("init project");
let turn_id = "client-turn-user-0001";
let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节";
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("persist user prompt before turn");
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("retry reuses the same user message identity");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:user");
let persisted = conversation
.messages
.iter()
.filter(|message| message.message_id.as_deref() == Some(message_id.as_str()))
.collect::<Vec<_>>();
assert_eq!(persisted.len(), 1);
assert_eq!(persisted[0].role, "user");
assert_eq!(persisted[0].content, normalized_prompt);
assert!(
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
.expect_err("same turn identity cannot be rebound")
.contains("messageId 冲突")
);
}
#[test]
fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复")
.expect("init project");
let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单")
.expect("persist first user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0001",
"主菜单已完成,下一步可以继续扩展关卡。",
)
.expect("persist first assistant turn");
persist_direct_codex_user_prompt_at(
root.path(),
"client-turn-0002",
"请在主菜单基础上增加关卡选择",
)
.expect("persist second user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0002",
"关卡选择页面已加入,并保留主菜单入口。",
)
.expect("persist second assistant turn");
// 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时,
// 两轮完整的 user/assistant 上下文都必须保留。
let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt(
root.path(),
"client-turn-0003",
"继续实现第二轮需求",
&request,
&direct_test_llm(),
)
.expect("rebuild history after restart");
assert_eq!(
prompt,
"user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求"
);
}
#[test]
fn direct_turn_update_payload_is_the_exact_camel_case_contract() {
let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent {
@@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter {
},
);
}
pub(crate) fn turn_id(&self) -> &str {
&self.turn_id
}
}
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
+79 -94
View File
@@ -297,56 +297,6 @@ function directCodexConversationMessageId(
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
function directCodexConversationTurnId(
message: ChatMessage,
role: ChatMessage['role'],
) {
if (message.role !== role) {
return null;
}
const messageId = message.messageId?.trim() ?? '';
const roleSuffix = `:${role}`;
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(roleSuffix)
) {
return null;
}
const turnId = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-roleSuffix.length,
);
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null;
}
export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message) {
continue;
}
const turnId = directCodexConversationTurnId(message, 'user');
if (!turnId) {
continue;
}
const assistantMessageId = directCodexConversationMessageId(
turnId,
'assistant',
);
if (
messages.some(
(candidate) =>
candidate.role === 'assistant' &&
candidate.messageId?.trim() === assistantMessageId,
)
) {
continue;
}
return { prompt: message.text, turnId };
}
return null;
}
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -544,6 +494,12 @@ export function App({
useState<number | null>(null);
const [directCodexTransientReply, setDirectCodexTransientReply] =
useState('');
const directCodexTransientReplyRef = useRef('');
const directCodexInterruptedPartialRef = useRef<{
projectPath: string;
text: string;
messageId: string;
} | null>(null);
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
@@ -583,6 +539,7 @@ export function App({
setDirectCodexProgress('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
@@ -596,6 +553,7 @@ export function App({
}
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
return true;
}
@@ -1234,10 +1192,10 @@ export function App({
: Date.now();
if (payload.status === 'failed') {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
return;
}
if (payload.status === 'completed') {
@@ -1249,6 +1207,7 @@ export function App({
}
if (typeof payload.accumulatedText === 'string') {
setDirectCodexTransientReply(payload.accumulatedText);
directCodexTransientReplyRef.current = payload.accumulatedText;
setDirectCodexTransientReplyUpdatedAt(updatedAt);
}
},
@@ -1844,9 +1803,8 @@ export function App({
);
if (localProjectPathRef.current === nextProjectPath) {
// Any history read started before this terminal append may hold
// a user-only snapshot. Invalidate it before releasing the
// in-memory claim so that stale hydration cannot replay the
// same billable Direct turn.
// A stale history snapshot may still be missing this terminal
// append. Invalidate it before releasing the in-memory claim.
projectSupervisorHistoryLoadVersionRef.current += 1;
}
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
@@ -2702,9 +2660,6 @@ export function App({
projectConversation.messages,
supervisorConversation?.messages ?? [],
);
const unansweredDirectTurn = directCodexProductRuntime
? unansweredDirectCodexConversationTurn(conversationMessages)
: null;
if (
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
@@ -2756,24 +2711,6 @@ export function App({
});
return nextConversationMessages;
});
if (unansweredDirectTurn) {
const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`;
void Promise.resolve().then(() => {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
localProjectPathRef.current !== nextProjectPath ||
activeDirectCodexTurnRef.current ||
recoveredDirectCodexTurnClaimsRef.current.has(claimKey)
) {
return;
}
recoveredDirectCodexTurnClaimsRef.current.add(claimKey);
void executeChatAgentReply({
prompt: unansweredDirectTurn.prompt,
clientTurnId: unansweredDirectTurn.turnId,
});
});
}
} catch (error) {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
@@ -5464,6 +5401,23 @@ export function App({
},
},
);
const persistDirectPartialMessage = (messageId: string, text: string) =>
directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId,
message: {
role: 'assistant',
// An interrupted partial is intentionally a normal assistant
// record so replay sees exactly what Codex emitted before the
// disconnect; the marker is product data, not UI metadata.
content: `${text.trim()}\nunexpected interrupt happened here`,
agentId: null,
},
},
);
const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
recoveredDirectCodexTurnClaimsRef.current.add(
recoveredDirectCodexTurnClaimKey,
@@ -5478,26 +5432,40 @@ export function App({
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
try {
// Persist the original user intent and stable turn identity before
// Codex can start any billable or externally visible work. The
// regular conversation writer may race this call, but messageId
// idempotency makes both writers converge on the same record.
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
const interruptedPartial = directCodexInterruptedPartialRef.current;
if (
interruptedPartial?.projectPath === directProjectPath &&
interruptedPartial.text.trim()
) {
await persistDirectPartialMessage(
interruptedPartial.messageId,
interruptedPartial.text,
);
directCodexInterruptedPartialRef.current = null;
}
// Rust owns the normalized user record for attachment turns so the
// durable message includes the same bounded project mapping that is
// sent to Codex. Plain turns keep the optimistic browser write; the
// Rust writer then converges on it through messageId idempotency.
if (!attachments?.length) {
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
agentId: null,
},
},
},
);
);
}
const directTurnInput: {
projectPath: string;
prompt: string;
@@ -5531,10 +5499,10 @@ export function App({
}
}
// Rust persists a successful Direct reply before returning Ok. The
// browser append is redundant, so the hydrated-turn claim can be
// browser append is redundant, so the in-memory turn claim can be
// released without reopening the Provider side effect. Invalidate
// any user-only history snapshot captured before Rust committed the
// terminal reply first.
// any history snapshot captured before Rust committed the terminal
// reply first.
if (localProjectPathRef.current === directProjectPath) {
projectSupervisorHistoryLoadVersionRef.current += 1;
}
@@ -5574,6 +5542,23 @@ export function App({
'陶泥儿智能创作',
true,
);
const partial = directCodexTransientReplyRef.current.trim();
if (partial) {
const partialMessageId =
globalThis.crypto?.randomUUID?.() ||
`direct-partial-${Date.now().toString(36)}`;
directCodexInterruptedPartialRef.current = {
projectPath: directProjectPath,
text: partial,
messageId: partialMessageId,
};
try {
await persistDirectPartialMessage(partialMessageId, partial);
} catch {
// The next user send retries this idempotent append before
// constructing the replay prompt.
}
}
try {
await persistDirectAssistantMessage(visibleMessage);
if (localProjectPathRef.current === directProjectPath) {
@@ -1,5 +1,5 @@
import { listen } from '@tauri-apps/api/event';
import { Download, LoaderCircle } from 'lucide-react';
import { Download, LoaderCircle, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { APP_VERSION } from '../app/appMetadata';
@@ -117,6 +117,16 @@ export function AppUpdateNotice() {
>
{isDownloading ? '正在下载…' : '下载更新'}
</button>
<button
type="button"
className="app-update-close"
onClick={dismiss}
disabled={isDownloading}
aria-label="关闭更新提示"
title="关闭"
>
<X size={18} aria-hidden="true" />
</button>
</aside>
{downloadState !== 'idle' ? (
<div
@@ -1,3 +1,6 @@
export const SPRITE_CHECKERBOARD_CLASS_NAME =
'bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]';
export function SpriteImagePreview({
src,
alt,
@@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = {
font_assets: {},
};
const MAX_HISTORY_LENGTH = 100;
export type UiEditorOperationFailureReason =
| 'locked'
| 'duplicate'
@@ -45,6 +47,15 @@ export type UiEditorOperationResult<T = undefined> =
| { ok: true; value: T }
| { ok: false; reason: UiEditorOperationFailureReason };
export type UiEditorHistoryState = {
canUndo: boolean;
canRedo: boolean;
};
export type UiEditorReplaceStateOptions = {
history?: 'record' | 'reset' | 'skip';
};
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
export type NodeMetadataPatch = Partial<
@@ -227,8 +238,47 @@ function cloneState(state: State): State {
return structuredClone(state);
}
function sameResource<T>(left: T, right: T): boolean {
return JSON.stringify(left) === JSON.stringify(right);
function sameResource<T>(
left: T,
right: T,
seenPairs = new WeakMap<object, WeakSet<object>>(),
): boolean {
if (Object.is(left, right)) return true;
if (
typeof left !== 'object' ||
left === null ||
typeof right !== 'object' ||
right === null
) {
return false;
}
const leftObject = left as object;
const rightObject = right as object;
let seenRightObjects = seenPairs.get(leftObject);
if (seenRightObjects?.has(rightObject)) return true;
if (!seenRightObjects) {
seenRightObjects = new WeakSet<object>();
seenPairs.set(leftObject, seenRightObjects);
}
seenRightObjects.add(rightObject);
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
return left.every((value, index) =>
sameResource(value, right[index], seenPairs),
);
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
sameResource(leftRecord[key], rightRecord[key], seenPairs),
);
}
function visitComponents(nodes: Node[], visit: (component: Component) => void) {
@@ -505,15 +555,82 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
return next;
});
const [isLocked, setIsLocked] = useState(false);
const [historyState, setHistoryState] = useState<UiEditorHistoryState>({
canUndo: false,
canRedo: false,
});
const stateRef = useRef(state);
const isLockedRef = useRef(false);
const undoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const redoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const pendingHistoryBeforeRef = useRef<State | null>(null);
stateRef.current = state;
const commit = useCallback((nextState: State) => {
const syncHistoryState = useCallback(() => {
setHistoryState({
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
});
}, []);
const applyState = useCallback((nextState: State) => {
stateRef.current = nextState;
setState(nextState);
}, []);
const commit = useCallback(
(nextState: State) => {
const current = stateRef.current;
const before = pendingHistoryBeforeRef.current ?? current;
if (sameResource(before, nextState)) {
pendingHistoryBeforeRef.current = null;
return false;
}
undoStackRef.current.push({
before: cloneState(before),
after: nextState,
});
if (undoStackRef.current.length > MAX_HISTORY_LENGTH) {
undoStackRef.current.shift();
}
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
applyState(nextState);
return true;
},
[applyState, syncHistoryState],
);
const resetHistory = useCallback(() => {
undoStackRef.current = [];
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
}, [syncHistoryState]);
const undo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = undoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
redoStackRef.current.push(entry);
applyState(cloneState(entry.before));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const redo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = redoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
undoStackRef.current.push(entry);
applyState(cloneState(entry.after));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const guard = useCallback((): UiEditorOperationFailure | null => {
// Every semantic write exits before reading or committing State while locked.
return isLockedRef.current ? { ok: false, reason: 'locked' } : null;
@@ -534,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
try {
return await operation(snapshot);
} finally {
pendingHistoryBeforeRef.current = null;
isLockedRef.current = false;
setIsLocked(false);
}
@@ -1397,22 +1515,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const clearState = useCallback((): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
commit(cloneState(EMPTY_UI_EDITOR_STATE));
resetHistory();
applyState(cloneState(EMPTY_UI_EDITOR_STATE));
return { ok: true, value: undefined };
}, [commit, guard]);
}, [applyState, guard, resetHistory]);
const replaceState = useCallback(
(nextState: State) => {
(nextState: State, options: UiEditorReplaceStateOptions = {}) => {
const next = cloneState(nextState);
synchronizeDesignImageTrees(next);
commit(next);
if (options.history === 'reset') {
resetHistory();
applyState(next);
} else if (options.history === 'skip') {
if (!pendingHistoryBeforeRef.current) {
pendingHistoryBeforeRef.current = cloneState(stateRef.current);
}
applyState(next);
} else {
commit(next);
}
},
[commit],
[applyState, commit, resetHistory],
);
return {
state,
historyState,
undo,
redo,
resetHistory,
isLocked,
runWithStateLocked,
setImageName,
+21
View File
@@ -75,6 +75,27 @@ body {
cursor: wait;
opacity: 0.65;
}
.app-update-notice .app-update-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #8d6a58;
cursor: pointer;
}
.app-update-notice .app-update-close:hover {
background: rgb(199 101 61 / 12%);
color: #4a220f;
}
.app-update-notice .app-update-close:disabled {
cursor: wait;
opacity: 0.5;
}
.app-update-overlay {
position: fixed;
@@ -1,6 +1,7 @@
import { Image as ImageIcon, Plus, Type } from 'lucide-react';
import { useMemo } from 'react';
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview';
import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
@@ -184,7 +185,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
<button
key={sprite.asset_id}
type="button"
className="relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]"
className={`relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
title={`${sprite.metadata.name} · 引用 ${spriteReferenceCounts[sprite.asset_id] ?? 0}`}
onClick={() => input.selectSprite(sprite.asset_id)}
>
@@ -293,6 +293,7 @@ function renderComponentEditor(
<ImagePanel
component={component.Image}
sprites={props.sprites}
previewUrls={props.previewUrls}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Image: next })}
/>
@@ -0,0 +1,165 @@
import { Check, ImageIcon, X } from 'lucide-react';
import { useState } from 'react';
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../../../features/ui-editor/components/SpriteImagePreview';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
export function ImageAssetSelector({
value,
sprites,
previewUrls,
readOnly,
onChange,
}: {
value: string | null;
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
readOnly: boolean;
onChange: (value: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [failedPreviewUrls, setFailedPreviewUrls] = useState<
Record<string, string>
>({});
const selectedSprite = value ? sprites[value] : undefined;
let selectedLabel = '未绑定';
if (selectedSprite) {
selectedLabel = selectedSprite.metadata.name || value || '未绑定';
} else if (value) {
selectedLabel = `素材不存在(${value}`;
}
const buttonLabel = value ? '更换素材' : '选择素材';
function close() {
setOpen(false);
}
function select(nextValue: string | null) {
onChange(nextValue);
close();
}
function markPreviewFailed(id: string, url: string) {
setFailedPreviewUrls((current) => {
if (current[id] === url) return current;
return { ...current, [id]: url };
});
}
return (
<>
<div className="block text-[11px] font-semibold text-(--platform-text-soft)">
<div className="mt-1 flex items-center gap-2">
<div
className={`min-w-0 flex-1 truncate rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs ${value && !selectedSprite ? 'text-red-600' : 'text-(--platform-text-strong)'}`}
title={selectedLabel}
>
{selectedLabel}
</div>
<button
type="button"
className="h-9 shrink-0 rounded-lg border border-orange-200 bg-orange-50 px-3 text-xs font-semibold text-orange-800 disabled:cursor-not-allowed disabled:opacity-40"
disabled={readOnly}
onClick={() => setOpen(true)}
>
{buttonLabel}
</button>
</div>
</div>
<ThemedModal
open={open}
onClose={close}
ariaLabel="选择图片素材"
panelClassName="flex max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] flex-col rounded-2xl p-5"
>
<div className="flex items-center justify-between gap-3">
<h2 className="m-0 text-base font-semibold"></h2>
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-(--platform-text-soft) hover:bg-black/5"
aria-label="关闭素材选择器"
onClick={close}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<button
type="button"
className={`flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed p-3 text-xs ${value === null ? 'border-orange-400 bg-orange-50 text-orange-800' : 'border-(--platform-subpanel-border) bg-white/45 text-(--platform-text-soft)'}`}
aria-pressed={value === null}
onClick={() => select(null)}
>
<X size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 text-[10px]"></span>
</button>
{value && !selectedSprite ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-3 text-center text-xs text-red-700">
<ImageIcon size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 break-all text-[10px]">{value}</span>
</div>
) : null}
{Object.entries(sprites).map(([id, sprite]) => {
const previewUrl = previewUrls[id];
const hasPreview =
Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl;
const selected = value === id;
return (
<button
key={id}
type="button"
className={`relative overflow-hidden rounded-xl border p-2 text-left transition ${selected ? 'border-orange-400 bg-orange-50 ring-2 ring-orange-200' : 'border-(--platform-subpanel-border) bg-white/45 hover:border-orange-200'}`}
aria-pressed={selected}
onClick={() => select(id)}
>
<div
className={`grid aspect-[4/3] place-items-center overflow-hidden rounded-lg ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
>
{hasPreview ? (
<img
src={previewUrl}
alt={sprite.metadata.name || id}
className="size-full object-contain"
onError={() => {
if (previewUrl) markPreviewFailed(id, previewUrl);
}}
/>
) : (
<ImageIcon
size={24}
className="text-(--platform-text-soft)"
aria-hidden="true"
/>
)}
</div>
<span className="mt-2 block truncate text-xs font-semibold text-(--platform-text-strong)">
{sprite.metadata.name || id}
</span>
{selected ? (
<span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-orange-500 text-white">
<Check size={14} aria-hidden="true" />
</span>
) : null}
</button>
);
})}
</div>
{Object.keys(sprites).length === 0 ? (
<p className="m-0 py-10 text-center text-xs text-(--platform-text-soft)">
</p>
) : null}
</div>
</ThemedModal>
</>
);
}
@@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import type { ImageEditorProps } from './componentEditorTypes';
import { ComponentNumberInput, ComponentSelect } from './ComponentField';
import { ImageAssetSelector } from './ImageAssetSelector';
export function ImagePanel({
component,
sprites,
previewUrls,
readOnly,
onChange,
}: ImageEditorProps) {
@@ -17,24 +19,13 @@ export function ImagePanel({
return (
<div className="space-y-3">
<ComponentSelect
label="目标素材"
value={component.target_graphic ?? ''}
disabled={readOnly}
onChange={(event) =>
update({
...component,
target_graphic: event.target.value || null,
})
}
>
<option value=""></option>
{Object.entries(sprites).map(([id, sprite]) => (
<option key={id} value={id}>
{sprite.metadata.name || id}
</option>
))}
</ComponentSelect>
<ImageAssetSelector
value={component.target_graphic}
sprites={sprites}
previewUrls={previewUrls}
readOnly={readOnly}
onChange={(target_graphic) => update({ ...component, target_graphic })}
/>
<ComponentSelect
label="图片类型"
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { type RgbaColor, RgbaColorPicker } from 'react-colorful';
import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing';
@@ -21,19 +21,49 @@ export function TextPanel({
onChange,
}: TextEditorProps) {
const [colorOpen, setColorOpen] = useState(false);
const rgba: RgbaColor = {
const [colorDraft, setColorDraft] = useState<RgbaColor | null>(null);
const colorDraftRef = useRef<RgbaColor | null>(null);
const componentRef = useRef(component);
const onChangeRef = useRef(onChange);
componentRef.current = component;
onChangeRef.current = onChange;
const committedRgba: RgbaColor = {
r: component.color[0] ?? 255,
g: component.color[1] ?? 255,
b: component.color[2] ?? 255,
a: (component.color[3] ?? 255) / 255,
};
const rgba = colorDraft ?? committedRgba;
useEffect(() => {
colorDraftRef.current = null;
setColorDraft(null);
}, [component.color]);
useEffect(
() => () => {
const draft = colorDraftRef.current;
if (!draft) return;
onChangeRef.current({
...componentRef.current,
color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)],
});
},
[],
);
const bestFit =
'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null;
const updateColor = (next: RgbaColor) =>
const commitColor = (next: RgbaColor = rgba) => {
if (!colorDraftRef.current) return;
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [next.r, next.g, next.b, Math.round(next.a * 255)],
});
};
const toggleColorOpen = () => {
if (colorOpen) commitColor();
setColorOpen((open) => !open);
};
return (
<div className="space-y-3">
@@ -159,7 +189,7 @@ export function TextPanel({
type="button"
className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40"
disabled={readOnly}
onClick={() => setColorOpen((open) => !open)}
onClick={toggleColorOpen}
>
<span
className="size-5 rounded border border-black/15"
@@ -172,24 +202,35 @@ export function TextPanel({
</ComponentField>
{colorOpen && !readOnly ? (
<div className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-(--platform-subpanel-border) bg-white p-3 shadow-xl">
<RgbaColorPicker color={rgba} onChange={updateColor} />
<div
onPointerCancel={() => {
colorDraftRef.current = null;
setColorDraft(null);
}}
>
<RgbaColorPicker
color={rgba}
onChange={(next) => {
colorDraftRef.current = next;
setColorDraft(next);
}}
onChangeEnd={commitColor}
/>
</div>
<ComponentNumberInput
label="Alpha"
min={0}
max={255}
step={1}
value={component.color[3]}
onChange={(event) =>
value={Math.round(rgba.a * 255)}
onChange={(event) => {
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [
component.color[0],
component.color[1],
component.color[2],
Number(event.target.value),
],
})
}
color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)],
});
}}
/>
</div>
) : null}
@@ -9,6 +9,7 @@ import type { UiEditorOperationResult } from '../../../../../features/ui-editor/
export type ComponentPanelProps = {
components: Component[];
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
projectPath: string;
@@ -35,6 +36,7 @@ export type ComponentEditorProps<T> = {
export type ImageEditorProps = ComponentEditorProps<ImageComponent> & {
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
};
export type TextEditorProps = ComponentEditorProps<TextComponent> & {
@@ -111,6 +111,7 @@ export function InspectorSidebar({
onTransformChange={inspector.setNodeTransform}
onLayoutChange={inspector.setNodeLayout}
sprites={inspector.sprites}
previewUrls={inspector.previewUrls}
fonts={inspector.fonts}
fontFaces={inspector.fontFaces}
projectPath={inspector.projectPath}
@@ -288,6 +289,7 @@ function NodeInspector({
onTransformChange,
onLayoutChange,
sprites,
previewUrls,
fonts,
fontFaces,
projectPath,
@@ -317,6 +319,7 @@ function NodeInspector({
onTransformChange: UiEditorInspectorProjection['setNodeTransform'];
onLayoutChange: UiEditorInspectorProjection['setNodeLayout'];
sprites: UiEditorInspectorProjection['sprites'];
previewUrls: UiEditorInspectorProjection['previewUrls'];
fonts: UiEditorInspectorProjection['fonts'];
fontFaces: UiEditorInspectorProjection['fontFaces'];
projectPath: string;
@@ -497,6 +500,7 @@ function NodeInspector({
<ComponentPanel
components={node.components}
sprites={sprites}
previewUrls={previewUrls}
fonts={fonts}
fontFaces={fontFaces}
projectPath={projectPath}
@@ -1,12 +1,16 @@
import {
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronDown,
ChevronUp,
Crosshair,
Info,
Move,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import {
pageRectFromSize,
@@ -30,6 +34,7 @@ export type TransformEditorProps = {
};
type Axis = 0 | 1;
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
type AnchorPreset = {
id: string;
label: string;
@@ -53,6 +58,45 @@ const ROW_MODE_LABELS: Record<AnchorMode, string> = {
const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
const CORNERS: readonly {
id: Corner;
label: string;
}[] = [
{ id: 'top-left', label: '左上角' },
{ id: 'top-right', label: '右上角' },
{ id: 'bottom-left', label: '左下角' },
{ id: 'bottom-right', label: '右下角' },
];
function CornerIcon({
corner,
active = false,
}: {
corner: Corner;
active?: boolean;
}) {
const paths: Record<Corner, string> = {
'top-left': 'M5 11V5h6',
'top-right': 'M13 11V5H7',
'bottom-left': 'M5 9v6h6',
'bottom-right': 'M13 9v6H7',
};
return (
<svg
aria-hidden="true"
viewBox="0 0 18 20"
className={`size-5 ${active ? 'text-(--platform-accent)' : 'text-(--platform-text-soft)'}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={active ? 2.2 : 1.8}
>
<path d={paths[corner]} />
</svg>
);
}
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
COLUMN_MODES.map((x) => ({
id: `${y}-${x}`,
@@ -74,10 +118,6 @@ const FIELD_HINTS = {
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
anchor_max:
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
offset_min:
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
offset_max:
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
} as const;
function cloneTransform(transform: Transform): Transform {
@@ -166,9 +206,44 @@ function formatValue(value: number): string {
return String(Number(value.toFixed(2)));
}
function cornerFields(corner: Corner): {
x: 'offset_min' | 'offset_max';
y: 'offset_min' | 'offset_max';
} {
switch (corner) {
case 'top-left':
return { x: 'offset_min', y: 'offset_min' };
case 'top-right':
return { x: 'offset_max', y: 'offset_min' };
case 'bottom-left':
return { x: 'offset_min', y: 'offset_max' };
case 'bottom-right':
return { x: 'offset_max', y: 'offset_max' };
}
}
function cornerValues(transform: Transform, corner: Corner): [number, number] {
const fields = cornerFields(corner);
return [transform[fields.x][0], transform[fields.y][1]];
}
function updateCorner(
transform: Transform,
corner: Corner,
axis: Axis,
value: number,
): Transform {
const fields = cornerFields(corner);
const next = cloneTransform(transform);
next[axis === 0 ? fields.x : fields.y][axis] = value;
return next;
}
function VectorInputRow({
label,
hint,
hideLabel = false,
stacked = false,
values,
step,
readOnly,
@@ -176,24 +251,45 @@ function VectorInputRow({
}: {
label: string;
hint: string;
hideLabel?: boolean;
stacked?: boolean;
values: readonly [number, number];
step: number;
readOnly: boolean;
onCommit: (axis: Axis, value: number) => void;
}) {
const fields = AXIS_LABELS.map((axisLabel, axis) => (
<ScalarInput
key={axisLabel}
ariaLabel={`${label} ${axisLabel}`}
value={values[axis as Axis]}
step={step}
readOnly={readOnly}
onCommit={(value) => onCommit(axis as Axis, value)}
/>
));
if (stacked) {
return (
<div className="grid min-w-0 gap-2">
{hideLabel ? (
<span className="sr-only">{label}</span>
) : (
<FieldLabel label={label} hint={hint} />
)}
<div className="grid min-w-0 gap-2">{fields}</div>
</div>
);
}
return (
<div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2">
<FieldLabel label={label} hint={hint} />
{AXIS_LABELS.map((axisLabel, axis) => (
<ScalarInput
key={axisLabel}
ariaLabel={`${label} ${axisLabel}`}
value={values[axis as Axis]}
step={step}
readOnly={readOnly}
onCommit={(value) => onCommit(axis as Axis, value)}
/>
))}
{hideLabel ? (
<span className="sr-only">{label}</span>
) : (
<FieldLabel label={label} hint={hint} />
)}
{fields}
</div>
);
}
@@ -201,7 +297,7 @@ function VectorInputRow({
function FieldLabel({ label, hint }: { label: string; hint: string }) {
return (
<span
className="group/field relative inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
className="inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
title={hint}
>
{label}
@@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) {
>
<Info size={11} aria-hidden="true" />
</button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 z-40 hidden w-64 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-2.5 py-2 text-[10px] font-normal leading-relaxed text-(--platform-neutral-text) shadow-lg group-hover/field:block group-focus-within/field:block"
>
{hint}
</span>
</span>
);
}
@@ -320,12 +410,105 @@ function ScalarInput({
);
}
function DirectionButton({
ariaLabel,
disabled,
onAdjust,
children,
}: {
ariaLabel: string;
disabled: boolean;
onAdjust: (step: number) => void;
children: ReactNode;
}) {
const repeatTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const repeated = useRef(false);
const stopRepeating = () => {
if (repeatTimeout.current) {
clearTimeout(repeatTimeout.current);
repeatTimeout.current = null;
}
if (repeatInterval.current) {
clearInterval(repeatInterval.current);
repeatInterval.current = null;
}
};
const startRepeating = (multiplier: number) => {
if (disabled) {
return;
}
repeated.current = false;
repeatTimeout.current = setTimeout(() => {
repeated.current = true;
onAdjust(multiplier);
repeatInterval.current = setInterval(() => onAdjust(multiplier), 70);
}, 350);
};
useEffect(() => {
return () => {
if (repeatTimeout.current) {
clearTimeout(repeatTimeout.current);
repeatTimeout.current = null;
}
if (repeatInterval.current) {
clearInterval(repeatInterval.current);
repeatInterval.current = null;
}
};
}, []);
return (
<button
type="button"
aria-label={ariaLabel}
disabled={disabled}
className="grid size-8 place-items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 text-(--platform-text-soft) transition hover:border-orange-300 hover:bg-orange-50 hover:text-orange-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-default disabled:opacity-40"
onPointerDown={(event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
startRepeating(event.shiftKey ? 10 : 1);
}}
onPointerUp={stopRepeating}
onPointerCancel={() => {
repeated.current = false;
stopRepeating();
}}
onPointerLeave={() => {
repeated.current = false;
stopRepeating();
}}
onClick={(event) => {
if (!repeated.current) {
onAdjust(event.shiftKey ? 10 : 1);
}
repeated.current = false;
stopRepeating();
}}
>
{children}
</button>
);
}
export function TransformEditor({
transform,
parentSize,
readOnly = false,
onChange,
}: TransformEditorProps) {
const [selectedCorner, setSelectedCorner] = useState<Corner>('top-left');
const transformRef = useRef(transform);
const selectedCornerRef = useRef(selectedCorner);
useEffect(() => {
transformRef.current = transform;
selectedCornerRef.current = selectedCorner;
}, [transform, selectedCorner]);
const [presetOpen, setPresetOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(
() => findPreset(transform).id === CUSTOM_PRESET.id,
@@ -369,6 +552,24 @@ export function TransformEditor({
transform.anchor_min[1] > transform.anchor_max[1] ||
geometry?.invalid;
const selectedCornerLabel =
CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角';
const selectedCornerValues = cornerValues(transform, selectedCorner);
const adjustSelectedCorner = (
axis: Axis,
direction: -1 | 1,
multiplier = 1,
) => {
if (readOnly) {
return;
}
const currentTransform = transformRef.current;
const currentCorner = selectedCornerRef.current;
const current = cornerValues(currentTransform, currentCorner)[axis];
const next = Number((current + direction * multiplier).toFixed(4));
onChange(updateCorner(currentTransform, currentCorner, axis, next));
};
return (
<section
aria-readonly={readOnly}
@@ -523,22 +724,100 @@ export function TransformEditor({
) : null}
<div className="grid gap-2">
<VectorInputRow
label="偏移最小值"
hint={FIELD_HINTS.offset_min}
values={transform.offset_min}
step={1}
readOnly={readOnly}
onCommit={(axis, value) => updateVector('offset_min', axis, value)}
/>
<VectorInputRow
label="偏移最大值"
hint={FIELD_HINTS.offset_max}
values={transform.offset_max}
step={1}
readOnly={readOnly}
onCommit={(axis, value) => updateVector('offset_max', axis, value)}
<FieldLabel
label="位置微调"
hint="调整当前选中角的原始 offset 值。"
/>
<div className="grid grid-cols-[minmax(7rem,8rem)_minmax(0,1fr)] items-center gap-4">
<div className="grid aspect-square w-full grid-cols-2 grid-rows-2 gap-1.5 justify-self-center rounded-2xl border border-(--platform-subpanel-border) bg-white/45 p-1.5">
{CORNERS.map((corner) => {
const active = corner.id === selectedCorner;
return (
<button
key={corner.id}
type="button"
aria-label={corner.label}
aria-pressed={active}
disabled={readOnly}
title={corner.label}
className={`grid min-h-10 min-w-10 place-items-center rounded-xl border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 ${active ? 'border-(--platform-accent) bg-(--platform-warm-bg)' : 'border-transparent hover:border-(--platform-accent) hover:bg-(--platform-warm-bg)'}`}
onClick={() => {
selectedCornerRef.current = corner.id;
setSelectedCorner(corner.id);
}}
>
<CornerIcon corner={corner.id} active={active} />
</button>
);
})}
</div>
<div className="grid min-w-0 gap-3">
<div
role="group"
className="mx-auto grid grid-cols-3 grid-rows-3 gap-1.5"
aria-label="角点方向键"
>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向上`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(1, -1, multiplier * 1)
}
>
<ArrowUp size={17} aria-hidden="true" />
</DirectionButton>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向左`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(0, -1, multiplier * 1)
}
>
<ArrowLeft size={17} aria-hidden="true" />
</DirectionButton>
<div className="grid size-8 place-items-center rounded-lg bg-slate-100 text-(--platform-text-soft)">
<CornerIcon corner={selectedCorner} />
</div>
<DirectionButton
ariaLabel={`${selectedCornerLabel}向右`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(0, 1, multiplier * 1)
}
>
<ArrowRight size={17} aria-hidden="true" />
</DirectionButton>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向下`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(1, 1, multiplier * 1)
}
>
<ArrowDown size={17} aria-hidden="true" />
</DirectionButton>
<span />
</div>
<VectorInputRow
label="位置微调"
hideLabel
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
values={selectedCornerValues}
step={1}
readOnly={readOnly}
stacked
onCommit={(axis, value) =>
onChange(updateCorner(transform, selectedCorner, axis, value))
}
/>
</div>
</div>
</div>
</div>
</section>
@@ -24,6 +24,7 @@ import {
} from 'react';
import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry';
import type { Node } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
@@ -48,6 +49,9 @@ export function PreviewWorkspace({
const [renderMode, setRenderMode] =
useState<UiEditorRenderMode>('editor-overlay');
const [showFrame, setShowFrame] = useState(false);
const [previewTransforms, setPreviewTransforms] = useState<
ReadonlyMap<NodeId, Node['layout']['transform']>
>(new Map());
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
@@ -55,6 +59,22 @@ export function PreviewWorkspace({
isPageRoot: boolean;
} | null>(null);
const tree = canvas.tree ?? null;
const updatePreviewTransform = useCallback(
(nodeId: NodeId, transform: Node['layout']['transform'] | null) => {
setPreviewTransforms((current) => {
if (!transform && !current.has(nodeId)) return current;
if (transform && current.get(nodeId) === transform) return current;
const next = new Map(current);
if (transform) next.set(nodeId, transform);
else next.delete(nodeId);
return next;
});
},
[],
);
useEffect(() => {
setPreviewTransforms(new Map());
}, [activeImageId]);
const activeImagePixelWidth = activeImage?.pixel_size[0];
const activeImagePixelHeight = activeImage?.pixel_size[1];
const activeImagePixelsPerUnit = activeImage?.pixels_per_unit;
@@ -87,7 +107,11 @@ export function PreviewWorkspace({
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged: canvas.keepChildrenUnchanged,
viewportRef,
onPreviewTransform: updatePreviewTransform,
previewRef: viewportElementRef,
selectedNodeId: canvas.selectedNodeId,
});
const {
onNodePointerDown,
@@ -96,6 +120,9 @@ export function PreviewWorkspace({
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onNodePointerCancel,
onNodeResizePointerCancel,
consumeNodeClick,
} = nodeInteractions;
const setViewport = useCallback((next: CanvasViewport) => {
@@ -382,6 +409,7 @@ export function PreviewWorkspace({
renderMode={renderMode}
showFrame={showFrame}
hiddenNodeIds={canvas.hiddenNodeIds}
previewTransforms={previewTransforms}
selectedNodeId={canvas.selectedNodeId}
resources={{
previewUrls,
@@ -389,13 +417,16 @@ export function PreviewWorkspace({
fontFaces: canvas.fontFaces,
}}
onSelectNode={canvas.selectNode}
consumeNodeClick={consumeNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodePointerCancel={onNodePointerCancel}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onNodeResizePointerCancel={onNodeResizePointerCancel}
onSelectExclusiveChild={canvas.selectExclusiveChild}
viewportScale={viewport.scale}
/>
@@ -32,9 +32,11 @@ type UiTreeRendererProps = {
renderMode: UiEditorRenderMode;
showFrame: boolean;
hiddenNodeIds: ReadonlySet<NodeId>;
previewTransforms?: ReadonlyMap<NodeId, UiNode['layout']['transform']>;
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
consumeNodeClick?: () => boolean;
onNodeContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
node: UiNode,
@@ -43,6 +45,7 @@ type UiTreeRendererProps = {
onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
@@ -50,6 +53,7 @@ type UiTreeRendererProps = {
) => void;
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onSelectExclusiveChild: (nodeId: NodeId) => void;
viewportScale: number;
};
@@ -70,6 +74,11 @@ const RESIZE_HANDLES: ReadonlyArray<{
{ id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' },
];
const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap<
NodeId,
UiNode['layout']['transform']
> = new Map();
function RenderNode({
node,
isRoot,
@@ -77,16 +86,20 @@ function RenderNode({
renderMode,
showFrame,
hiddenNodeIds,
previewTransforms,
selectedNodeId,
resources,
onSelectNode,
consumeNodeClick = () => false,
onNodeContextMenu,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodePointerCancel,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onNodeResizePointerCancel,
onSelectExclusiveChild,
viewportScale,
}: Omit<UiTreeRendererProps, 'tree'> & {
@@ -94,14 +107,16 @@ function RenderNode({
isRoot?: boolean;
parentContainer?: UiNode['layout']['container'];
}) {
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
if (hiddenNodeIds.has(node.id)) return null;
const previewTransform = activePreviewTransforms.get(node.id);
const layout = previewTransform
? { ...node.layout, transform: previewTransform }
: node.layout;
let geometry;
try {
geometry = controlLayoutToPreviewCss(
node.layout,
parentContainer !== undefined,
);
geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined);
} catch {
// Keep malformed nodes isolated from the rest of the tree.
return null;
@@ -109,6 +124,8 @@ function RenderNode({
const isEditorOverlay = renderMode === 'editor-overlay';
const isFrameVisible = isEditorOverlay || showFrame;
const receivesPointerGesture = parentContainer === undefined;
const hasDirectPointerGesture = receivesPointerGesture && !isRoot;
const exclusiveVisibleChildId =
node.children_display_mode === 'Exclusive'
? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds)
@@ -116,11 +133,11 @@ function RenderNode({
return (
<div
data-node-id={node.id}
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
className={`absolute min-h-0 min-w-0 select-none ${isRoot ? 'cursor-default' : 'cursor-move'}`}
style={{
...geometry,
...(parentContainer
? childInContainerToPreviewCss(node.layout, parentContainer)
? childInContainerToPreviewCss(layout, parentContainer)
: {}),
...containerToPreviewCss(node.layout.container),
...(isFrameVisible
@@ -137,10 +154,18 @@ function RenderNode({
}
: {}),
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
onClick={
hasDirectPointerGesture
? (event) => {
event.stopPropagation();
consumeNodeClick();
}
: (event) => {
event.stopPropagation();
if (consumeNodeClick()) return;
onSelectNode(node.id);
}
}
onContextMenu={(event) => {
if (!isEditorOverlay) return;
event.preventDefault();
@@ -149,11 +174,13 @@ function RenderNode({
onNodeContextMenu(event, node, Boolean(isRoot));
}}
onPointerDown={
parentContainer ? undefined : (event) => onNodePointerDown(event, node)
receivesPointerGesture
? (event) => onNodePointerDown(event, node)
: undefined
}
onPointerMove={parentContainer ? undefined : onNodePointerMove}
onPointerUp={parentContainer ? undefined : onNodePointerUp}
onPointerCancel={parentContainer ? undefined : onNodePointerUp}
onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined}
onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined}
onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined}
title={isFrameVisible ? node.metadata.name || undefined : undefined}
>
{isFrameVisible && node.metadata.name ? (
@@ -189,16 +216,20 @@ function RenderNode({
renderMode={renderMode}
showFrame={showFrame}
hiddenNodeIds={hiddenNodeIds}
previewTransforms={activePreviewTransforms}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
consumeNodeClick={consumeNodeClick}
onNodeContextMenu={onNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodePointerCancel={onNodePointerCancel}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onNodeResizePointerCancel={onNodeResizePointerCancel}
onSelectExclusiveChild={onSelectExclusiveChild}
viewportScale={viewportScale}
/>
@@ -228,7 +259,7 @@ function RenderNode({
}
onPointerMove={onNodeResizePointerMove}
onPointerUp={onNodeResizePointerUp}
onPointerCancel={onNodeResizePointerUp}
onPointerCancel={onNodeResizePointerCancel}
/>
))
: null}
@@ -9,9 +9,12 @@ import {
import {
findNodePageContext,
type PageRect,
pageRectFromSize,
type ResizeAxis,
type ResizeHandle,
resizePageRect,
resolveChildrenTransformsForParentRect,
resolvePageRect,
resolveProportionalResizeAxis,
setOffsetsForPageRect,
} from '../../../../features/ui-editor/nodeTransformGeometry';
@@ -28,7 +31,10 @@ type GestureBase = {
startClientX: number;
startClientY: number;
startTransform: UiNode['layout']['transform'];
hitNodeId: string;
hasMoved: boolean;
pendingTransform?: UiNode['layout']['transform'];
previewNodeIds: string[];
};
type ActiveGesture =
@@ -78,31 +84,151 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
}
}
function findNodeById(node: UiNode, nodeId: string): UiNode | null {
if (node.id === nodeId) return node;
for (const child of node.children) {
const match = findNodeById(child, nodeId);
if (match) return match;
}
return null;
}
function resolveHitNodeId(
event: ReactPointerEvent<HTMLDivElement>,
fallback: string,
) {
const target = event.target;
if (!(target instanceof Element)) return fallback;
return (
target.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId ?? fallback
);
}
function isInsidePreview(
event: ReactPointerEvent<HTMLDivElement>,
previewRef: RefObject<HTMLElement | null> | undefined,
) {
const preview = previewRef?.current;
if (!preview) return true;
const rect = preview.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
function previewNodeIds(
tree: UITree | null,
nodeId: string,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
) {
if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId];
const context = findNodePageContext(
tree.root,
nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
return context
? [nodeId, ...context.node.children.map((child) => child.id)]
: [nodeId];
}
function emitPreviewTransforms(
gesture: ActiveGesture,
tree: UITree | null,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
onPreviewTransform: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void,
) {
onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null);
if (
!gesture.pendingTransform ||
!keepChildrenUnchanged ||
!tree ||
!logicalSize
) {
return;
}
const context = findNodePageContext(
tree.root,
gesture.nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
if (!context) return;
const newNodeRect = resolvePageRect(
gesture.pendingTransform,
context.parentRect,
);
if (!isFiniteRect(newNodeRect)) return;
for (const child of resolveChildrenTransformsForParentRect(
context.node.children,
context.rect,
newNodeRect,
)) {
onPreviewTransform(child.id, child.transform);
}
}
export function useNodeTransformInteraction({
activeImageId,
canvas,
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged,
viewportRef,
onPreviewTransform,
previewRef,
selectedNodeId,
}: {
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
logicalSize: { width: number; height: number } | null;
spaceHeld: boolean;
tree: UITree | null;
keepChildrenUnchanged: boolean;
viewportRef: RefObject<ViewportScale>;
onPreviewTransform?: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void;
previewRef?: RefObject<HTMLElement | null>;
selectedNodeId?: string | null;
}) {
const activeGestureRef = useRef<ActiveGesture | null>(null);
// Keep cleanup stable while still invoking the latest preview callback.
const onPreviewTransformRef = useRef(onPreviewTransform);
onPreviewTransformRef.current = onPreviewTransform;
const suppressNextNodeClickRef = useRef(false);
const cancelGesture = useCallback(() => {
const gesture = activeGestureRef.current;
if (gesture) releasePointer(gesture.target, gesture.pointerId);
if (gesture) {
releasePointer(gesture.target, gesture.pointerId);
onPreviewTransformRef.current?.(gesture.nodeId, null);
for (const nodeId of gesture.previewNodeIds) {
if (nodeId !== gesture.nodeId) {
onPreviewTransformRef.current?.(nodeId, null);
}
}
}
activeGestureRef.current = null;
suppressNextNodeClickRef.current = false;
}, []);
useEffect(() => cancelGesture, [cancelGesture]);
useEffect(() => {
window.addEventListener('blur', cancelGesture);
return () => window.removeEventListener('blur', cancelGesture);
}, [cancelGesture]);
useEffect(() => {
const gesture = activeGestureRef.current;
if (
@@ -138,22 +264,42 @@ export function useNodeTransformInteraction({
) {
return;
}
const selectedNode =
selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null;
const dragNode =
selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node;
if (!isFiniteTransform(dragNode.layout.transform)) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
canvas.selectNode(node.id);
suppressNextNodeClickRef.current = false;
event.preventDefault();
activeGestureRef.current = {
kind: 'drag',
treeId: activeImageId,
nodeId: node.id,
nodeId: dragNode.id,
pointerId: event.pointerId,
target: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
startTransform: structuredClone(dragNode.layout.transform),
hitNodeId: resolveHitNodeId(event, node.id),
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
dragNode.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, spaceHeld, tree?.root.id],
[
activeImageId,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
selectedNodeId,
tree,
],
);
const onNodePointerMove = useCallback(
@@ -176,6 +322,7 @@ export function useNodeTransformInteraction({
return;
}
gesture.hasMoved = true;
suppressNextNodeClickRef.current = true;
const nextTransform = structuredClone(gesture.startTransform);
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
@@ -185,12 +332,50 @@ export function useNodeTransformInteraction({
cancelGesture();
return;
}
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
gesture.pendingTransform = nextTransform;
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
const onNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!acceptsGestureEvent(event)) return;
event.stopPropagation();
const gesture = activeGestureRef.current;
if (!gesture) return;
const cleanPointerUpInside =
!gesture?.hasMoved && isInsidePreview(event, previewRef);
if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId);
if (gesture.hasMoved && gesture.pendingTransform) {
canvas.updateNodeTransform(
gesture.treeId,
gesture.nodeId,
gesture.pendingTransform,
);
}
cancelGesture();
if (gesture.hasMoved || cleanPointerUpInside)
suppressNextNodeClickRef.current = true;
},
[acceptsGestureEvent, cancelGesture, canvas, previewRef],
);
const onNodePointerCancel = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!acceptsGestureEvent(event)) return;
event.stopPropagation();
@@ -236,7 +421,7 @@ export function useNodeTransformInteraction({
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
canvas.selectNode(node.id);
suppressNextNodeClickRef.current = false;
activeGestureRef.current = {
kind: 'resize',
treeId: activeImageId,
@@ -247,13 +432,20 @@ export function useNodeTransformInteraction({
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hitNodeId: node.id,
startRect: context.rect,
parentRect: context.parentRect,
ratioAxis: null,
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
node.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, logicalSize, spaceHeld, tree],
[activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree],
);
const onNodeResizePointerMove = useCallback(
@@ -310,17 +502,40 @@ export function useNodeTransformInteraction({
return;
}
gesture.hasMoved = true;
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
suppressNextNodeClickRef.current = true;
gesture.pendingTransform = nextTransform;
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
return {
consumeNodeClick: useCallback(() => {
if (!suppressNextNodeClickRef.current) return false;
suppressNextNodeClickRef.current = false;
return true;
}, []),
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodePointerCancel,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp: onNodePointerUp,
onNodeResizePointerCancel: onNodePointerCancel,
};
}
@@ -1,4 +1,4 @@
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, Redo2, Undo2 } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
@@ -77,11 +77,44 @@ export default function UiEditorPage({
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked;
const historyUndo = session.history.undo;
const historyRedo = session.history.redo;
useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && historyUndo()) {
event.preventDefault();
} else if (isRedo && historyRedo()) {
event.preventDefault();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyRedo, historyUndo]);
async function save(afterReturn = false) {
if (await session.save.save()) {
if (afterReturn) {
@@ -155,6 +188,26 @@ export default function UiEditorPage({
</button>
<strong className="text-sm">{resourceLabel ?? 'UI 设计'}</strong>
<div className="game-workbench-editor-actions">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
aria-label="撤销"
disabled={!session.history.canUndo || session.save.isLocked}
onClick={() => session.history.undo()}
>
<Undo2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
aria-label="重做"
disabled={!session.history.canRedo || session.save.isLocked}
onClick={() => session.history.redo()}
>
<Redo2 size={16} aria-hidden="true" />
</button>
{walletEntry ? (
<div className="game-workbench-editor-wallet">{walletEntry}</div>
) : null}
@@ -274,7 +274,7 @@ export function useUiEditorSession(
.load(resourceId)
.then(({ state, revision }) => {
if (!cancelled) {
replaceEditorState(state);
replaceEditorState(state, { history: 'reset' });
const ids = Object.keys(
state.ui_design_images,
).sort() as UIDesignImageId[];
@@ -1028,7 +1028,9 @@ export function useUiEditorSession(
spriteIds,
});
current = applyBindingResult(current, result);
editor.replaceState(current);
editor.replaceState(current, {
history: index < batches.length - 1 ? 'skip' : 'record',
});
}
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
@@ -1214,6 +1216,7 @@ export function useUiEditorSession(
tree: treeForActiveImage ?? null,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeId,
keepChildrenUnchanged,
hiddenNodeIds,
focusRequest,
status,
@@ -1228,6 +1231,11 @@ export function useUiEditorSession(
deleteNode,
openClearDialog: () => setClearOpen(true),
},
history: {
...editor.historyState,
undo: editor.undo,
redo: editor.redo,
},
inspector: {
isLocked: editor.isLocked,
projectPath,
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Transform } from '../src/features/ui-editor/types/Transform';
import { TransformEditor } from '../src/view/ui-editor/components/Inspector/Transform/TransformEditor';
const transform: Transform = {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [10, 20],
offset_max: [30, 40],
};
function firePointerDown(element: HTMLElement, button: number) {
const event = new Event('pointerdown', { bubbles: true });
Object.defineProperty(event, 'button', { value: button });
fireEvent(element, event);
}
function renderEditor(onChange = vi.fn()) {
return {
onChange,
...render(<TransformEditor transform={transform} onChange={onChange} />),
};
}
describe('TransformEditor corner offset controls', () => {
afterEach(() => {
vi.useRealTimers();
});
it('uses one X/Y input pair for the selected corner', () => {
renderEditor();
expect(
screen.getByRole('spinbutton', { name: '位置微调 X' }),
).toHaveProperty('value', '10');
expect(
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
).toHaveProperty('value', '20');
expect(screen.getAllByRole('spinbutton')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
expect(
screen.getByRole('spinbutton', { name: '位置微调 X' }),
).toHaveProperty('value', '30');
expect(
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
).toHaveProperty('value', '40');
});
it('writes the selected corner input back to the matching offset component', () => {
const { onChange } = renderEditor();
fireEvent.click(screen.getByRole('button', { name: '右上角' }));
const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' });
fireEvent.change(xInput, { target: { value: '55' } });
fireEvent.blur(xInput);
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [55, 40],
});
});
it('moves only the selected corner with direction keys', () => {
const { onChange } = renderEditor();
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
fireEvent.click(screen.getByRole('button', { name: '右下角向左' }));
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [29, 40],
});
fireEvent.click(screen.getByRole('button', { name: '右下角向上' }), {
shiftKey: true,
});
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [30, 30],
});
});
it('allows a click adjustment after a cancelled long press', () => {
vi.useFakeTimers();
const { onChange } = renderEditor();
const button = screen.getByRole('button', { name: '左上角向右' });
firePointerDown(button, 0);
act(() => {
vi.advanceTimersByTime(350);
});
fireEvent.pointerCancel(button);
fireEvent.click(button);
expect(onChange).toHaveBeenCalledTimes(2);
});
it('does not start repeating for non-primary pointer buttons', () => {
vi.useFakeTimers();
const { onChange } = renderEditor();
const button = screen.getByRole('button', { name: '左上角向右' });
firePointerDown(button, 2);
act(() => {
vi.advanceTimersByTime(350);
});
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -2314,303 +2314,6 @@ export function registerHomeProjectCreationTests() {
),
).toHaveLength(1);
});
it('releases a hydrated Direct Codex turn claim after an in-progress rejection so the same App can resume it later', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\running-direct-project';
const manifest = createGameCreationAppManifest(
'running-direct-project',
'运行中直连项目',
);
const stableTurnId = 'stable-running-turn-001';
let directTurnCallCount = 0;
const persistedMessages: Array<Record<string, unknown>> = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '继续完成运行中的项目',
agentId: null,
messageId: `direct-codex:${stableTurnId}:user`,
updatedAt: 1,
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_conversation') {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as Record<string, unknown>;
const messageId = String(args?.messageId ?? '');
if (
!messageId ||
!persistedMessages.some(
(candidate) => candidate.messageId === messageId,
)
) {
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
messageId,
updatedAt: Number(
message.updatedAt ?? persistedMessages.length + 1,
),
});
}
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'chat_with_game_creator_direct_codex') {
directTurnCallCount += 1;
if (directTurnCallCount === 1) {
throw new Error(
'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行',
);
}
return '恢复后的最终回复';
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
expect(await screen.findByText('继续完成运行中的项目')).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '继续完成运行中的项目',
clientTurnId: stableTurnId,
},
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(persistedMessages).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
(args as Record<string, unknown> | undefined)?.messageId ===
`direct-codex:${stableTurnId}:assistant`,
),
).toHaveLength(0);
expect(screen.queryByText(/direct-codex-turn-already-running/)).toBeNull();
const directComposer = screen.getByLabelText('陶泥儿对话内容');
fireEvent.change(directComposer, { target: { value: '/history' } });
fireEvent.submit(directComposer.closest('form') as HTMLFormElement);
expect(await screen.findByText('恢复后的最终回复')).not.toBeNull();
await waitFor(() => {
expect(directTurnCallCount).toBe(2);
expect(
persistedMessages.filter(
(message) =>
message.messageId === `direct-codex:${stableTurnId}:assistant`,
),
).toEqual([
expect.objectContaining({
role: 'assistant',
content: '恢复后的最终回复',
}),
]);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_conversation',
).length,
).toBeGreaterThanOrEqual(2);
});
it.each([
{
caseName: 'a successful reply',
firstError: null,
firstReply: '首次成功回复',
firstVisibleText: '首次成功回复',
},
{
caseName: 'an ordinary error reply',
firstError: 'codex-app-server-error:unauthorized',
firstReply: null,
firstVisibleText: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态',
},
])(
'reconciles a hydrated Direct Codex claim after persisting $caseName fails',
async ({ firstError, firstReply, firstVisibleText }) => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\retry-terminal-persistence';
const manifest = createGameCreationAppManifest(
'retry-terminal-persistence',
'终态持久化重试项目',
);
const stableTurnId = 'stable-terminal-persistence-001';
let directTurnCallCount = 0;
let allowAssistantPersistence = false;
let failedAssistantPersistenceCount = 0;
const directTurnIds: string[] = [];
const persistedMessages: Array<Record<string, unknown>> = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '恢复终态持久化失败的回合',
agentId: null,
messageId: `direct-codex:${stableTurnId}:user`,
updatedAt: 1,
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_conversation') {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as Record<string, unknown>;
if (message.role === 'assistant' && !allowAssistantPersistence) {
failedAssistantPersistenceCount += 1;
throw new Error('assistant conversation persistence unavailable');
}
const messageId = String(args?.messageId ?? '');
if (
!messageId ||
!persistedMessages.some(
(candidate) => candidate.messageId === messageId,
)
) {
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
messageId,
updatedAt: Number(
message.updatedAt ?? persistedMessages.length + 1,
),
});
}
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'chat_with_game_creator_direct_codex') {
directTurnCallCount += 1;
directTurnIds.push(String(args?.clientTurnId ?? ''));
if (directTurnCallCount === 1) {
if (firstError) {
throw new Error(firstError);
}
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: firstReply,
agentId: null,
messageId: `direct-codex:${stableTurnId}:assistant`,
updatedAt: 2,
});
return firstReply ?? '';
}
allowAssistantPersistence = true;
return '恢复后的最终回复';
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
expect(await screen.findByText(firstVisibleText)).not.toBeNull();
await waitFor(() => {
expect(directTurnCallCount).toBe(1);
expect(failedAssistantPersistenceCount).toBeGreaterThan(
firstError ? 1 : 0,
);
expect(persistedMessages).toHaveLength(firstError ? 1 : 2);
});
const directComposer = screen.getByLabelText('陶泥儿对话内容');
fireEvent.change(directComposer, { target: { value: '/history' } });
fireEvent.submit(directComposer.closest('form') as HTMLFormElement);
expect(
await screen.findByText(firstError ? '恢复后的最终回复' : firstReply!),
).not.toBeNull();
await waitFor(() => {
expect(directTurnCallCount).toBe(firstError ? 2 : 1);
expect(directTurnIds).toEqual(
firstError ? [stableTurnId, stableTurnId] : [stableTurnId],
);
expect(
persistedMessages.filter(
(message) =>
message.messageId === `direct-codex:${stableTurnId}:assistant`,
),
).toEqual([
expect.objectContaining({
role: 'assistant',
content: firstError ? '恢复后的最终回复' : firstReply,
}),
]);
});
},
);
}
export function registerRecentProjectsTests() {
@@ -1,7 +1,4 @@
import {
isDirectCodexTurnAlreadyRunningError,
unansweredDirectCodexConversationTurn,
} from '../../src/App';
import { isDirectCodexTurnAlreadyRunningError } from '../../src/App';
import {
act,
agentRuntimeUserInputRequest,
@@ -26,66 +23,6 @@ import {
} from './harness';
export function registerProjectConversationTests() {
it('replays only the latest unanswered Direct Codex turn with its original stable identity', () => {
expect(
unansweredDirectCodexConversationTurn([
{
role: 'user',
text: '请重新生成美术',
messageId: 'direct-codex:stable-turn-001:user',
},
]),
).toEqual({
prompt: '请重新生成美术',
turnId: 'stable-turn-001',
});
expect(
unansweredDirectCodexConversationTurn([
{
role: 'user',
text: '请重新生成美术',
messageId: 'direct-codex:stable-turn-001:user',
},
{
role: 'assistant',
text: '已完成',
messageId: 'direct-codex:stable-turn-001:assistant',
},
]),
).toBeNull();
expect(
unansweredDirectCodexConversationTurn([
{
role: 'user',
text: '恢复较早的未回答回合',
messageId: 'direct-codex:stable-turn-older:user',
},
{
role: 'user',
text: '较新的已回答回合',
messageId: 'direct-codex:stable-turn-newer:user',
},
{
role: 'assistant',
text: '较新的回复',
messageId: 'direct-codex:stable-turn-newer:assistant',
},
]),
).toEqual({
prompt: '恢复较早的未回答回合',
turnId: 'stable-turn-older',
});
expect(
unansweredDirectCodexConversationTurn([
{
role: 'user',
text: '伪造回合',
messageId: 'direct-codex:../unsafe:user',
},
]),
).toBeNull();
});
it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => {
expect(
isDirectCodexTurnAlreadyRunningError(

Some files were not shown because too many files have changed in this diff Show More