Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2292ae1fc1 | |||
| b077cb5277 | |||
| 8ad0487b8d | |||
| 417b5ec630 | |||
| 5adc16fbda | |||
| 6c98c19c12 | |||
| 41c59a37a0 | |||
| a6de1b5570 | |||
| 444bf9bc24 | |||
| 21c85c4dd1 | |||
| a45172e055 | |||
| e6d92b2d98 | |||
| ec02c39a5d | |||
| 14a0286f70 | |||
| 2f47540eeb | |||
| a2b016bb9b | |||
| b898590c7d | |||
| 4e277798b8 | |||
| 928a0ea13e | |||
| cdc0e3d163 | |||
| 8ac77cb090 | |||
| 7240b3793f | |||
| 4fff733516 | |||
| 5d336bf147 | |||
| 3d6d51737c | |||
| 1de661df10 | |||
| 6b5f67d207 | |||
| 74b3760d0f | |||
| 53026fe8f3 | |||
| bb7cbae118 | |||
| 0b3bf3e52d | |||
| 6d5cd51649 | |||
| 181e364d80 | |||
| 3b8ecb02d4 | |||
| d02d0ed7a4 | |||
| ef3e783ab1 | |||
| 7ce4a3a9c6 |
@@ -113,6 +113,11 @@ const allowedUncalledTauriCommands = [
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||||
// `start_local_project_asset_generation`。
|
||||
'generate_local_project_asset',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
|
||||
@@ -2829,8 +2829,6 @@ impl CodexAppServerConnection {
|
||||
callback(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text: streamed_text.clone(),
|
||||
delta_text: delta,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
});
|
||||
}
|
||||
@@ -3135,7 +3133,6 @@ fn parse_game_creator_codex_app_server_text(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some(thread_id.to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -589,7 +589,6 @@ fn parse_game_creator_codex_cli_response(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id,
|
||||
usage,
|
||||
|
||||
@@ -49,18 +49,6 @@ pub(crate) struct DesignView {
|
||||
messages: Vec<DesignMessage>,
|
||||
running: bool,
|
||||
can_retry: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_text: Option<String>,
|
||||
reasoning_entries: Vec<DesignReasoningEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignReasoningEntry {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -77,7 +65,6 @@ pub(crate) struct DesignEvent {
|
||||
}
|
||||
|
||||
fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||||
let reasoning_entries = persisted_design_reasoning_entries(session);
|
||||
DesignView {
|
||||
session: DesignSessionSummary {
|
||||
session_id: session.session_id.clone(),
|
||||
@@ -95,124 +82,9 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||||
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||
&& session.pending_approval.is_none()
|
||||
&& session.pending_clarification.is_none(),
|
||||
reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()),
|
||||
reasoning_entries,
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_text_from_history_item(item: &Value) -> Option<String> {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
return None;
|
||||
}
|
||||
let mut text = String::new();
|
||||
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
|
||||
for part in summary {
|
||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||||
text.push_str(value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(content) = item.get("content").and_then(Value::as_array) {
|
||||
for part in content {
|
||||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if matches!(
|
||||
part_type,
|
||||
"reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking"
|
||||
) {
|
||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||||
text.push_str(value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReasoningEntry> {
|
||||
// Responses history contains tool-only provider responses. Their reasoning is
|
||||
// followed by function calls and only the next provider response may contain
|
||||
// visible assistant text, so pairing on the next `message` item makes the
|
||||
// earlier reasoning look like an orphan and moves it to the bottom of the UI.
|
||||
// Both persisted streams retain user-turn boundaries; pair reasoning and
|
||||
// visible assistant messages by their response order within each turn.
|
||||
let mut assistant_groups: Vec<Vec<String>> = vec![Vec::new()];
|
||||
for message in &session.messages {
|
||||
if message.role == "user" {
|
||||
assistant_groups.push(Vec::new());
|
||||
} else if message.role == "assistant" {
|
||||
assistant_groups
|
||||
.last_mut()
|
||||
.expect("assistant group always exists")
|
||||
.push(message.id.clone());
|
||||
}
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut group_index = 0;
|
||||
let mut assistant_index = 0;
|
||||
let mut sequence = 0_u64;
|
||||
let mut current_reasoning = Vec::new();
|
||||
let mut pending_reasoning = Vec::new();
|
||||
let mut saw_response_output = false;
|
||||
|
||||
for item in &session.history {
|
||||
if item.get("role").and_then(Value::as_str) == Some("user") {
|
||||
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
}
|
||||
group_index += 1;
|
||||
assistant_index = 0;
|
||||
saw_response_output = false;
|
||||
continue;
|
||||
}
|
||||
if item.get("type").and_then(Value::as_str) == Some("reasoning") {
|
||||
if saw_response_output {
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
saw_response_output = false;
|
||||
}
|
||||
if let Some(text) = reasoning_text_from_history_item(item) {
|
||||
sequence += 1;
|
||||
current_reasoning.push(DesignReasoningEntry {
|
||||
id: item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("reasoning-{sequence}")),
|
||||
text,
|
||||
message_id: None,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
|| item.get("type").and_then(Value::as_str) == Some("message")
|
||||
{
|
||||
pending_reasoning.extend(current_reasoning.drain(..));
|
||||
let assistant_id = assistant_groups
|
||||
.get(group_index)
|
||||
.and_then(|ids| ids.get(assistant_index))
|
||||
.cloned();
|
||||
assistant_index += 1;
|
||||
for mut entry in pending_reasoning.drain(..) {
|
||||
entry.message_id = assistant_id.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
saw_response_output = false;
|
||||
} else if item.get("type").is_some() {
|
||||
saw_response_output = true;
|
||||
}
|
||||
}
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
let fallback_id = assistant_groups
|
||||
.get(group_index)
|
||||
.and_then(|ids| ids.last())
|
||||
.cloned();
|
||||
for mut entry in pending_reasoning {
|
||||
entry.message_id = fallback_id.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn design_event(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
@@ -232,17 +104,6 @@ fn design_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reasoning_event(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
id: Option<&str>,
|
||||
reasoning: String,
|
||||
) -> DesignEvent {
|
||||
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
|
||||
event.reasoning_text = Some(reasoning);
|
||||
event
|
||||
}
|
||||
|
||||
fn design_project_id(root: &Path) -> Result<String, String> {
|
||||
validate_project_root(root)?;
|
||||
Ok(read_existing_manifest_for_project(root)?.project_id)
|
||||
@@ -601,7 +462,6 @@ fn build_design_request(
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
||||
.with_web_search(false);
|
||||
apply_game_creator_llm_reasoning_effort(request, llm)
|
||||
.map(|request| request.with_reasoning_capture(true))
|
||||
}
|
||||
|
||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||
@@ -688,12 +548,6 @@ async fn request_design_provider(
|
||||
Some(String::new()),
|
||||
None,
|
||||
));
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
let result = if llm.stream {
|
||||
let mut stream_sequence = 0_u64;
|
||||
client
|
||||
@@ -711,30 +565,18 @@ async fn request_design_provider(
|
||||
"model": llm.model,
|
||||
"deltaChars": delta.delta_text.chars().count(),
|
||||
"accumulatedChars": delta.accumulated_text.chars().count(),
|
||||
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
|
||||
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
|
||||
"deltaText": delta.delta_text,
|
||||
"finishReason": delta.finish_reason,
|
||||
}),
|
||||
);
|
||||
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
|
||||
emit(design_event(
|
||||
root,
|
||||
&turn_id,
|
||||
"text",
|
||||
Some(&message_id),
|
||||
Some(delta.accumulated_text.clone()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if !delta.reasoning_delta.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
delta.accumulated_reasoning.clone(),
|
||||
));
|
||||
}
|
||||
emit(design_event(
|
||||
root,
|
||||
&turn_id,
|
||||
"text",
|
||||
Some(&message_id),
|
||||
Some(delta.accumulated_text.clone()),
|
||||
None,
|
||||
));
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
@@ -742,14 +584,6 @@ async fn request_design_provider(
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if !response.reasoning.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
response.reasoning.clone(),
|
||||
));
|
||||
}
|
||||
design_debug(
|
||||
root,
|
||||
"response",
|
||||
@@ -772,12 +606,6 @@ async fn request_design_provider(
|
||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||
.is_none()
|
||||
{
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err(detail);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
@@ -826,24 +654,8 @@ async fn request_scripted_design_provider(
|
||||
Some(String::new()),
|
||||
None,
|
||||
));
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
match fake_provider::take() {
|
||||
Some(Ok(response)) => {
|
||||
if !response.reasoning.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
response.reasoning.clone(),
|
||||
));
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
Some(Ok(response)) => return Ok(response),
|
||||
Some(Err(error)) => {
|
||||
let detail = redact_agent_runtime_error(
|
||||
root,
|
||||
@@ -854,24 +666,10 @@ async fn request_scripted_design_provider(
|
||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||
.is_none()
|
||||
{
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err(detail);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err("假 Provider 脚本耗尽".into());
|
||||
}
|
||||
None => return Err("假 Provider 脚本耗尽".into()),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
@@ -1142,9 +940,7 @@ pub(crate) fn set_design_agent_runtime_mode(
|
||||
"design.runtime-mode",
|
||||
)?;
|
||||
if active_runtime.trim() == "game" {
|
||||
if crate::assets::register_design_artifacts_at(root)? {
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
}
|
||||
crate::assets::register_design_artifacts_at(root)?;
|
||||
}
|
||||
write_design_runtime_mode(root, active_runtime.trim())
|
||||
}
|
||||
@@ -1380,38 +1176,6 @@ mod tests {
|
||||
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
||||
assert!(error.contains("游戏运行态"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
|
||||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read initial revision")
|
||||
.revision;
|
||||
assert_eq!(
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
|
||||
.expect("switch to game")
|
||||
.active_runtime,
|
||||
"game"
|
||||
);
|
||||
let after = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read committed revision")
|
||||
.revision;
|
||||
assert_eq!(after, before + 1);
|
||||
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
|
||||
.expect("repeat switch to game");
|
||||
let repeated = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read repeated revision")
|
||||
.revision;
|
||||
assert_eq!(repeated, after);
|
||||
}
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
@@ -1521,7 +1285,6 @@ mod tests {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "fake-design".into(),
|
||||
text: text.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some(if calls.is_empty() {
|
||||
"stop".into()
|
||||
} else {
|
||||
@@ -1582,110 +1345,6 @@ mod tests {
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
||||
let session = new_design_session("project", "quality");
|
||||
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
|
||||
.expect("design request");
|
||||
assert!(request.capture_reasoning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_reasoning_follows_response_order_across_tool_only_responses() {
|
||||
let mut session = new_design_session("project", "quality");
|
||||
session.messages = vec![
|
||||
DesignMessage {
|
||||
id: "turn:user".into(),
|
||||
role: "user".into(),
|
||||
text: "需求".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "call-1:tool".into(),
|
||||
role: "tool".into(),
|
||||
text: "读取资源".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "turn:response:0".into(),
|
||||
role: "assistant".into(),
|
||||
text: "给出方案".into(),
|
||||
},
|
||||
];
|
||||
session.history = vec![
|
||||
json!({"role":"user", "content":"需求"}),
|
||||
json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}),
|
||||
json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}),
|
||||
json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}),
|
||||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}),
|
||||
];
|
||||
|
||||
let entries = persisted_design_reasoning_entries(&session);
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("r1", Some("turn:response:0")),
|
||||
("r2", Some("turn:response:0")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
begin_design_turn(&mut session, "turn-reasoning");
|
||||
let mut response = fake_response("reasoning", "正文", Vec::new());
|
||||
response.reasoning = "先分析需求,再组织方案。".into();
|
||||
let _fake = fake_provider::install(vec![Ok(response)], 0);
|
||||
let mut events = Vec::new();
|
||||
let response =
|
||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||||
.await
|
||||
.expect("scripted provider");
|
||||
|
||||
let reasoning_events = events
|
||||
.iter()
|
||||
.filter_map(|event| event.reasoning_text.as_deref())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
|
||||
assert_eq!(response.text, "正文");
|
||||
assert!(session.history.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
begin_design_turn(&mut session, "turn-reasoning-retry");
|
||||
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
|
||||
response.reasoning = "重试后的推理".into();
|
||||
let _fake = fake_provider::install(
|
||||
vec![
|
||||
Err(platform_llm::LlmError::Upstream {
|
||||
status_code: 503,
|
||||
message: "busy".into(),
|
||||
}),
|
||||
Ok(response),
|
||||
],
|
||||
1,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
let response =
|
||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||||
.await
|
||||
.expect("scripted retry provider");
|
||||
|
||||
let reasoning_events = events
|
||||
.iter()
|
||||
.filter_map(|event| event.reasoning_text.as_deref())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
|
||||
assert_eq!(response.text, "重试后的正文");
|
||||
assert!(session.history.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+96
@@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生,
|
||||
/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。
|
||||
///
|
||||
/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与
|
||||
/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey`
|
||||
/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时
|
||||
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
|
||||
///
|
||||
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
|
||||
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
|
||||
root: &Path,
|
||||
context: &PlatformArtGenerationRuntimeContext,
|
||||
legacy_run_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if !is_standalone_platform_art_generation_runtime_context(context)
|
||||
|| legacy_run_id == context.run_id
|
||||
|| !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份
|
||||
// 旧账本迁移到新路径,或与新建账本互相覆盖。
|
||||
let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_generate.runtime.claim",
|
||||
)?;
|
||||
if game_creator_agent_runtime_external_generation_exists(
|
||||
root,
|
||||
&context.agent_id,
|
||||
&context.run_id,
|
||||
) {
|
||||
// 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。
|
||||
return Ok(false);
|
||||
}
|
||||
let legacy_relative_path =
|
||||
platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id);
|
||||
let Some(legacy_state) =
|
||||
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
|
||||
root,
|
||||
&legacy_relative_path,
|
||||
"External Editor 生成账本",
|
||||
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if legacy_state.agent_id != context.agent_id
|
||||
|| legacy_state.run_id != legacy_run_id
|
||||
|| legacy_state.action_fingerprint != context.action_fingerprint
|
||||
{
|
||||
// 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。
|
||||
return Ok(false);
|
||||
}
|
||||
let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id);
|
||||
let legacy_context = PlatformArtGenerationRuntimeContext {
|
||||
task_id: legacy_identity.clone(),
|
||||
session_id: legacy_identity.clone(),
|
||||
run_id: legacy_run_id.to_string(),
|
||||
action_id: legacy_identity,
|
||||
..context.clone()
|
||||
};
|
||||
let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
migrated.run_id = context.run_id.clone();
|
||||
migrated.task_id = context.task_id.clone();
|
||||
migrated.session_id = context.session_id.clone();
|
||||
migrated.action_id = context.action_id.clone();
|
||||
migrated.updated_at = unix_timestamp();
|
||||
write_platform_art_generation_runtime_state(root, &migrated)?;
|
||||
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_operation_id_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> Option<&str> {
|
||||
state.operation_id.as_deref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_run_id_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> &str {
|
||||
&state.run_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> &str {
|
||||
&state.action_fingerprint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
||||
root: &Path,
|
||||
|
||||
@@ -409,8 +409,6 @@ where
|
||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text,
|
||||
delta_text,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason,
|
||||
});
|
||||
}
|
||||
@@ -501,7 +499,6 @@ mod tests {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "interaction-test".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("interaction-response".to_string()),
|
||||
usage: None,
|
||||
|
||||
-10
@@ -115,7 +115,6 @@ fn persist_tool_plan_handoff_repair_chain(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -147,8 +146,6 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
|
||||
platform_llm::LlmStreamDelta {
|
||||
accumulated_text: accumulated_text.to_string(),
|
||||
delta_text: delta_text.to_string(),
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
@@ -1006,7 +1003,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: private_response.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1120,7 +1116,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1307,7 +1302,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: format!("capacity response {loop_iteration}"),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1442,7 +1436,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1559,7 +1552,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "cleanup handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1631,7 +1623,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "terminal handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1727,7 +1718,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "已成功但尚未消费的回复".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
|
||||
@@ -798,7 +798,6 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
let response = platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "test-model".to_string(),
|
||||
reasoning: String::new(),
|
||||
text: "C:\\private\\response".to_string(),
|
||||
finish_reason: Some("completed".to_string()),
|
||||
response_id: Some("response-1".to_string()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
|
||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||
}
|
||||
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||
let design_root = root.join("design_artifacts");
|
||||
if !design_root.exists() {
|
||||
return Ok(false);
|
||||
return Ok(0);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut directories = vec![design_root];
|
||||
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut changed = false;
|
||||
let mut registered = 0;
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
Some("yaml" | "yml") => "text/yaml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
||||
register_local_asset_at(
|
||||
root,
|
||||
&relative,
|
||||
"document",
|
||||
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
changed |= asset_changed;
|
||||
registered += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
Ok(registered)
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_asset_at(
|
||||
@@ -1876,18 +1876,6 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
@@ -1900,7 +1888,7 @@ fn register_local_asset_entry_with_change(
|
||||
let mut source_for_record = source.clone();
|
||||
source_for_record.prompt = None;
|
||||
|
||||
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
|
||||
let (id, record_type) = mutate_manifest_at(root, |manifest| {
|
||||
if let Some(existing) = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
@@ -1910,16 +1898,13 @@ fn register_local_asset_entry_with_change(
|
||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||
// 时才触发),于是这个资产永远停在错误栏目。
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
Ok((existing.id.clone(), "asset.update"))
|
||||
} else {
|
||||
let id = format!(
|
||||
"{id_prefix}-{}-{}",
|
||||
@@ -1937,7 +1922,7 @@ fn register_local_asset_entry_with_change(
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register", true))
|
||||
Ok((id, "asset.register"))
|
||||
}
|
||||
})?;
|
||||
append_agent_db_record(
|
||||
@@ -1952,15 +1937,12 @@ fn register_local_asset_entry_with_change(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok((
|
||||
UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
},
|
||||
changed,
|
||||
))
|
||||
Ok(UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -2155,27 +2137,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn design_artifact_registration_reports_only_real_manifest_changes() {
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
assert!(register_design_artifacts_at(root).expect("register first time"));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root)
|
||||
.unwrap()
|
||||
.assets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -517,7 +517,11 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
let result = (|| {
|
||||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||||
prepare_game_creator_private_path_for_read(
|
||||
&project_root,
|
||||
true,
|
||||
"自动项目目录",
|
||||
)?;
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
init_local_game_project_at(
|
||||
|
||||
@@ -1299,12 +1299,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
#[cfg(all(windows, test))]
|
||||
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
||||
#[cfg(windows)]
|
||||
// This invocation created the directory, so initialize its
|
||||
// owner/DACL in-process. Marker-based managed-path detection
|
||||
// must not route a newly-created descendant into UAC.
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -1343,11 +1346,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
fs::create_dir(&directory).map_err(|retry_error| {
|
||||
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
||||
})?;
|
||||
// The retry also created this directory in the current
|
||||
// process; keep it on the local hardening path.
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
|
||||
@@ -905,7 +905,6 @@ mod tests {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "context-compaction-test".to_string(),
|
||||
text: summary.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("context-compaction-response".to_string()),
|
||||
usage: Some(platform_llm::LlmTokenUsage {
|
||||
|
||||
@@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview {
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) pixel_width: u32,
|
||||
pub(crate) pixel_height: u32,
|
||||
/// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。
|
||||
///
|
||||
/// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」,
|
||||
/// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图
|
||||
/// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。
|
||||
pub(crate) has_alpha: bool,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
@@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
false,
|
||||
)?;
|
||||
cancellation.check()?;
|
||||
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
|
||||
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
|
||||
// 的不透明图都不会因此变慢。
|
||||
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
|
||||
Ok(LocalProjectImagePreview {
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
pixel_width: image.pixel_width,
|
||||
pixel_height: image.pixel_height,
|
||||
has_alpha,
|
||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||
})
|
||||
}
|
||||
@@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
|
||||
}
|
||||
}
|
||||
|
||||
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
|
||||
/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
|
||||
///
|
||||
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
|
||||
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
|
||||
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
|
||||
///
|
||||
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
|
||||
/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
|
||||
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`;
|
||||
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
|
||||
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
|
||||
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
|
||||
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
|
||||
match media_type {
|
||||
"image/png" => detect_png_has_alpha(bytes),
|
||||
"image/webp" => detect_webp_has_alpha(bytes),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
|
||||
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
|
||||
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
|
||||
return false;
|
||||
}
|
||||
if matches!(bytes[25], 4 | 6) {
|
||||
return true;
|
||||
}
|
||||
png_has_transparency_chunk(bytes)
|
||||
}
|
||||
|
||||
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
|
||||
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
|
||||
let mut offset = 8usize;
|
||||
loop {
|
||||
let Some(header_end) = offset.checked_add(8) else {
|
||||
return false;
|
||||
};
|
||||
if header_end > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let chunk_type = &bytes[offset + 4..header_end];
|
||||
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
|
||||
if chunk_type == b"tRNS" {
|
||||
return true;
|
||||
}
|
||||
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
|
||||
return false;
|
||||
}
|
||||
let chunk_len =
|
||||
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
|
||||
let Some(next) = header_end
|
||||
.checked_add(chunk_len)
|
||||
.and_then(|value| value.checked_add(4))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if next <= offset || next > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
offset = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
|
||||
return false;
|
||||
}
|
||||
match &bytes[12..16] {
|
||||
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
|
||||
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
|
||||
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
|
||||
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TiffByteOrder {
|
||||
LittleEndian,
|
||||
@@ -745,6 +834,74 @@ mod tests {
|
||||
.expect("valid 1x1 png")
|
||||
}
|
||||
|
||||
/// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。
|
||||
fn png_header(color_type: u8) -> Vec<u8> {
|
||||
png_header_with_size(color_type, 1, 1)
|
||||
}
|
||||
|
||||
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&width.to_be_bytes());
|
||||
ihdr.extend_from_slice(&height.to_be_bytes());
|
||||
ihdr.push(8);
|
||||
ihdr.push(color_type);
|
||||
ihdr.extend_from_slice(&[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IHDR", &ihdr);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。
|
||||
/// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明
|
||||
/// 判据没有解码像素。
|
||||
fn push_png_chunk(bytes: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
bytes.extend_from_slice(
|
||||
&u32::try_from(data.len())
|
||||
.expect("chunk length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
bytes.extend_from_slice(kind);
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。
|
||||
fn webp_vp8x(flags: u8) -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8X");
|
||||
bytes.extend_from_slice(&10_u32.to_le_bytes());
|
||||
bytes.push(flags);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。
|
||||
fn webp_vp8l(has_alpha: bool) -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8L");
|
||||
bytes.extend_from_slice(&5_u32.to_le_bytes());
|
||||
bytes.push(0x2f);
|
||||
bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走
|
||||
/// `VP8X` 扩展格式(+ `ALPH` chunk)。
|
||||
fn webp_vp8_simple() -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8 ");
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
if let Some(payload) = app1_payload {
|
||||
@@ -840,6 +997,138 @@ mod tests {
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
assert_eq!(preview.byte_len, png_bytes().len() as u64);
|
||||
assert!(preview.data_url.starts_with("data:image/png;base64,"));
|
||||
// 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」——
|
||||
// 资源卡据此才铺棋盘格底。
|
||||
assert!(preview.has_alpha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_alpha_follows_color_type_and_transparency_chunk() {
|
||||
let color_type_alpha = |color_type: u8| {
|
||||
let mut bytes = png_header(color_type);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
detect_raster_image_has_alpha(&bytes, "image/png")
|
||||
};
|
||||
|
||||
// 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。
|
||||
assert!(color_type_alpha(4), "colorType 4 应判为有 alpha");
|
||||
assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha");
|
||||
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
|
||||
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
|
||||
assert!(
|
||||
!color_type_alpha(2),
|
||||
"PNG-24(colorType 2)不应判为有 alpha"
|
||||
);
|
||||
assert!(
|
||||
!color_type_alpha(3),
|
||||
"colorType 3 无 tRNS 时不应判为有 alpha"
|
||||
);
|
||||
// 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。
|
||||
assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha");
|
||||
|
||||
// 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。
|
||||
for color_type in [0_u8, 2, 3] {
|
||||
let mut bytes = png_header(color_type);
|
||||
push_png_chunk(&mut bytes, b"tRNS", &[0]);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
assert!(
|
||||
detect_raster_image_has_alpha(&bytes, "image/png"),
|
||||
"colorType {color_type} + tRNS 也是真透明 PNG"
|
||||
);
|
||||
}
|
||||
|
||||
// tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。
|
||||
let mut late_trns = png_header(3);
|
||||
push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut late_trns, b"tRNS", &[0]);
|
||||
push_png_chunk(&mut late_trns, b"IEND", &[]);
|
||||
assert!(!detect_raster_image_has_alpha(&late_trns, "image/png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_and_webp_alpha_follow_container_flags() {
|
||||
// JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&jpeg_bytes(40, 20, None),
|
||||
"image/jpeg"
|
||||
));
|
||||
// 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。
|
||||
assert!(detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x10),
|
||||
"image/webp"
|
||||
));
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x00),
|
||||
"image/webp"
|
||||
));
|
||||
// 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x28),
|
||||
"image/webp"
|
||||
));
|
||||
// 无损 VP8L 的 alpha_is_used 位。
|
||||
assert!(detect_raster_image_has_alpha(
|
||||
&webp_vp8l(true),
|
||||
"image/webp"
|
||||
));
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8l(false),
|
||||
"image/webp"
|
||||
));
|
||||
// 简单有损格式不带 alpha 通道。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8_simple(),
|
||||
"image/webp"
|
||||
));
|
||||
|
||||
// 头部被截断时失败关闭为「不透明」,且不得 panic。
|
||||
let truncated_webp = webp_vp8x(0x10);
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&truncated_webp[..18],
|
||||
"image/webp"
|
||||
));
|
||||
let truncated_png = png_header(6);
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&truncated_png[..20],
|
||||
"image/png"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_judgement_never_decodes_pixels() {
|
||||
// 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法
|
||||
// deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功,
|
||||
// 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
let mut bytes = png_header_with_size(6, 4_096, 4_096);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image");
|
||||
|
||||
let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png")
|
||||
.expect("header-only preview");
|
||||
|
||||
assert_eq!(preview.pixel_width, 4_096);
|
||||
assert_eq!(preview.byte_len, bytes.len() as u64);
|
||||
assert!(preview.has_alpha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_preview_serializes_alpha_flag_for_the_shell() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
|
||||
|
||||
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
|
||||
.expect("load project preview");
|
||||
|
||||
// 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`);
|
||||
// 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。
|
||||
let serialized = serde_json::to_value(&preview).expect("serialize preview");
|
||||
assert_eq!(serialized["hasAlpha"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -244,6 +244,7 @@ macro_rules! app_log {
|
||||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||||
mod agent;
|
||||
mod agent_native_tools;
|
||||
mod asset_generation_tasks;
|
||||
mod assets;
|
||||
mod browser;
|
||||
mod builtin_plugins;
|
||||
@@ -289,6 +290,7 @@ mod windows;
|
||||
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
use assets::*;
|
||||
use browser::*;
|
||||
use cli::*;
|
||||
@@ -2736,6 +2738,8 @@ fn main() {
|
||||
ensure_ui_design_resource_for_prototype,
|
||||
generate_platform_art_asset,
|
||||
generate_local_project_asset,
|
||||
start_local_project_asset_generation,
|
||||
list_local_project_asset_generations,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
get_limited_local_commands,
|
||||
|
||||
@@ -51,7 +51,6 @@ impl AgentRuntimeProviderHandoffRecord {
|
||||
provider: self.response.provider,
|
||||
model: self.response.model.clone(),
|
||||
text: self.response.text.clone(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: self.response.finish_reason.clone(),
|
||||
response_id: self.response.response_id.clone(),
|
||||
usage: self.response.usage.clone(),
|
||||
@@ -341,7 +340,6 @@ mod tests {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "handoff-model".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("response-handoff".to_string()),
|
||||
usage: Some(LlmTokenUsage {
|
||||
|
||||
@@ -2775,7 +2775,6 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "provider-handoff-runner-test".to_string(),
|
||||
text: "durable final reply".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("provider-handoff-response".to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -4475,7 +4475,6 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "real-e2e-checkpoint-model".to_string(),
|
||||
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
||||
usage: None,
|
||||
@@ -4721,7 +4720,6 @@ fn agent_tool_plan_llm_response(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "mock-game-model".to_string(),
|
||||
text: text.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("response-tool-plan-test".to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -127,7 +127,6 @@ impl AgentRuntimeToolPlanHandoffEntry {
|
||||
provider: self.response.provider,
|
||||
model: self.response.model.clone(),
|
||||
text,
|
||||
reasoning: String::new(),
|
||||
finish_reason: self.response.finish_reason.clone(),
|
||||
response_id: self.response.response_id.clone(),
|
||||
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
||||
|
||||
@@ -84,7 +84,6 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "tool-plan-handoff-model".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("tool-plan-handoff-response".to_string()),
|
||||
usage: Some(LlmTokenUsage {
|
||||
|
||||
@@ -596,12 +596,6 @@ export function App({
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
} | null>(null);
|
||||
const designAgentEventSubscriptionReadyRef = useRef<Promise<void> | null>(
|
||||
null,
|
||||
);
|
||||
const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>(
|
||||
null,
|
||||
);
|
||||
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
|
||||
// Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。
|
||||
// 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。
|
||||
@@ -827,17 +821,7 @@ export function App({
|
||||
useState('');
|
||||
const planningV2TransientReplyTargetRef = useRef('');
|
||||
const planningV2VisibleReplyRef = useRef('');
|
||||
const designAgentPendingViewRef = useRef<{
|
||||
clientTurnId: string;
|
||||
projectPath: string;
|
||||
view: DesignView;
|
||||
} | null>(null);
|
||||
const [planningV2Reasoning, setPlanningV2Reasoning] = useState('');
|
||||
const designAgentReasoningTurnRef = useRef<{
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const planningV2TurnRef = useRef<{
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
@@ -865,22 +849,6 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
function designAgentEventSubscriptionReady() {
|
||||
if (!designAgentEventSubscriptionReadyRef.current) {
|
||||
designAgentEventSubscriptionReadyRef.current = new Promise<void>(
|
||||
(resolve) => {
|
||||
designAgentEventSubscriptionResolveRef.current = resolve;
|
||||
},
|
||||
);
|
||||
}
|
||||
return designAgentEventSubscriptionReadyRef.current;
|
||||
}
|
||||
|
||||
function resolveDesignAgentEventSubscriptionReady() {
|
||||
designAgentEventSubscriptionResolveRef.current?.();
|
||||
designAgentEventSubscriptionResolveRef.current = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const target = planningV2TransientReplyTargetRef.current;
|
||||
@@ -1012,15 +980,6 @@ export function App({
|
||||
}
|
||||
|
||||
function designMessagesToChat(view: DesignView): ChatMessage[] {
|
||||
const reasoningByMessageId = new Map<string, string[]>();
|
||||
for (const entry of view.reasoningEntries ?? []) {
|
||||
if (!entry.messageId) {
|
||||
continue;
|
||||
}
|
||||
const texts = reasoningByMessageId.get(entry.messageId) ?? [];
|
||||
texts.push(entry.text);
|
||||
reasoningByMessageId.set(entry.messageId, texts);
|
||||
}
|
||||
return view.messages
|
||||
.filter((message) => message.text.trim())
|
||||
.map((message) => ({
|
||||
@@ -1028,7 +987,6 @@ export function App({
|
||||
text: message.text,
|
||||
runtimeOwned: true,
|
||||
messageId: message.id,
|
||||
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
@@ -1047,67 +1005,6 @@ export function App({
|
||||
latestMessagesRef.current = conversation;
|
||||
}
|
||||
|
||||
function commitDesignAgentView(view: DesignView, projectPath: string) {
|
||||
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
|
||||
designAgentPendingViewRef.current = null;
|
||||
applyDesignView(view, projectPath);
|
||||
setPlanningV2Reasoning('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
|
||||
designAgentTurnRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDesignAgentViewAfterTransient(
|
||||
view: DesignView,
|
||||
projectPath: string,
|
||||
clientTurnId: string,
|
||||
) {
|
||||
let target = planningV2TransientReplyTargetRef.current;
|
||||
const tracked = designAgentTurnRef.current;
|
||||
if (!target.trim() && !view.running) {
|
||||
const latestAssistantText = [...view.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== 'user' && message.text.trim())
|
||||
?.text.trim();
|
||||
if (latestAssistantText) {
|
||||
setPlanningV2TransientReplyTarget(latestAssistantText);
|
||||
target = latestAssistantText;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!view.running &&
|
||||
tracked?.clientTurnId === clientTurnId &&
|
||||
target.trim() &&
|
||||
planningV2VisibleReplyRef.current !== target
|
||||
) {
|
||||
designAgentPendingViewRef.current = {
|
||||
clientTurnId,
|
||||
projectPath,
|
||||
view,
|
||||
};
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(view, projectPath);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const pending = designAgentPendingViewRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const target = planningV2TransientReplyTargetRef.current;
|
||||
if (target && planningV2VisibleReplyRef.current !== target) {
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(pending.view, pending.projectPath);
|
||||
}, 50);
|
||||
return () => window.clearInterval(timer);
|
||||
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function hydrateDesignAgentSession(nextProjectPath: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || !nextProjectPath.trim()) {
|
||||
@@ -1141,17 +1038,9 @@ export function App({
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
text: '',
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
await designAgentEventSubscriptionReady();
|
||||
setChatAgentBusy(true);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
try {
|
||||
const view = await invoke<DesignView>('continue_design_agent_session', {
|
||||
projectPath: nextProjectPath,
|
||||
@@ -1161,7 +1050,7 @@ export function App({
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
|
||||
applyDesignView(view, nextProjectPath);
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
@@ -1173,10 +1062,8 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(message);
|
||||
setPlanGddError(message);
|
||||
} finally {
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -1668,9 +1555,6 @@ export function App({
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2Session(null);
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
designAgentPendingViewRef.current = null;
|
||||
designAgentReasoningTurnRef.current = null;
|
||||
setPlanningV2Reasoning('');
|
||||
setPlanningV2Active(planningStartMode);
|
||||
planningV2ActiveRef.current = planningStartMode;
|
||||
designAgentLaneRef.current = planningStartMode;
|
||||
@@ -2106,14 +1990,8 @@ export function App({
|
||||
}, [planningV2Active]);
|
||||
|
||||
useEffect(() => {
|
||||
const ready = designAgentEventSubscriptionReady();
|
||||
if (!canSubscribeTauriEvents() || !planningV2Active) {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
return () => {
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
let cleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
@@ -2130,45 +2008,26 @@ export function App({
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.reasoningText != null) {
|
||||
const reasoningTurn = designAgentReasoningTurnRef.current;
|
||||
if (
|
||||
reasoningTurn &&
|
||||
reasoningTurn.projectPath === payload.projectPath &&
|
||||
reasoningTurn.clientTurnId === payload.clientTurnId
|
||||
) {
|
||||
reasoningTurn.text = payload.reasoningText;
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
}
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
}
|
||||
if (payload.kind === 'tool' && payload.text) {
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.view) {
|
||||
applyDesignAgentViewAfterTransient(
|
||||
payload.view,
|
||||
payload.projectPath,
|
||||
payload.clientTurnId,
|
||||
);
|
||||
applyDesignView(payload.view, payload.projectPath);
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
cleanup = unlisten;
|
||||
})
|
||||
.catch(() => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
});
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
// applyDesignView 读的是 refs 和当前项目路径,
|
||||
// 把它写进依赖会在每轮回复时重订事件。
|
||||
@@ -6213,7 +6072,6 @@ export function App({
|
||||
setChatAgentBusy(true);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
try {
|
||||
const result = currentSessionId
|
||||
? await invoke<PlanningSessionCommandResultV2>(
|
||||
@@ -11939,9 +11797,6 @@ export function App({
|
||||
: projectSupervisorTransientReply
|
||||
}
|
||||
designReasoning={planningV2Reasoning}
|
||||
designReasoningEntries={
|
||||
useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : []
|
||||
}
|
||||
visibleMessages={visibleMessages}
|
||||
visibleProfessionalAgentCards={visibleProfessionalAgentCards}
|
||||
showProfessionalCollaboration={
|
||||
@@ -11969,25 +11824,15 @@ export function App({
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
text: '',
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
setChatAgentBusy(true);
|
||||
setPlanGddDecisionBusy(true);
|
||||
void designAgentEventSubscriptionReady()
|
||||
.then(() =>
|
||||
invoke<DesignView>('decide_design_phase', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
}),
|
||||
)
|
||||
void invoke<DesignView>('decide_design_phase', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
})
|
||||
.then((view) => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
@@ -11997,11 +11842,7 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyDesignAgentViewAfterTransient(
|
||||
view,
|
||||
nextProjectPath,
|
||||
clientTurnId,
|
||||
);
|
||||
applyDesignView(view, nextProjectPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
@@ -12023,10 +11864,8 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setChatAgentBusy(false);
|
||||
setPlanGddDecisionBusy(false);
|
||||
});
|
||||
|
||||
@@ -993,7 +993,6 @@ export interface ChatMessage {
|
||||
draftCommand?: string;
|
||||
draftCommandLabel?: string;
|
||||
messageId?: string | null;
|
||||
reasoningText?: string;
|
||||
agentId?: string | null;
|
||||
updatedAt?: number;
|
||||
runtimeOwned?: boolean;
|
||||
@@ -1039,19 +1038,11 @@ export interface DesignAgentMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DesignReasoningEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
export interface DesignView {
|
||||
session: DesignSessionSummary;
|
||||
messages: DesignAgentMessage[];
|
||||
running: boolean;
|
||||
canRetry: boolean;
|
||||
reasoningText?: string | null;
|
||||
reasoningEntries?: DesignReasoningEntry[];
|
||||
}
|
||||
|
||||
export interface DesignEvent {
|
||||
|
||||
+3
-33
@@ -16,11 +16,7 @@ import type {
|
||||
PlanGddDecisionAction,
|
||||
PlanGddStateViewV1,
|
||||
} from '../../app/types';
|
||||
import type {
|
||||
DesignClarificationRequest,
|
||||
DesignReasoningEntry,
|
||||
DesignView,
|
||||
} from '../../app/types';
|
||||
import type { DesignClarificationRequest, DesignView } from '../../app/types';
|
||||
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
||||
import {
|
||||
projectProfessionalAgentLabel,
|
||||
@@ -101,7 +97,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
showProfessionalCollaboration?: boolean;
|
||||
transientReply: string;
|
||||
designReasoning?: string;
|
||||
designReasoningEntries?: DesignReasoningEntry[];
|
||||
visibleMessages: ChatMessage[];
|
||||
visibleProfessionalAgentCards: AgentStatusCard[];
|
||||
workspaceStatus: string;
|
||||
@@ -154,7 +149,6 @@ export function ProjectSupervisorView({
|
||||
showProfessionalCollaboration = true,
|
||||
transientReply,
|
||||
designReasoning = '',
|
||||
designReasoningEntries = [],
|
||||
visibleMessages,
|
||||
visibleProfessionalAgentCards,
|
||||
workspaceStatus,
|
||||
@@ -266,35 +260,11 @@ export function ProjectSupervisorView({
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
{message.reasoningText ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{message.reasoningText}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{designReasoningEntries
|
||||
.filter((entry) => !entry.messageId)
|
||||
.map((entry) => (
|
||||
<details
|
||||
key={`reasoning-${entry.id}`}
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{entry.text}</pre>
|
||||
</details>
|
||||
))}
|
||||
{designReasoning ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<details className="design-agent-reasoning">
|
||||
<summary>显示思考过程</summary>
|
||||
<pre>{designReasoning}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。
|
||||
*
|
||||
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
|
||||
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
|
||||
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
|
||||
*
|
||||
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
|
||||
* 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
|
||||
*/
|
||||
.game-resource-type-dialog {
|
||||
width: min(480px, 100%);
|
||||
max-height: min(720px, calc(100dvh - 40px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/*
|
||||
* body 分三段:提示 / 选项列表 / 错误提示。
|
||||
*
|
||||
* `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表
|
||||
* (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。
|
||||
*/
|
||||
.game-resource-type-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。
|
||||
*/
|
||||
.game-resource-type-hint {
|
||||
margin: 0;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 纵向单选列表(`role="radiogroup"`):**一行一个选项**。
|
||||
*
|
||||
* 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。
|
||||
* 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行,
|
||||
* 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`),
|
||||
* 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。
|
||||
*/
|
||||
.game-resource-type-options {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-auto-flow: row;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
max-height: min(320px, 40dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/*
|
||||
* 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left /
|
||||
* 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。
|
||||
*
|
||||
* `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽
|
||||
* 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。
|
||||
* `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。
|
||||
*/
|
||||
.game-resource-type-option {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: rgb(255 255 255 / 62%);
|
||||
color: var(--platform-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-resource-type-option:hover:not(:disabled) {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。
|
||||
*
|
||||
* 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)`
|
||||
* 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`((0,2,0))会在悬停时
|
||||
* 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。
|
||||
*/
|
||||
.game-resource-type-options .game-resource-type-option[aria-checked='true'],
|
||||
.game-resource-type-options
|
||||
.game-resource-type-option[aria-checked='true']:hover:not(:disabled) {
|
||||
border-color: var(--platform-warm-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-resource-type-error {
|
||||
margin: 0;
|
||||
color: #b3261e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 第二入口:信息浮层「分类」行右侧的入口按钮。
|
||||
*
|
||||
* 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用,
|
||||
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
|
||||
*/
|
||||
.game-resource-info-field-action {
|
||||
align-self: start;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-info-field-action:hover,
|
||||
.game-resource-info-field-action:focus-visible {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user