Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09a9fa7b10 | |||
| 7092689b36 | |||
| 896cfad81e | |||
| 9f40f044a5 | |||
| f7c8b9f217 | |||
| 13e137191a | |||
| fd88e516b8 | |||
| 1b7bc95914 | |||
| b4a9be4581 | |||
| e174b6dcf4 | |||
| e8f9929630 | |||
| deb327ce1f | |||
| 0635ddfdb1 | |||
| 6ab7047eff | |||
| 33336d6242 |
@@ -957,6 +957,9 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
||||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
||||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
||||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||||
|
||||
|
||||
@@ -2829,6 +2829,8 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -3133,6 +3135,7 @@ 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,6 +589,7 @@ fn parse_game_creator_codex_cli_response(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id,
|
||||
usage,
|
||||
|
||||
@@ -104,6 +104,17 @@ 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)
|
||||
@@ -462,6 +473,7 @@ 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))
|
||||
}
|
||||
|
||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||
@@ -548,6 +560,12 @@ 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
|
||||
@@ -565,18 +583,30 @@ 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,
|
||||
}),
|
||||
);
|
||||
emit(design_event(
|
||||
root,
|
||||
&turn_id,
|
||||
"text",
|
||||
Some(&message_id),
|
||||
Some(delta.accumulated_text.clone()),
|
||||
None,
|
||||
));
|
||||
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(),
|
||||
));
|
||||
}
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
@@ -584,6 +614,14 @@ 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",
|
||||
@@ -606,6 +644,12 @@ 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(
|
||||
@@ -654,8 +698,24 @@ 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)) => return Ok(response),
|
||||
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(Err(error)) => {
|
||||
let detail = redact_agent_runtime_error(
|
||||
root,
|
||||
@@ -666,10 +726,24 @@ 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 => return Err("假 Provider 脚本耗尽".into()),
|
||||
None => {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err("假 Provider 脚本耗尽".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
@@ -795,10 +869,15 @@ pub(crate) async fn continue_design_agent_at(
|
||||
.ok_or("策划 Agent 当前正在工作")?;
|
||||
let mut session = match read_design_session(root)? {
|
||||
Some(session) => session,
|
||||
None => new_design_session(
|
||||
&project_id,
|
||||
&load_game_creator_app_config()?.selected_model_id,
|
||||
),
|
||||
None => {
|
||||
if read_planning_session_v2(root)?.is_some() {
|
||||
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
|
||||
}
|
||||
new_design_session(
|
||||
&project_id,
|
||||
&load_game_creator_app_config()?.selected_model_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
@@ -1280,6 +1359,7 @@ 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 {
|
||||
@@ -1340,6 +1420,69 @@ 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);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -409,6 +409,8 @@ where
|
||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text,
|
||||
delta_text,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason,
|
||||
});
|
||||
}
|
||||
@@ -499,6 +501,7 @@ 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,6 +115,7 @@ 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,
|
||||
@@ -146,6 +147,8 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1006,7 @@ 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,
|
||||
@@ -1116,6 +1120,7 @@ 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,
|
||||
@@ -1302,6 +1307,7 @@ 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,
|
||||
@@ -1436,6 +1442,7 @@ 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,
|
||||
@@ -1552,6 +1559,7 @@ 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,
|
||||
@@ -1623,6 +1631,7 @@ 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,
|
||||
@@ -1718,6 +1727,7 @@ 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,
|
||||
|
||||
@@ -8,6 +8,8 @@ mod finalization;
|
||||
mod json_sidecar;
|
||||
mod models;
|
||||
mod planning_gdd_model;
|
||||
mod planning_policy_v2;
|
||||
mod planning_session_v2;
|
||||
mod provider_control;
|
||||
mod provider_retry;
|
||||
mod real_e2e_checkpoint;
|
||||
@@ -23,6 +25,8 @@ pub(in crate::agent) use finalization::*;
|
||||
pub(in crate::agent) use json_sidecar::*;
|
||||
pub(in crate::agent) use models::*;
|
||||
pub(crate) use planning_gdd_model::*;
|
||||
pub(crate) use planning_policy_v2::*;
|
||||
pub(crate) use planning_session_v2::*;
|
||||
pub(in crate::agent) use provider_control::*;
|
||||
pub(in crate::agent) use provider_retry::*;
|
||||
pub(in crate::agent) use real_e2e_checkpoint::*;
|
||||
|
||||
+2021
File diff suppressed because it is too large
Load Diff
+1756
File diff suppressed because it is too large
Load Diff
@@ -798,6 +798,7 @@ 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()),
|
||||
|
||||
@@ -905,6 +905,7 @@ 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 {
|
||||
|
||||
@@ -2628,6 +2628,10 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
chat_with_game_creator_direct_codex,
|
||||
start_planning_session_v2,
|
||||
continue_planning_session_v2,
|
||||
decide_planning_artifact_v2,
|
||||
hydrate_planning_session_v2,
|
||||
hydrate_design_agent_session,
|
||||
reset_design_agent_session,
|
||||
get_design_agent_runtime_mode,
|
||||
|
||||
@@ -372,7 +372,7 @@ pub(crate) fn project_write_lock_reclaim(
|
||||
}
|
||||
|
||||
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
|
||||
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
|
||||
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
|
||||
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
|
||||
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
|
||||
|
||||
@@ -476,7 +476,7 @@ impl ProjectWriteLockFailure {
|
||||
}
|
||||
|
||||
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
|
||||
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
|
||||
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
|
||||
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
|
||||
pub(crate) fn message(&self) -> String {
|
||||
match self {
|
||||
|
||||
@@ -51,6 +51,7 @@ 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(),
|
||||
@@ -340,6 +341,7 @@ 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,6 +2775,7 @@ 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,6 +4475,7 @@ 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,
|
||||
@@ -4720,6 +4721,7 @@ 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,6 +127,7 @@ 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,6 +84,7 @@ 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 {
|
||||
|
||||
@@ -229,6 +229,7 @@ import {
|
||||
parseRememberInput,
|
||||
} from './features/project-workspace/memoryCommands';
|
||||
import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation';
|
||||
import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane';
|
||||
import {
|
||||
type PlanningApprovalCommandResultV2,
|
||||
planningMessagesToChatMessages,
|
||||
@@ -595,6 +596,12 @@ 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 的新默认。
|
||||
@@ -820,7 +827,17 @@ 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;
|
||||
@@ -848,6 +865,22 @@ 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;
|
||||
@@ -1004,6 +1037,66 @@ export function App({
|
||||
latestMessagesRef.current = conversation;
|
||||
}
|
||||
|
||||
function commitDesignAgentView(view: DesignView, projectPath: string) {
|
||||
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
|
||||
designAgentPendingViewRef.current = null;
|
||||
applyDesignView(view, projectPath);
|
||||
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()) {
|
||||
@@ -1037,9 +1130,17 @@ 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,
|
||||
@@ -1049,7 +1150,7 @@ export function App({
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
applyDesignView(view, nextProjectPath);
|
||||
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
@@ -1061,8 +1162,10 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(message);
|
||||
setPlanGddError(message);
|
||||
} finally {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -1201,6 +1304,39 @@ export function App({
|
||||
void hydratePlanGddState(targetProjectPath);
|
||||
}, [hydratePlanGddState, localProject?.projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个
|
||||
// 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时
|
||||
// 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次
|
||||
// hydrate,白烧 IPC。
|
||||
if (
|
||||
!localProject?.projectPath ||
|
||||
projectSupervisorRuntime?.status === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做
|
||||
// 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。
|
||||
// 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进
|
||||
// 依赖等于 hydrate 触发 hydrate。
|
||||
if (
|
||||
!planningStateNeedsRuntimeRefresh(
|
||||
projectSupervisorRuntime?.source,
|
||||
planGddStateRef.current,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void hydratePlanGddState(localProject.projectPath);
|
||||
}, [
|
||||
hydratePlanGddState,
|
||||
localProject?.projectPath,
|
||||
projectSupervisorRuntime?.phase,
|
||||
projectSupervisorRuntime?.source,
|
||||
projectSupervisorRuntime?.status,
|
||||
projectSupervisorRuntime?.updatedAt,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hydrateOnResume = () => {
|
||||
if (document.visibilityState === 'hidden' || !localProject?.projectPath) {
|
||||
@@ -1521,6 +1657,9 @@ export function App({
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2Session(null);
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
designAgentPendingViewRef.current = null;
|
||||
designAgentReasoningTurnRef.current = null;
|
||||
setPlanningV2Reasoning('');
|
||||
setPlanningV2Active(planningStartMode);
|
||||
planningV2ActiveRef.current = planningStartMode;
|
||||
designAgentLaneRef.current = planningStartMode;
|
||||
@@ -1956,8 +2095,14 @@ export function App({
|
||||
}, [planningV2Active]);
|
||||
|
||||
useEffect(() => {
|
||||
const ready = designAgentEventSubscriptionReady();
|
||||
if (!canSubscribeTauriEvents() || !planningV2Active) {
|
||||
return;
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
return () => {
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
let cleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
@@ -1974,26 +2119,45 @@ export function App({
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.reasoningText != null) {
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
const reasoningTurn = designAgentReasoningTurnRef.current;
|
||||
if (
|
||||
reasoningTurn &&
|
||||
reasoningTurn.projectPath === payload.projectPath &&
|
||||
reasoningTurn.clientTurnId === payload.clientTurnId
|
||||
) {
|
||||
reasoningTurn.text = payload.reasoningText;
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
}
|
||||
}
|
||||
if (payload.kind === 'tool' && payload.text) {
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.view) {
|
||||
applyDesignView(payload.view, payload.projectPath);
|
||||
applyDesignAgentViewAfterTransient(
|
||||
payload.view,
|
||||
payload.projectPath,
|
||||
payload.clientTurnId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
cleanup = unlisten;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
// applyDesignView 读的是 refs 和当前项目路径,
|
||||
// 把它写进依赖会在每轮回复时重订事件。
|
||||
@@ -6038,6 +6202,7 @@ export function App({
|
||||
setChatAgentBusy(true);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
try {
|
||||
const result = currentSessionId
|
||||
? await invoke<PlanningSessionCommandResultV2>(
|
||||
@@ -11790,15 +11955,25 @@ export function App({
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setChatAgentBusy(true);
|
||||
setPlanGddDecisionBusy(true);
|
||||
void invoke<DesignView>('decide_design_phase', {
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
})
|
||||
text: '',
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
setChatAgentBusy(true);
|
||||
setPlanGddDecisionBusy(true);
|
||||
void designAgentEventSubscriptionReady()
|
||||
.then(() =>
|
||||
invoke<DesignView>('decide_design_phase', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
}),
|
||||
)
|
||||
.then((view) => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
@@ -11808,7 +11983,11 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyDesignView(view, nextProjectPath);
|
||||
applyDesignAgentViewAfterTransient(
|
||||
view,
|
||||
nextProjectPath,
|
||||
clientTurnId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
@@ -11830,8 +12009,10 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
setChatAgentBusy(false);
|
||||
setPlanGddDecisionBusy(false);
|
||||
});
|
||||
|
||||
@@ -145,6 +145,11 @@ export function AuthenticatedClient({
|
||||
const [authCheckError, setAuthCheckError] = useState('');
|
||||
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
|
||||
const authCheckRunRef = useRef(0);
|
||||
/**
|
||||
* 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后
|
||||
* 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。
|
||||
*/
|
||||
const loginAttemptRef = useRef(0);
|
||||
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
@@ -477,6 +482,7 @@ export function AuthenticatedClient({
|
||||
return;
|
||||
}
|
||||
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
||||
const loginAttempt = (loginAttemptRef.current += 1);
|
||||
setLoginBusy(true);
|
||||
setLoginStatus('正在登录');
|
||||
const loginGeneration = beginPlatformSessionTransition();
|
||||
@@ -493,15 +499,43 @@ export function AuthenticatedClient({
|
||||
password,
|
||||
loginApiBaseUrl,
|
||||
);
|
||||
const committedGeneration = await withAuthCheckTimeout(
|
||||
commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
const commitRequest = commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
);
|
||||
let commitFenceExpired = false;
|
||||
// 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区,
|
||||
// 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。
|
||||
void commitRequest
|
||||
.then((committedGeneration) => {
|
||||
if (
|
||||
committedGeneration === null ||
|
||||
!commitFenceExpired ||
|
||||
loginAttemptRef.current !== loginAttempt ||
|
||||
currentPlatformSessionGeneration() !== committedGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('authenticated');
|
||||
setCode('');
|
||||
setPassword('');
|
||||
setLoginStatus('本地运行时登录态已确认');
|
||||
})
|
||||
.catch(() => undefined);
|
||||
let committedGeneration: number | null;
|
||||
try {
|
||||
committedGeneration = await withAuthCheckTimeout(
|
||||
commitRequest,
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
);
|
||||
} catch (error) {
|
||||
commitFenceExpired = true;
|
||||
throw error;
|
||||
}
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ export function ProjectSupervisorRuntimePanel({
|
||||
error: string;
|
||||
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>;
|
||||
controlBusy: boolean;
|
||||
planGddAwaitingDecision?: boolean;
|
||||
readOnly?: boolean;
|
||||
professionalResultsByAgentId: Record<
|
||||
string,
|
||||
|
||||
@@ -94,6 +94,7 @@ export function WorkspaceLauncherShell({
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
homeCreationBusy,
|
||||
homeCreationRecoverableProjectPath,
|
||||
} = homeProject;
|
||||
const switchedToGameRuntime =
|
||||
gameRuntimeSwitch !== null &&
|
||||
@@ -524,6 +525,7 @@ export function WorkspaceLauncherShell({
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
creationBusy={homeCreationBusy}
|
||||
recoverableCreatedProjectPath={homeCreationRecoverableProjectPath}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
|
||||
@@ -92,6 +92,37 @@ async function suggestAutomaticProjectName(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动建项的兜底期限。
|
||||
*
|
||||
* 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页
|
||||
* 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时,
|
||||
* 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。
|
||||
*/
|
||||
const HOME_CREATION_DEADLINE_MS = 10 * 60_000;
|
||||
const HOME_CREATION_WATCHDOG_MESSAGE =
|
||||
'工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区';
|
||||
|
||||
/**
|
||||
* 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。
|
||||
* 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。
|
||||
*/
|
||||
function resolveRecoverableHomeProjectPath(
|
||||
operation: ClientOperation<
|
||||
'home-create',
|
||||
{ draft: HomeDraft; startMode: ProjectStartMode }
|
||||
> | null,
|
||||
) {
|
||||
if (!operation) return '';
|
||||
if (
|
||||
operation.phase !== 'retryable-failure' &&
|
||||
operation.phase !== 'unknown'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return operation.scope.projectPath ?? '';
|
||||
}
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
@@ -146,6 +177,14 @@ export function useHomeProjectCreation({
|
||||
* 一次代次,await 回来时已经不是最新代次的结果整体丢弃。
|
||||
*/
|
||||
const projectEntryTokenRef = useRef(0);
|
||||
/**
|
||||
* 首页自动建项的尝试代次与"底层创建仍未返回"标记。
|
||||
*
|
||||
* 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许
|
||||
* 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。
|
||||
*/
|
||||
const homeCreationAttemptRef = useRef(0);
|
||||
const homeCreationUnresolvedRef = useRef(false);
|
||||
|
||||
function validateProjectPath(nextProjectPath: string) {
|
||||
const trimmedProjectPath = nextProjectPath.trim();
|
||||
@@ -667,14 +706,21 @@ export function useHomeProjectCreation({
|
||||
if (projectActionRef.current || homeCreationIsBusy()) {
|
||||
return '已有项目操作进行中,请稍候';
|
||||
}
|
||||
if (homeCreationUnresolvedRef.current) {
|
||||
// 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个
|
||||
// 工作区,所以只挡"再建一个",不挡打开/查看已有项目。
|
||||
return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试';
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const attempt = (homeCreationAttemptRef.current += 1);
|
||||
const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt;
|
||||
const operation = createClientOperation(
|
||||
'home-create',
|
||||
{ draft, startMode },
|
||||
{ deadlineMs: null, cancellable: false },
|
||||
{ deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false },
|
||||
);
|
||||
setHomeCreationOperation(transitionClientOperation(operation, 'network'));
|
||||
// This action is owned by WorkspaceLauncher rather than HomeView. The
|
||||
@@ -682,62 +728,123 @@ export function useHomeProjectCreation({
|
||||
// the guard while project creation or first-turn import is still running.
|
||||
projectActionRef.current = 'creating';
|
||||
setProjectAction('creating');
|
||||
homeCreationUnresolvedRef.current = true;
|
||||
setStatus('正在创建工作区');
|
||||
try {
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
let createdProjectPath: string | undefined;
|
||||
let entryTokenAtWatchdog: number | null = null;
|
||||
const operationScope = () =>
|
||||
createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {};
|
||||
let watchdogId: number | undefined;
|
||||
/**
|
||||
* 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束,
|
||||
* 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。
|
||||
* 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成
|
||||
* 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。
|
||||
*/
|
||||
const watchdog = new Promise<string>((resolve) => {
|
||||
watchdogId = window.setTimeout(() => {
|
||||
if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return;
|
||||
entryTokenAtWatchdog = projectEntryTokenRef.current;
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'success', {
|
||||
transitionClientOperation(operation, 'unknown', operationScope()),
|
||||
);
|
||||
setStatus(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
}, HOME_CREATION_DEADLINE_MS);
|
||||
});
|
||||
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
|
||||
const creation = (async () => {
|
||||
try {
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
createdProjectPath = result.projectPath;
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
if (
|
||||
entryTokenAtWatchdog !== null &&
|
||||
projectEntryTokenRef.current !== entryTokenAtWatchdog
|
||||
) {
|
||||
// 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。
|
||||
rememberRecentWorkspace(result.projectPath);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('工作区已创建;可在项目列表打开');
|
||||
return '工作区已创建,可从项目列表打开';
|
||||
}
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'success', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
|
||||
rememberRecentWorkspace(result.projectPath);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
} catch (error) {
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
transitionClientOperation(
|
||||
operation,
|
||||
'retryable-failure',
|
||||
operationScope(),
|
||||
),
|
||||
);
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
if (createdProjectPath) {
|
||||
rememberRecentWorkspace(createdProjectPath);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (watchdogId !== undefined) {
|
||||
window.clearTimeout(watchdogId);
|
||||
}
|
||||
homeCreationUnresolvedRef.current = false;
|
||||
if (isCurrentAttempt() && projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure'),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
// 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。
|
||||
void creation.catch(() => undefined);
|
||||
return await Promise.race([creation, watchdog]);
|
||||
}
|
||||
|
||||
async function pickAndOpenProject() {
|
||||
@@ -850,6 +957,9 @@ export function useHomeProjectCreation({
|
||||
projectBusy: projectAction !== null,
|
||||
homeCreationOperation,
|
||||
homeCreationBusy: homeCreationIsBusy(),
|
||||
homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath(
|
||||
homeCreationOperation,
|
||||
),
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
startGameFromApprovedGdd,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+171
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AgentRuntimeState,
|
||||
AgentRuntimeUserInputRequest,
|
||||
} from '../../app/types';
|
||||
import {
|
||||
AgentRuntimeUserInputCard,
|
||||
projectRuntimeVisibleError,
|
||||
} from '../agent-runtime';
|
||||
|
||||
type PlanningLaneRuntimeStripProps = {
|
||||
runtime: AgentRuntimeState | null;
|
||||
error: string;
|
||||
controlBusy: boolean;
|
||||
readOnly?: boolean;
|
||||
onSupervisorRetry: (runtime: AgentRuntimeState) => Promise<string>;
|
||||
onUserInput: (
|
||||
request: AgentRuntimeUserInputRequest,
|
||||
responseId: string,
|
||||
answers: Record<string, string>,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 立项策划链路下替代 `ProjectSupervisorRuntimePanel` 的窄条。
|
||||
*
|
||||
* 完整面板是为做游戏链路设计的:十几个专业 Agent、多步计划、逐 Agent 重试。套到
|
||||
* 策划链路上,子 Agent 永远只有 `project-planning` 一个,计划永远一两步,「专业
|
||||
* Agent 协作:1」永远是 1——它把 D11 的「总控 + 委派子 Run」拓扑整个漏给了用户,而
|
||||
* 用户的心智模型是在跟一个策划聊天。状态本身由顶部的 `PlanGddStageProgress` 承担。
|
||||
*
|
||||
* 这里只画真正需要用户动手的两样:澄清问答卡,以及失败后的恢复入口。其余时候
|
||||
* 返回 null,不占一行。
|
||||
*
|
||||
* 完整面板在 `waiting-for-user-input` 却读不到 `userInputRequest` 时会画一句
|
||||
* 「待回答问题未能读取」。策划链路里这个组合出现在子 Run 退出到父 Run 醒来之间的
|
||||
* 瞬时窗口,以及审批等待(交互面是审批卡)——两种都不是读取失败,所以这里不画。
|
||||
*/
|
||||
export function PlanningLaneRuntimeStrip({
|
||||
runtime,
|
||||
error,
|
||||
controlBusy,
|
||||
readOnly = false,
|
||||
onSupervisorRetry,
|
||||
onUserInput,
|
||||
}: PlanningLaneRuntimeStripProps) {
|
||||
const [retrySubmitting, setRetrySubmitting] = useState(false);
|
||||
const [retryAccepted, setRetryAccepted] = useState(false);
|
||||
const [retryFeedback, setRetryFeedback] = useState('');
|
||||
useEffect(() => {
|
||||
setRetrySubmitting(false);
|
||||
setRetryAccepted(false);
|
||||
if (runtime?.status === 'failed' || runtime?.phase === 'failed') {
|
||||
setRetryFeedback('');
|
||||
}
|
||||
}, [runtime?.phase, runtime?.runId, runtime?.status]);
|
||||
|
||||
const userInputRequest = readOnly
|
||||
? null
|
||||
: (runtime?.userInputRequest ?? null);
|
||||
const needsReconciliation = Boolean(
|
||||
runtime &&
|
||||
(runtime.status === 'needs-reconciliation' ||
|
||||
runtime.phase === 'needs-reconciliation'),
|
||||
);
|
||||
// Planning V2 has no supported manual retry path. Its Provider failure is
|
||||
// terminal for the current session; exposing the generic Supervisor retry
|
||||
// would incorrectly enter the retired V1 Runtime and report a busy service.
|
||||
const showRecovery = false;
|
||||
// 与完整面板同源:App 层的操作错误(如「请先回答当前的澄清问题」)优先,其次是
|
||||
// run 自己记下的失败原因。这是原面板里唯一真正面向用户的一行文字,照搬。
|
||||
const rawErrorDetail = error || runtime?.error || '';
|
||||
const errorDetail = rawErrorDetail
|
||||
? projectRuntimeVisibleError(rawErrorDetail, '项目总控 Agent', true)
|
||||
: '';
|
||||
|
||||
if (!userInputRequest && !showRecovery && !errorDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="agent-runtime-status planning-lane-runtime-strip"
|
||||
aria-label="立项策划运行状态"
|
||||
>
|
||||
{errorDetail ? (
|
||||
<small className="project-runtime-error" role="alert">
|
||||
{errorDetail}
|
||||
</small>
|
||||
) : null}
|
||||
{showRecovery && runtime ? (
|
||||
<div
|
||||
className="project-runtime-recovery"
|
||||
aria-label={
|
||||
needsReconciliation ? '立项策划待核对恢复' : '立项策划失败恢复'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{needsReconciliation ? (
|
||||
<>
|
||||
本轮工具动作的结果不确定,需要先结束旧任务。
|
||||
<small>不会直接重试,避免重复执行未核对的动作。</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
本轮策划已停止。
|
||||
<small>在当前项目重新启动策划,不会新建项目。</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={controlBusy || retrySubmitting || retryAccepted}
|
||||
onClick={() => {
|
||||
setRetryFeedback(
|
||||
needsReconciliation
|
||||
? '正在结束待核对的旧任务…'
|
||||
: '正在重新启动策划…',
|
||||
);
|
||||
setRetrySubmitting(true);
|
||||
void onSupervisorRetry(runtime)
|
||||
.then((message) => {
|
||||
setRetryAccepted(true);
|
||||
setRetryFeedback(message);
|
||||
})
|
||||
.catch((retryError) => {
|
||||
setRetryAccepted(false);
|
||||
setRetryFeedback(
|
||||
projectRuntimeVisibleError(
|
||||
retryError instanceof Error
|
||||
? retryError.message
|
||||
: String(retryError),
|
||||
'项目总控 Agent',
|
||||
true,
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => setRetrySubmitting(false));
|
||||
}}
|
||||
>
|
||||
{retrySubmitting
|
||||
? needsReconciliation
|
||||
? '正在结束旧任务…'
|
||||
: '正在重新启动…'
|
||||
: retryAccepted
|
||||
? needsReconciliation
|
||||
? '旧任务结束请求已受理'
|
||||
: '重试已受理'
|
||||
: needsReconciliation
|
||||
? '已核对,结束旧任务'
|
||||
: '重新启动策划'}
|
||||
</button>
|
||||
{retryFeedback ? (
|
||||
<small className="project-runtime-retry-feedback" role="status">
|
||||
{retryFeedback}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{userInputRequest && !controlBusy ? (
|
||||
<AgentRuntimeUserInputCard
|
||||
key={`${userInputRequest.requestId}:${userInputRequest.responseId ?? 'pending'}`}
|
||||
request={userInputRequest}
|
||||
controlBusy={controlBusy}
|
||||
onSubmit={onUserInput}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user