diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index b2aaf7048..48edc498f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 8af1c9d2d..23fd00fe9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3cd44b4bd..2d24a4997 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -49,6 +49,18 @@ pub(crate) struct DesignView { messages: Vec, running: bool, can_retry: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, + reasoning_entries: Vec, +} + +#[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, } #[derive(Clone, Debug, Serialize)] @@ -65,6 +77,7 @@ 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(), @@ -82,9 +95,124 @@ 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 { + 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 { + // 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![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, @@ -104,6 +232,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 { validate_project_root(root)?; Ok(read_existing_manifest_for_project(root)?.project_id) @@ -462,6 +601,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,8 +688,15 @@ 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; + let mut emitted_reasoning = String::new(); client .stream_run(request.clone(), |delta| { stream_sequence = stream_sequence.saturating_add(1); @@ -565,18 +712,33 @@ 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() + || delta.accumulated_reasoning != emitted_reasoning + { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + delta.accumulated_reasoning.clone(), + )); + emitted_reasoning = delta.accumulated_reasoning.clone(); + } }) .await } else { @@ -584,6 +746,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 +776,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 +830,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 +858,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!() @@ -940,7 +1146,9 @@ pub(crate) fn set_design_agent_runtime_mode( "design.runtime-mode", )?; if active_runtime.trim() == "game" { - crate::assets::register_design_artifacts_at(root)?; + if crate::assets::register_design_artifacts_at(root)? { + advance_agent_runtime_project_revision_locked(root)?; + } } write_design_runtime_mode(root, active_runtime.trim()) } @@ -1176,6 +1384,38 @@ 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; @@ -1285,6 +1525,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 { @@ -1345,6 +1586,110 @@ 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![ + ("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::>(); + 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::>(); + 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(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index d53f2f58e..d0cb7c0bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 8cd93a2c5..d0e3a92a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index dc5ad1e60..bd6f8b6b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -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()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c4382bcef..110e35161 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -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 { +pub(crate) fn register_design_artifacts_at(root: &Path) -> Result { let design_root = root.join("design_artifacts"); if !design_root.exists() { - return Ok(0); + return Ok(false); } 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 } } files.sort(); - let mut registered = 0; + let mut changed = false; for path in files { let relative = path .strip_prefix(root) @@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result Some("yaml" | "yml") => "text/yaml", _ => "application/octet-stream", }; - register_local_asset_at( + let (_, asset_changed) = register_local_asset_entry_with_change( root, &relative, "document", @@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result reference_resource_ids: Vec::new(), }, )?; - registered += 1; + changed |= asset_changed; } - Ok(registered) + Ok(changed) } pub(crate) fn import_canvas_asset_at( @@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { + 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"); @@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry( let mut source_for_record = source.clone(); source_for_record.prompt = None; - let (id, record_type) = mutate_manifest_at(root, |manifest| { + let (id, record_type, changed) = mutate_manifest_at(root, |manifest| { if let Some(existing) = manifest .assets .iter_mut() @@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry( // 而陈旧的非 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")) + Ok((existing.id.clone(), "asset.update", changed)) } else { let id = format!( "{id_prefix}-{}-{}", @@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry( tags: Vec::new(), source, }); - Ok((id, "asset.register")) + Ok((id, "asset.register", true)) } })?; append_agent_db_record( @@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry( }), )?; - 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(), - }) + 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, + )) } #[derive(Clone, Debug, Deserialize)] @@ -2137,6 +2155,27 @@ 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 值 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c81dd2663..b3cc58f28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -517,11 +517,7 @@ pub(crate) fn create_automatic_local_game_project_at( match fs::create_dir(&project_root) { Ok(()) => { let result = (|| { - prepare_game_creator_private_path_for_read( - &project_root, - true, - "自动项目目录", - )?; + harden_new_game_creator_private_path(&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( diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index ebbee916b..b66b976af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1319,15 +1319,10 @@ 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)] - 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, - )?; - } + // This invocation created the directory: initialize it in + // process first, with a narrowly-scoped managed-path fallback + // only if Windows rejects that local ACL update. + harden_new_game_creator_private_path(&directory, true, label)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -1366,15 +1361,7 @@ pub(crate) fn ensure_game_creator_private_directory_tree( fs::create_dir(&directory).map_err(|retry_error| { format!("创建 {label} 失败:{}: {retry_error}", directory.display()) })?; - 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, - )?; - } + harden_new_game_creator_private_path(&directory, true, label)?; } Err(error) => { return Err(format!( @@ -1439,15 +1426,34 @@ pub(crate) fn harden_new_game_creator_private_path( path.display() )); } - // This invocation created the object, so its owner is the current - // user. Tighten the inherited descriptor in-process; UAC repair is - // reserved for existing, externally-owned objects. - secure_windows_game_creator_path_for_current_user_with_owner_policy( - path, - is_directory, - true, - true, - )?; + // This invocation created the object, so local hardening is always + // the first path. Some Windows configurations can nevertheless + // reject the descriptor update (for example when an inherited ACL is + // protected by the parent). Only a managed path may use the existing + // one-shot repair in that exceptional case; ordinary new projects do + // not prompt for UAC. + if let Err(local_error) = + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + ) + { + if !game_creator_private_path_allows_auto_elevation(path) + || !windows_acl_error_may_need_elevation(&local_error) + { + return Err(local_error); + } + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + ) + .map_err(|repair_error| { + format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}") + })?; + } } #[cfg(unix)] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs index d605b6e2c..419e00d23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs @@ -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 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs index bae190561..e4cf78d4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs @@ -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 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index b0ce174bc..20a6dfc0d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f66a45540..28b4dcbc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs index c7f5451eb..f2a14571f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs @@ -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), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index e9f048dbd..2e7f6fe79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -84,6 +84,7 @@ fn response(text: &str, tool_calls: Vec) -> 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 { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d8d0ad655..58526a3db 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -596,6 +596,12 @@ export function App({ projectPath: string; clientTurnId: string; } | null>(null); + const designAgentEventSubscriptionReadyRef = useRef | null>( + null, + ); + const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( + null, + ); // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 @@ -821,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; @@ -849,6 +865,28 @@ export function App({ } } + function designAgentEventSubscriptionReady() { + // 业务动作只读取当前订阅代次;清理与下一次 effect 建立之间不能创建 + // 一个没有订阅 effect 接管的悬挂 promise。 + return designAgentEventSubscriptionReadyRef.current ?? Promise.resolve(); + } + + function createDesignAgentEventSubscriptionReady() { + if (!designAgentEventSubscriptionReadyRef.current) { + designAgentEventSubscriptionReadyRef.current = new Promise( + (resolve) => { + designAgentEventSubscriptionResolveRef.current = resolve; + }, + ); + } + return designAgentEventSubscriptionReadyRef.current; + } + + function resolveDesignAgentEventSubscriptionReady() { + designAgentEventSubscriptionResolveRef.current?.(); + designAgentEventSubscriptionResolveRef.current = null; + } + useEffect(() => { const timer = window.setInterval(() => { const target = planningV2TransientReplyTargetRef.current; @@ -980,6 +1018,15 @@ export function App({ } function designMessagesToChat(view: DesignView): ChatMessage[] { + const reasoningByMessageId = new Map(); + 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) => ({ @@ -987,6 +1034,7 @@ export function App({ text: message.text, runtimeOwned: true, messageId: message.id, + reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); } @@ -1005,6 +1053,67 @@ 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()) { @@ -1038,9 +1147,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('continue_design_agent_session', { projectPath: nextProjectPath, @@ -1050,7 +1167,7 @@ export function App({ if (localProjectPathRef.current !== nextProjectPath) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; @@ -1062,8 +1179,10 @@ export function App({ setProjectSupervisorRuntimeError(message); setPlanGddError(message); } finally { - designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + if (!designAgentPendingViewRef.current) { + designAgentTurnRef.current = null; + setPlanningV2TransientReplyTarget(''); + } setChatAgentBusy(false); } } @@ -1555,6 +1674,9 @@ export function App({ setProjectSupervisorRuntimeError(''); setPlanningV2Session(null); setPlanningV2TransientReplyTarget(''); + designAgentPendingViewRef.current = null; + designAgentReasoningTurnRef.current = null; + setPlanningV2Reasoning(''); setPlanningV2Active(planningStartMode); planningV2ActiveRef.current = planningStartMode; designAgentLaneRef.current = planningStartMode; @@ -1990,8 +2112,15 @@ export function App({ }, [planningV2Active]); useEffect(() => { + const ready = createDesignAgentEventSubscriptionReady(); if (!canSubscribeTauriEvents() || !planningV2Active) { - return; + resolveDesignAgentEventSubscriptionReady(); + return () => { + if (designAgentEventSubscriptionReadyRef.current === ready) { + designAgentEventSubscriptionReadyRef.current = null; + createDesignAgentEventSubscriptionReady(); + } + }; } let cleanup: (() => void) | null = null; let disposed = false; @@ -2008,26 +2137,46 @@ 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; + createDesignAgentEventSubscriptionReady(); + } }; // applyDesignView 读的是 refs 和当前项目路径, // 把它写进依赖会在每轮回复时重订事件。 @@ -6072,6 +6221,7 @@ export function App({ setChatAgentBusy(true); setProjectSupervisorRuntimeError(''); setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); try { const result = currentSessionId ? await invoke( @@ -11796,7 +11946,11 @@ export function App({ ? directCodexTransientReply : projectSupervisorTransientReply } + showDesignReasoning={planningV2Active} designReasoning={planningV2Reasoning} + designReasoningEntries={ + useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] + } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} showProfessionalCollaboration={ @@ -11824,15 +11978,25 @@ export function App({ projectPath: nextProjectPath, clientTurnId, }; - setPlanningV2TransientReplyTarget(''); - setChatAgentBusy(true); - setPlanGddDecisionBusy(true); - void invoke('decide_design_phase', { + designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, - requestId, - approved, - }) + text: '', + }; + designAgentPendingViewRef.current = null; + setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); + setChatAgentBusy(true); + setPlanGddDecisionBusy(true); + void designAgentEventSubscriptionReady() + .then(() => + invoke('decide_design_phase', { + projectPath: nextProjectPath, + clientTurnId, + requestId, + approved, + }), + ) .then((view) => { if ( localProjectPathRef.current !== nextProjectPath || @@ -11842,7 +12006,11 @@ export function App({ ) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient( + view, + nextProjectPath, + clientTurnId, + ); }) .catch((error) => { if ( @@ -11864,8 +12032,10 @@ export function App({ ) { return; } - designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + if (!designAgentPendingViewRef.current) { + designAgentTurnRef.current = null; + setPlanningV2TransientReplyTarget(''); + } setChatAgentBusy(false); setPlanGddDecisionBusy(false); }); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index a89c1698a..64a79bfab 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -993,6 +993,7 @@ export interface ChatMessage { draftCommand?: string; draftCommandLabel?: string; messageId?: string | null; + reasoningText?: string; agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; @@ -1038,11 +1039,19 @@ 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 { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index ef8a6b119..096a53a00 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -16,7 +16,11 @@ import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; -import type { DesignClarificationRequest, DesignView } from '../../app/types'; +import type { + DesignClarificationRequest, + DesignReasoningEntry, + DesignView, +} from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, @@ -96,7 +100,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { projectPath: string; showProfessionalCollaboration?: boolean; transientReply: string; + showDesignReasoning?: boolean; designReasoning?: string; + designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; workspaceStatus: string; @@ -148,7 +154,9 @@ export function ProjectSupervisorView({ projectPath, showProfessionalCollaboration = true, transientReply, + showDesignReasoning = false, designReasoning = '', + designReasoningEntries = [], visibleMessages, visibleProfessionalAgentCards, workspaceStatus, @@ -260,11 +268,36 @@ export function ProjectSupervisorView({ role={message.role} text={projectSupervisorChatMessageText(message)} /> + {showDesignReasoning && message.reasoningText ? ( +
+ 思考过程 +
{message.reasoningText}
+
+ ) : null} ))} - {designReasoning ? ( -
- 显示思考过程 + {showDesignReasoning && + designReasoningEntries + .filter((entry) => !entry.messageId) + .map((entry) => ( +
+ 思考过程 +
{entry.text}
+
+ ))} + {showDesignReasoning && designReasoning ? ( +
+ 思考过程
{designReasoning}
) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 5fa401401..499ec2464 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -7,6 +7,7 @@ import { React, render, screen, + setComposerText, waitFor, } from './harness'; @@ -55,6 +56,46 @@ function designClarificationView() { }; } +function designConversationView() { + return { + session: { + sessionId: 'design-session-reasoning', + projectId: 'local-project-draft', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification: null, + turnIndex: 1, + lastError: null, + }, + messages: [], + running: false, + canRetry: false, + }; +} + +function designHistoryView() { + return { + ...designConversationView(), + messages: [ + { id: 'assistant-1', role: 'assistant', text: '第一轮正文' }, + { id: 'assistant-2', role: 'assistant', text: '第二轮正文' }, + ], + reasoningEntries: [ + { + id: 'reasoning-1', + messageId: 'assistant-1', + text: '第一轮思考', + }, + { + id: 'reasoning-2', + messageId: 'assistant-2', + text: '第二轮思考', + }, + ], + }; +} + export function registerDesignAgentSurfaceTests() { it('hydrates an existing design session and decides approval through design commands', async () => { const harness = createProjectSupervisorRuntimeHarness({ @@ -141,4 +182,83 @@ export function registerDesignAgentSurfaceTests() { ).toBe(true); }); }); + + it('keeps the current turn reasoning after completion and supports collapse/expand', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designConversationView(), + designAgentContinueView: designConversationView(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + const input = await screen.findByLabelText('项目需求'); + await setComposerText(input, '请给出核心玩法方案'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => { + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'continue_design_agent_session', + ), + ).toBe(true); + }); + const continueCall = [...harness.invoke.mock.calls] + .reverse() + .find(([command]) => command === 'continue_design_agent_session'); + const clientTurnId = String( + (continueCall?.[1] as { clientTurnId?: string }).clientTurnId, + ); + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'reasoning', + reasoningText: '先分析需求,再组织方案。', + }); + + const summary = await screen.findByText('思考过程'); + const details = summary.closest('details') as HTMLDetailsElement; + expect(details.open).toBe(false); + fireEvent.click(summary); + expect(details.open).toBe(true); + expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull(); + }); + + it('renders historical reasoning as independent collapsed sections', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designHistoryView(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + const summaries = await screen.findAllByText('思考过程'); + expect(summaries).toHaveLength(2); + const details = summaries.map( + (summary) => summary.closest('details') as HTMLDetailsElement, + ); + expect(details.every((element) => !element.open)).toBe(true); + fireEvent.click(summaries[0]); + expect(details[0].open).toBe(true); + expect(details[1].open).toBe(false); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 71af32e10..f683a7fa7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -741,6 +741,9 @@ function createProjectSupervisorRuntimeHarness({ }; }) => void) | null = null; + let designAgentUpdateHandler: + | ((event: { payload: Record }) => void) + | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -1113,6 +1116,10 @@ function createProjectSupervisorRuntimeHarness({ if (eventName === 'game-creator-agent-progress') { progressHandler = handler as unknown as typeof progressHandler; } + if (eventName === 'design-agent-update') { + designAgentUpdateHandler = + handler as unknown as typeof designAgentUpdateHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1123,6 +1130,9 @@ function createProjectSupervisorRuntimeHarness({ if (progressHandler === handler) { progressHandler = null; } + if (designAgentUpdateHandler === handler) { + designAgentUpdateHandler = null; + } }; }, ); @@ -1149,6 +1159,9 @@ function createProjectSupervisorRuntimeHarness({ setPlanningV2Result(state: Record | null) { currentPlanningV2Result = state; }, + emitDesignAgentEvent(payload: Record) { + designAgentUpdateHandler?.({ payload }); + }, setPlanningV2StartResult(state: Record | null) { currentPlanningV2StartResult = state; }, diff --git a/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md b/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md new file mode 100644 index 000000000..87be9f2c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md @@ -0,0 +1,214 @@ +# 【里程碑】Provider 推理与正文分离及策划 Agent 展示 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-09-14 | +| Parent Spec | `docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md` | +| Related Issue | `GenarrativeAI/Genarrative#331` | + +## 一句话交付结果 + +让策划 Agent 能在流式回合中单独收到 Provider reasoning,并在 UI 中以默认折叠的思考过程展示;用户可见正文、工具调用和 GameAgent 现有行为保持不变。 + +## 当前实现进度(2026-09-14) + +- 已完成共享 reasoning 字段、Provider 解析、策划事件映射以及正文流式收尾的前两轮提交。 +- 当前第三轮聚焦策划 Agent 前端 reasoning 生命周期:按 `projectPath + clientTurnId` 绑定事件,回合结束后保留本轮 reasoning,下一轮或项目切换时清理。 +- 现有 `
` 展示保持默认收起,并提供明确的展开/收起入口;不新增持久化字段,也不改动 GameAgent、Direct/Codex、supervisor 或已退役链路。 +- 已核对实际会话产物:Responses 原生 `history` 已持久化 `reasoning` / `reasoning_text`,当前修复补齐 `reasoning_text` 提取,并在策划会话恢复时回填最近一轮 reasoning。 + +## 背景与现状 + +- `platform-llm` 当前只向上层提供正文累计值、正文增量和结束状态。 +- Chat 兼容响应中的 `reasoning`、`reasoning_content` 以及 reasoning content part 会被正文提取器过滤。 +- Responses 响应中的 reasoning 类型 output item 也不会进入独立的上层字段。 +- 策划 Agent 已经预留 `DesignEvent.reasoningText`、`planningV2Reasoning` 和默认折叠 UI,但 Provider 解析链没有产出数据,因此折叠区通常不出现。 +- GameAgent 当前只消费 `delta_text`、`accumulated_text` 和 `finish_reason`,没有消费策划 Agent 的 `reasoningText`。 + +## 目标 + +1. 为 Provider 流式响应增加独立 reasoning 增量和累计通道。 +2. 为非流式终态响应提供独立 reasoning 字段。 +3. 支持 Responses 和 Chat 兼容协议的 reasoning 解析。 +4. 仅由策划 Agent 显式启用 reasoning 捕获和 UI 转发。 +5. 保证 reasoning 不进入用户可见正文、工具调用参数或 GameAgent 消息流。 +6. 在无 reasoning、reasoning 解析异常、重试和工具调用共存场景下保持可恢复行为。 + +## 非目标 + +- 不改变 GameAgent 的正文展示、工具调用、`` 过滤和运行时状态语义。 +- 不把 reasoning 自动拼接到 `delta_text`、`accumulated_text` 或正式 assistant 消息。 +- 不把 reasoning 作为新的业务消息类型写入策划会话历史。 +- 不新增通用 reasoning UI,不改造 Direct/Codex 的过程卡展示。 +- 不修改 Provider 请求模型、推理档位或 token 预算。 +- 不为 reasoning 增加新的 SpacetimeDB 表、公开 API 或持久化 schema。 + +## 受影响模块与边界 + +### Provider 共享层 + +`server-rs/crates/platform-llm` 负责协议解析和流式累计: + +- `LlmStreamDelta` 增加 `reasoning_delta` 与 `accumulated_reasoning`。 +- `LlmRunResponse` 增加终态 reasoning 字段。 +- `LlmRunRequest` 增加默认关闭的 reasoning 捕获开关。 +- 正文提取继续排除隐藏 reasoning part;reasoning 进入旁路字段。 +- reasoning 解析失败只丢弃 reasoning,不影响正文和工具调用。 + +### 策划 Runtime + +`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs` 仅在策划专用请求中打开 reasoning 捕获: + +- 流式 reasoning 更新映射到已有 `DesignEvent.reasoningText`。 +- 正文继续使用已有 `text` 事件。 +- 新回合、重试、项目切换和请求失败时清理旧 reasoning。 +- debug 记录与正文记录分开,内容受现有 debug 开关和长度限制约束。 + +### 其它调用方 + +GameAgent、Agent Interaction、Direct/Codex 适配层和通用 runtime 继续只读取正文字段。新增 reasoning 字段默认为空,不改变这些调用方的业务判断。 + +### 前端 + +复用现有 `ProjectSupervisorView` 的 `designReasoning` 和默认折叠 `
` 展示。只补事件生命周期和状态清理,不新建平行组件或平行状态协议。 + +## 分步实施方案 + +### 第一步:冻结共享契约与兼容开关 + +明确字段语义、空值语义和捕获开关: + +- reasoning 字段只表示 Provider 返回的内部推理内容,不代表用户正文。 +- 捕获开关默认关闭;未启用时新增字段为空。 +- 正文、工具调用、finish reason 和 Responses 原生 output 的现有语义保持不变。 +- 该步只更新规范、类型定义和构造点,不接入策划 UI。 + +验收重点:所有现有 Rust 构造点可编译,GameAgent 现有调用仍只依赖正文字段。 + +### 第二步:实现 `platform-llm` 协议解析 + +分别补齐: + +- Responses reasoning 增量事件; +- Responses 终态 reasoning output item / summary; +- Chat `reasoning`、`reasoning_content` 和 reasoning content part; +- 正文与 reasoning 的独立累计; +- reasoning 与正文、工具调用同时出现时的顺序和去重; +- reasoning 解析失败时的降级行为。 + +Responses 的原生 output 仍按当前方式保留,用于后续 Responses 会话回放;新增 reasoning 字段只用于上层展示和调试消费。 + +验收重点:正文永远不含 reasoning;无 reasoning 的响应与当前行为一致。 + +### 第三步:补齐共享适配层并锁定 GameAgent 不变 + +更新 `LlmStreamDelta` 构造点、适配器和测试辅助函数,使它们为新增字段提供空值。检查并锁定: + +- GameAgent 正文流不读取 reasoning; +- 工具调用判断不读取 reasoning; +- Direct/Codex 过程卡不显示 reasoning; +- 通用 response stream 过滤逻辑不因新增字段改变。 + +验收重点:现有工具调用、正文流式、Direct 和 Agent Interaction 测试无行为回归。 + +### 第四步:接通策划 Runtime 与现有 UI + +仅在策划 Agent Provider 请求中启用捕获开关: + +- 收到 reasoning 增量时发出独立 `reasoningText`; +- 收到正文增量时继续发出原有 `text`; +- 重试时替换同一回合的临时 reasoning,不残留上一 attempt; +- 正式回合结束后保留本回合展示,下一回合开始时清理; +- UI 默认折叠,展开后显示累计 reasoning,不影响正文滚动和输入。 + +验收重点:策划 Agent 能看到独立 reasoning,正文气泡不重复、不混入推理文本。 + +### 第五步:完成回归、文档和验收证据 + +形成逐条证据矩阵,至少覆盖: + +- Responses reasoning 增量和终态; +- Chat reasoning 字段和 content part; +- 正文与 reasoning 分离; +- reasoning 与工具调用并存; +- reasoning 解析失败降级; +- 无 reasoning 兼容行为; +- 策划 Runtime 事件映射和 UI 生命周期; +- GameAgent 正文与工具调用回归。 + +## 第五轮验收证据 + +| 验收面 | 证据 | 结果 | +| --- | --- | --- | +| Chat / Responses reasoning 解析 | `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS,152 个单元测试;含字段、content part、SSE 增量、终态快照和正文隔离 | +| reasoning 与工具调用共存 | `responses_response_captures_reasoning_alongside_tool_call`、既有 Chat/Responses 流式工具测试 | PASS | +| 默认关闭与请求兼容 | `run_request_defaults_to_openai_responses_api_kind`、`reasoning_capture_switch_does_not_change_provider_request_body` | PASS | +| 策划 Runtime 生命周期 | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell design_runtime` | PASS,10 个测试;含事件映射、history 隔离、重试清理和失败清理 | +| GameAgent / Direct/Codex 正文回归 | `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests` 与现有 response stream / direct tests 编译 | PASS;新增字段未进入正文消费路径 | +| 前端与文档门禁 | `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` | PASS | +| 格式门禁 | `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`、AGC Tauri 同命令 | PASS | + +真实 Provider、浏览器运行时 smoke 和 `check-config.mjs` 的 Windows 私有 DACL 路径本轮未验证;前者需要凭据和运行环境,后者受当前沙箱权限限制,不能据此扩大验收结论。 + +## 契约与持久化策略 + +- 不修改 HTTP API、OpenAPI、SpacetimeDB schema 或生成绑定。 +- 不新增正式持久化字段;策划会话仍保存既有对话和 Responses 原生 output。 +- reasoning 捕获开关属于 Provider 请求的内部调用语义,默认关闭,不改变已有请求的默认指纹和展示行为。 +- reasoning 不作为下一轮普通用户可见正文回灌;Responses 原生 output 的恢复语义保持现状。 + +## 失败、重试与恢复 + +- reasoning 解析失败:保留正文和工具调用,reasoning 字段置空或保留已累计部分。 +- Provider 瞬态重试:reasoning 与正文使用同一回合、同一响应槽,新的 attempt 替换临时值。 +- 流中断:沿用现有 Provider 错误和策划会话恢复规则,不把未完成 reasoning 误判为正式消息。 +- UI 刷新或项目恢复:只从当前事件/状态恢复 reasoning,不隐式唤醒 Provider。 + +## 风险与回滚点 + +| 风险 | 控制措施 | 回滚点 | +| --- | --- | --- | +| 共享结构体新增字段导致构造点遗漏 | 先补齐所有构造点和编译检查 | 回退共享字段提交 | +| Provider 把 reasoning 混入正文 | 保留独立提取器和正文过滤测试 | 关闭 reasoning 捕获开关 | +| Responses summary 事件重复累计 | 以增量事件为主,终态仅做快照/兜底 | 关闭对应事件解析 | +| GameAgent 意外展示 reasoning | 捕获默认关闭,调用方只读正文字段 | 回退策划开关,不影响共享解析 | +| 重试残留旧 reasoning | 按回合和响应槽清理/替换 | 回退 UI 事件消费 | + +## 验收命令 + +代码实现阶段按里程碑执行,不在本计划阶段运行业务测试。预计命令: + +```text +cargo test -p platform-llm +cargo test -p ai-game-creator-shell +npm run typecheck +npm run check:encoding +git diff --check +``` + +文档阶段已要求补充运行: + +```text +npm run check:doc-index +npm run check:encoding +git diff --check +``` + +## 第六轮定位:历史 reasoning 位置修复 + +持久化 Responses history 中,reasoning 不一定紧邻可见 `message`:一次 Provider 响应可能先产生 reasoning 和多个 `function_call`,下一次响应才产生正文。原实现遇到下一段 reasoning 就提前结束上一段,无法绑定到 `session.messages` 中的正确 assistant 响应,前端遂把无 `messageId` 的条目统一追加到列表底部。 + +修复方式是按每个用户回合分别收集: + +- `session.history` 中 reasoning 的响应顺序; +- `session.messages` 中 assistant 消息的响应顺序。 + +两者按顺序绑定,允许 reasoning 跨越 tool-only Responses;同一正文对应多段 reasoning 时前端合并显示,仍使用默认折叠的单个思考区域。只有没有任何可见 assistant 消息的异常历史才保留为底部 orphan。 + +验证:新增 tool-only Responses 顺序回归测试,策划 Runtime 定向测试 11/11 通过;前端聚合逻辑保留历史多段 reasoning 的顺序。 + +## 当前状态与下一步 + +当前已完成 Provider 解析、策划 Runtime 生命周期、历史 reasoning 恢复及响应顺序归属修复;待本轮提交后继续按定向回归结果推进后续验收。字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 不进入普通上下文的约束保持不变。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d77371713..d35c4436d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -206,6 +206,7 @@ - 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 - 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 +- 补充:逐级创建 `.agent`、`runtime`、`locks` 等目录时,即使祖先已有 `manifest.json`,刚由本进程创建的目录也必须直接走 owner/DACL 初始化,不能因 managed-path 判定进入 UAC;自动项目根目录同样在创建成功后立即本地加固。 - 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 - 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 @@ -357,6 +358,7 @@ - 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。 - 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。 +- 补充:策划项目切换到 GameAgent 时,`design_artifacts` 的新增或登记信息实际变化必须与一次项目 revision 推进配对;重复切换不重复推进,避免 manifest 已变化而 revision 仍停留在旧值,触发前端同 revision 清单冲突提示。 - 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。 - 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index 4d0bc94fe..956aaa46f 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -342,12 +342,12 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide ## 14. 开发调试入口 -项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”写入 `game`。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。 +项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”先登记 `design_artifacts` 中尚未登记或登记信息已变化的策划产物,若 manifest 实际发生变化则在同一项目写锁范围内推进一次项目 revision,再写入 `game`。重复切换不重复登记或推进 revision。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。 开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。 -## 15. 策划 Agent reasoning 展示现状 +## 15. 策划 Agent reasoning 展示 -右侧栏已预留策划 Agent 的 `reasoningText` 事件字段和默认折叠的展示样式,但当前 Provider 解析链仍会过滤 reasoning 内容,尚未向策划 Runtime 产出该字段。因此现阶段只展示用户可见正文和工具状态;reasoning 折叠区在没有数据时不会出现。 +策划 Agent 的 Provider 请求显式开启 `capture_reasoning`,共享 `platform-llm` 将 Chat / Responses 的 reasoning 通过独立字段旁路传递,策划 Runtime 映射为已有 `DesignEvent.reasoningText`,前端复用右侧栏默认折叠的思考过程展示。正文、工具调用参数、正式 assistant message 和会话 history 继续使用原有字段;GameAgent、Direct/Codex 与通用 response stream 保持只消费正文的行为。 -后续若补充 reasoning,需要在策划 Agent 专用 Provider 解析层接入,不能直接修改共享 Provider 以免影响 GameAgent。 +reasoning 捕获默认关闭。新回合和 Provider 重试会先清空同一响应槽的临时 reasoning,失败路径也会清理,避免旧内容残留。该能力不新增公开 API、SpacetimeDB 字段或独立 UI 组件。 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index a82a1a021..9d53dd690 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -120,6 +120,7 @@ pub async fn proxy_llm_chat_completions( request_timeout_ms: None, response_reasoning_effort: None, response_text_verbosity: None, + capture_reasoning: false, function_tools: Vec::new(), tool_choice: None, }; diff --git a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs index 713a4e938..9fbe66c67 100644 --- a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs +++ b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs @@ -55,6 +55,7 @@ pub fn build_gpt5_multimodal_request( api_kind: LlmApiKind::OpenAiChat, response_reasoning_effort: None, response_text_verbosity: None, + capture_reasoning: false, function_tools: Vec::new(), tool_choice: None, } diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index 5d014f862..cb40e71a4 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -19,7 +19,7 @@ 2. `OpenAiChat`、`OpenAiResponses` 与 `Anthropic` 三类 API kind 都支持 JSON 请求、非流式响应和 SSE 流式响应;默认 API kind 仍为 `OpenAiResponses`。 3. 三类协议都使用统一的 `function_tools` / `tool_choice` 输入和 `LlmRunResponse.tool_calls` 输出。Anthropic 请求使用顶层 `tools[].input_schema` 与对象形态 `tool_choice`;Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages`。 4. Anthropic 当前仍不支持 `web_search`、图片内容和纯 system 消息;至少需要一条非 system 文本消息。角色动画、图片、视频、资产轮询仍留在其他平台适配和业务模块任务里。 -5. 流式 `on_delta` 只发送文本增量与完成原因;工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。上下文管理、后台执行和业务状态不写回本 crate。 +5. 流式 `on_delta` 发送正文增量、可选的独立 reasoning 增量与完成原因;reasoning 只有在 `LlmRunRequest.capture_reasoning=true` 时才累计,默认关闭。Responses 的 reasoning summary 按 `item_id + summary_index` 分段累计,单段 `.done` 只校正对应段,不覆盖其它 item;Anthropic 的 `thinking_delta` 同样进入独立 reasoning 通道。分段快照即使改写了原内容也会通知调用方刷新累计 reasoning。工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。reasoning 不进入 `delta_text`、`accumulated_text`、正式 assistant message 或工具参数;上下文管理、后台执行和业务状态不写回本 crate。 6. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。 7. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API。 8. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index d21c888d5..8b87ff6bb 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, env, error::Error, fmt, fs, @@ -180,6 +181,8 @@ pub struct LlmRunRequest { pub request_timeout_ms: Option, pub response_reasoning_effort: Option, pub response_text_verbosity: Option, + /// 是否把 Provider 返回的内部 reasoning 作为独立旁路字段暴露给调用方;默认关闭。 + pub capture_reasoning: bool, pub function_tools: Vec, pub tool_choice: Option, } @@ -237,6 +240,10 @@ impl LlmResponseTextVerbosity { pub struct LlmStreamDelta { pub accumulated_text: String, pub delta_text: String, + /// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。 + pub accumulated_reasoning: String, + /// 当前回调的推理增量;不得追加到正文。 + pub reasoning_delta: String, pub finish_reason: Option, } @@ -254,6 +261,8 @@ pub struct LlmRunResponse { pub provider: LlmProvider, pub model: String, pub text: String, + /// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。 + pub reasoning: String, pub finish_reason: Option, pub response_id: Option, pub usage: Option, @@ -527,6 +536,14 @@ where Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) } +fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .and_then(|value| value.as_str().map(str::to_string))) +} + #[derive(Deserialize)] struct ChatCompletionsChoice { #[serde(default)] @@ -543,6 +560,10 @@ struct ChatCompletionsMessage { content: Option, #[serde(default)] tool_calls: Option>, + #[serde(default, deserialize_with = "deserialize_optional_string")] + reasoning: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + reasoning_content: Option, } // 流式分片只有首片带 id / name,后续片仅有 index 与 arguments 片段,因此字段全部可选。 @@ -575,7 +596,7 @@ enum ChatCompletionsContent { struct ChatCompletionsContentPart { #[serde(rename = "type")] part_type: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_string")] text: Option, } @@ -607,13 +628,15 @@ struct ResponsesOutputItem { name: Option, #[serde(default)] arguments: Option, + #[serde(default)] + summary: Option, } #[derive(Deserialize)] struct ResponsesOutputContentPart { #[serde(rename = "type")] part_type: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_string")] text: Option, } @@ -644,6 +667,8 @@ struct AnthropicContentBlock { block_type: Option, #[serde(default)] text: Option, + #[serde(default)] + thinking: Option, // tool_use block 字段:id 与 name 标识调用,input 是已解析的 JSON object。 #[serde(default)] id: Option, @@ -687,6 +712,11 @@ struct OpenAiCompatibleSseParser { #[derive(Debug, Default)] struct ParsedStreamEvent { delta_text: Option, + reasoning_delta: Option, + // Responses 的 summary part 还必须绑定 item_id;summary_index 只在单个 reasoning item 内唯一。 + reasoning_summary_item_id: Option, + reasoning_summary_index: Option, + reasoning_snapshot: Option, responses_output: Option>, // 终态事件携带的完整正文快照。必须与 delta_text 分开:它不是增量,按增量累加会让 // 正文翻倍。只有 Responses 的 completed / incomplete 会填——Chat 的 [DONE] 与 @@ -852,6 +882,10 @@ fn normalize_tool_calls( #[derive(Debug, Default)] struct StreamAccumulation { text: String, + reasoning: String, + // Responses summary part 的流式累计,key 必须同时包含 reasoning item 与 part 索引。 + reasoning_summary_parts: BTreeMap<(String, u64), String>, + reasoning_summary_item_order: Vec, responses_output: Vec, finish_reason: Option, usage: Option, @@ -1253,6 +1287,7 @@ impl LlmRunRequest { request_timeout_ms: None, response_reasoning_effort: None, response_text_verbosity: None, + capture_reasoning: false, function_tools: Vec::new(), tool_choice: None, } @@ -1318,6 +1353,12 @@ impl LlmRunRequest { self } + /// 只设置本地捕获意图,不改变请求的模型、推理档位和协议请求体。 + pub fn with_reasoning_capture(mut self, enabled: bool) -> Self { + self.capture_reasoning = enabled; + self + } + pub fn with_function_tools(mut self, function_tools: Vec) -> Self { self.function_tools = function_tools; self @@ -1621,6 +1662,7 @@ impl LlmClient { request.api_kind, self.config.provider(), &resolved_model, + request.capture_reasoning, raw_text.as_str(), ) .map_err(|error| { @@ -1731,6 +1773,7 @@ impl LlmClient { stream_terminated = consume_stream_parser_result( parser.push_chunk(chunk_text.as_ref()), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1780,6 +1823,7 @@ impl LlmClient { stream_terminated = consume_stream_parser_result( parser.push_chunk(trailing_text), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1801,6 +1845,7 @@ impl LlmClient { consume_stream_parser_result( parser.finish(), &mut accumulation, + request.capture_reasoning, emit_finish_only_delta, &mut on_delta, ) @@ -1914,6 +1959,11 @@ impl LlmClient { provider: self.config.provider(), model: resolved_model, text: content, + reasoning: if request.capture_reasoning { + accumulation.reasoning + } else { + String::new() + }, finish_reason: accumulation.finish_reason, response_id, usage: accumulation.usage, @@ -2187,6 +2237,7 @@ impl OpenAiCompatibleSseParser { fn consume_stream_parser_result( result: Result, SseEventDrainError>, accumulation: &mut StreamAccumulation, + capture_reasoning: bool, emit_finish_only_delta: bool, on_delta: &mut F, ) -> Result @@ -2198,8 +2249,13 @@ where Err(error) => (error.parsed_events, Some(error.error)), }; // 槽位身份冲突比尾部错误更根本:累加出的工具调用已不可信,不能再走保留路径。 - let stream_terminated = - consume_stream_events(events, accumulation, emit_finish_only_delta, on_delta)?; + let stream_terminated = consume_stream_events( + events, + accumulation, + capture_reasoning, + emit_finish_only_delta, + on_delta, + )?; if stream_terminated { return Ok(true); @@ -2253,6 +2309,7 @@ fn retain_completed_stream_after_tail_error( fn consume_stream_events( events: Vec, accumulation: &mut StreamAccumulation, + capture_reasoning: bool, emit_finish_only_delta: bool, on_delta: &mut F, ) -> Result @@ -2262,6 +2319,10 @@ where for event in events { let ParsedStreamEvent { delta_text, + reasoning_delta, + reasoning_summary_item_id, + reasoning_summary_index, + reasoning_snapshot, responses_output, text_snapshot, finish_reason: event_finish_reason, @@ -2283,6 +2344,98 @@ where accumulation.completion_observed = true; } + let mut reasoning_delta = if capture_reasoning { + reasoning_delta.unwrap_or_default() + } else { + String::new() + }; + let mut reasoning_snapshot_corrected = false; + if capture_reasoning { + if let Some(summary_index) = reasoning_summary_index { + let item_id = reasoning_summary_item_id + .unwrap_or_else(|| "__default_reasoning_item__".to_string()); + if !accumulation + .reasoning_summary_item_order + .iter() + .any(|known| known == &item_id) + { + accumulation + .reasoning_summary_item_order + .push(item_id.clone()); + } + let key = (item_id, summary_index); + if !reasoning_delta.is_empty() { + accumulation + .reasoning_summary_parts + .entry(key.clone()) + .or_default() + .push_str(reasoning_delta.as_str()); + accumulation.reasoning = accumulation + .reasoning_summary_item_order + .iter() + .flat_map(|item_id| { + accumulation + .reasoning_summary_parts + .iter() + .filter(move |((known_id, _), _)| known_id == item_id) + .map(|(_, text)| text.as_str()) + }) + .collect::(); + } + if let Some(snapshot) = reasoning_snapshot.filter(|text| !text.trim().is_empty()) { + let current_part = accumulation + .reasoning_summary_parts + .get(&key) + .cloned() + .unwrap_or_default(); + if snapshot != current_part { + reasoning_delta = if current_part.is_empty() { + snapshot.clone() + } else { + snapshot + .strip_prefix(current_part.as_str()) + .unwrap_or_default() + .to_string() + }; + accumulation.reasoning_summary_parts.insert(key, snapshot); + accumulation.reasoning = accumulation + .reasoning_summary_item_order + .iter() + .flat_map(|item_id| { + accumulation + .reasoning_summary_parts + .iter() + .filter(move |((known_id, _), _)| known_id == item_id) + .map(|(_, text)| text.as_str()) + }) + .collect::(); + reasoning_snapshot_corrected = true; + } else { + reasoning_delta.clear(); + } + } + } else { + let mut reasoning_snapshot_applied = false; + if let Some(snapshot) = reasoning_snapshot.filter(|text| !text.trim().is_empty()) { + if snapshot != accumulation.reasoning { + reasoning_delta = if accumulation.reasoning.is_empty() { + snapshot.clone() + } else { + snapshot + .strip_prefix(accumulation.reasoning.as_str()) + .unwrap_or_default() + .to_string() + }; + accumulation.reasoning = snapshot; + reasoning_snapshot_applied = true; + } + } + if !reasoning_snapshot_applied && !reasoning_delta.is_empty() { + accumulation.reasoning.push_str(reasoning_delta.as_str()); + } + } + } + if let Some(event_usage) = event_usage { accumulation.usage = Some(match accumulation.usage.take() { Some(previous) => { @@ -2356,18 +2509,27 @@ where if let Some(event_finish_reason) = event_finish_reason { accumulation.finish_reason = Some(event_finish_reason.clone()); - if has_delta || emit_finish_only_delta || snapshot_corrected { + if has_delta + || !reasoning_delta.is_empty() + || reasoning_snapshot_corrected + || emit_finish_only_delta + || snapshot_corrected + { let update = LlmStreamDelta { accumulated_text: accumulation.text.clone(), delta_text, + accumulated_reasoning: accumulation.reasoning.clone(), + reasoning_delta, finish_reason: Some(event_finish_reason), }; on_delta(&update); } - } else if has_delta { + } else if has_delta || !reasoning_delta.is_empty() || reasoning_snapshot_corrected { let update = LlmStreamDelta { accumulated_text: accumulation.text.clone(), delta_text, + accumulated_reasoning: accumulation.reasoning.clone(), + reasoning_delta, finish_reason: None, }; on_delta(&update); @@ -3187,20 +3349,32 @@ fn parse_text_response( api_kind: LlmApiKind, provider: LlmProvider, fallback_model: &str, + capture_reasoning: bool, raw_text: &str, ) -> Result { match api_kind { - LlmApiKind::OpenAiChat => { - parse_chat_completions_response(provider, fallback_model, raw_text) + LlmApiKind::OpenAiChat => parse_chat_completions_response_with_capture( + provider, + fallback_model, + capture_reasoning, + raw_text, + ), + LlmApiKind::OpenAiResponses => parse_responses_response_with_capture( + provider, + fallback_model, + capture_reasoning, + raw_text, + ), + LlmApiKind::Anthropic => { + parse_anthropic_response(provider, fallback_model, capture_reasoning, raw_text) } - LlmApiKind::OpenAiResponses => parse_responses_response(provider, fallback_model, raw_text), - LlmApiKind::Anthropic => parse_anthropic_response(provider, fallback_model, raw_text), } } -fn parse_chat_completions_response( +fn parse_chat_completions_response_with_capture( provider: LlmProvider, fallback_model: &str, + capture_reasoning: bool, raw_text: &str, ) -> Result { let parsed: ChatCompletionsResponsePayload = serde_json::from_str(raw_text) @@ -3232,8 +3406,16 @@ fn parse_chat_completions_response( Ok(LlmRunResponse { provider, - model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + model: parsed + .model + .clone() + .unwrap_or_else(|| fallback_model.to_string()), text: content, + reasoning: if capture_reasoning { + extract_message_reasoning(first_choice).unwrap_or_default() + } else { + String::new() + }, finish_reason: first_choice.finish_reason.clone(), response_id: parsed.id, usage: parsed.usage, @@ -3242,9 +3424,10 @@ fn parse_chat_completions_response( }) } -fn parse_responses_response( +fn parse_responses_response_with_capture( provider: LlmProvider, fallback_model: &str, + capture_reasoning: bool, raw_text: &str, ) -> Result { let raw: serde_json::Value = serde_json::from_str(raw_text).map_err(|error| { @@ -3276,8 +3459,16 @@ fn parse_responses_response( Ok(LlmRunResponse { provider, - model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + model: parsed + .model + .clone() + .unwrap_or_else(|| fallback_model.to_string()), text: content, + reasoning: if capture_reasoning { + extract_responses_reasoning(&parsed).unwrap_or_default() + } else { + String::new() + }, finish_reason: parsed.status, response_id: parsed.id, usage: parsed.usage.map(|usage| LlmTokenUsage { @@ -3293,6 +3484,7 @@ fn parse_responses_response( fn parse_anthropic_response( provider: LlmProvider, fallback_model: &str, + capture_reasoning: bool, raw_text: &str, ) -> Result { let parsed: AnthropicResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { @@ -3317,8 +3509,16 @@ fn parse_anthropic_response( Ok(LlmRunResponse { provider, - model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + model: parsed + .model + .clone() + .unwrap_or_else(|| fallback_model.to_string()), text: content, + reasoning: if capture_reasoning { + extract_anthropic_reasoning(&parsed).unwrap_or_default() + } else { + String::new() + }, finish_reason: parsed.stop_reason, response_id: parsed.id, usage: parsed.usage.map(map_anthropic_usage), @@ -3347,6 +3547,36 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option }) } +fn append_reasoning(target: &mut String, value: Option<&str>) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + target.push_str(value); +} + +fn extract_responses_reasoning(parsed: &ResponsesResponseEnvelope) -> Option { + let mut reasoning = String::new(); + for item in &parsed.output { + if item.item_type.as_deref() != Some("reasoning") { + continue; + } + if let Some(parts) = item.summary.as_ref().and_then(serde_json::Value::as_array) { + for part in parts { + append_reasoning( + &mut reasoning, + part.get("text").and_then(serde_json::Value::as_str), + ); + } + } + for part in &item.content { + if is_hidden_reasoning_part(part.part_type.as_deref()) { + append_reasoning(&mut reasoning, part.text.as_deref()); + } + } + } + (!reasoning.is_empty()).then_some(reasoning) +} + fn extract_responses_tool_calls( parsed: &ResponsesResponseEnvelope, ) -> Result, LlmError> { @@ -3398,6 +3628,16 @@ fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option if text.is_empty() { None } else { Some(text) } } +fn extract_anthropic_reasoning(parsed: &AnthropicResponseEnvelope) -> Option { + let mut reasoning = String::new(); + for block in &parsed.content { + if block.block_type.as_deref() == Some("thinking") { + append_reasoning(&mut reasoning, block.thinking.as_deref()); + } + } + (!reasoning.is_empty()).then_some(reasoning) +} + fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { choice .message @@ -3413,6 +3653,25 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { }) } +fn extract_message_reasoning(choice: &ChatCompletionsChoice) -> Option { + let mut reasoning = String::new(); + for message in [choice.message.as_ref(), choice.delta.as_ref()] + .into_iter() + .flatten() + { + append_reasoning(&mut reasoning, message.reasoning.as_deref()); + append_reasoning(&mut reasoning, message.reasoning_content.as_deref()); + if let Some(ChatCompletionsContent::Parts(parts)) = message.content.as_ref() { + for part in parts { + if is_hidden_reasoning_part(part.part_type.as_deref()) { + append_reasoning(&mut reasoning, part.text.as_deref()); + } + } + } + } + (!reasoning.is_empty()).then_some(reasoning) +} + fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result, LlmError> { let raw = choice .message @@ -3466,9 +3725,15 @@ fn is_hidden_reasoning_part(part_type: Option<&str>) -> bool { return false; }; - ["reasoning", "reasoning_content", "analysis", "thinking"] - .iter() - .any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type)) + [ + "reasoning", + "reasoning_content", + "reasoning_text", + "analysis", + "thinking", + ] + .iter() + .any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type)) } fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec), LlmError> { @@ -3559,6 +3824,7 @@ fn parse_sse_event_block( Ok(Some(ParsedStreamEvent { delta_text: extract_message_text(first_choice), + reasoning_delta: extract_message_reasoning(first_choice), finish_reason: first_choice.finish_reason.clone(), usage: parsed.usage, // Chat 的收尾信号是非空 finish_reason,不能只认 [DONE]:部分兼容网关(MiniMax) @@ -3628,6 +3894,34 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll .map(str::to_string), ..Default::default() })), + "response.reasoning_summary_text.delta" => Ok(Some(ParsedStreamEvent { + reasoning_delta: parsed + .get("delta") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + reasoning_summary_item_id: parsed + .get("item_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + reasoning_summary_index: parsed + .get("summary_index") + .and_then(serde_json::Value::as_u64), + ..Default::default() + })), + "response.reasoning_summary_text.done" => Ok(Some(ParsedStreamEvent { + reasoning_snapshot: parsed + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + reasoning_summary_item_id: parsed + .get("item_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + reasoning_summary_index: parsed + .get("summary_index") + .and_then(serde_json::Value::as_u64), + ..Default::default() + })), // completed 事件携带完整 output;有的网关只发它而不发增量事件,这里再取一遍, // 槽位沿用 output 数组下标,与 output_index 语义一致,可安全覆盖增量拼接结果。 // 整体收尾信号只有 completed 与 incomplete 两个;单个 item 的 @@ -3656,6 +3950,7 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll // 工具会让纯文本的 completed-only 响应变成 EmptyResponse,让「正文 + 工具」 // 响应静默丢掉模型的前置说明。 text_snapshot: extract_responses_terminal_text(&parsed), + reasoning_snapshot: extract_responses_terminal_reasoning(&parsed), responses_output: extract_responses_output_array(&parsed), tool_fragments: extract_responses_completed_tool_fragments(&parsed)?, ..Default::default() @@ -3720,7 +4015,7 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll // 终态事件的 response 字段就是一个完整 Response 对象,直接反序列化后复用非流式的正文 // 提取:它已经处理了 output_text 优先、output[].content[] 回退,以及 reasoning / -// reasoning_content / analysis / thinking 这些隐藏 part 的过滤。另写裸 JSON 提取器必然 +// reasoning_content / reasoning_text / analysis / thinking 这些隐藏 part 的过滤。另写裸 JSON 提取器必然 // 漏掉过滤层,会把思维链当正文吐给调用方。 fn extract_responses_terminal_text(parsed: &serde_json::Value) -> Option { let response = parsed.get("response")?; @@ -3731,6 +4026,12 @@ fn extract_responses_terminal_text(parsed: &serde_json::Value) -> Option extract_responses_text(&envelope).filter(|text| !text.trim().is_empty()) } +fn extract_responses_terminal_reasoning(parsed: &serde_json::Value) -> Option { + let response = parsed.get("response")?; + let envelope: ResponsesResponseEnvelope = serde_json::from_value(response.clone()).ok()?; + extract_responses_reasoning(&envelope) +} + fn extract_responses_output_array(parsed: &serde_json::Value) -> Option> { parsed .pointer("/response/output") @@ -3984,6 +4285,16 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll })); } + if delta_type == "thinking_delta" { + return Ok(Some(ParsedStreamEvent { + reasoning_delta: delta + .and_then(|value| value.get("thinking")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + ..Default::default() + })); + } + if delta_type != "text_delta" { return Ok(None); } @@ -4305,9 +4616,40 @@ mod tests { let request = LlmRunRequest::single_turn("系统", "用户"); assert_eq!(request.api_kind, LlmApiKind::OpenAiResponses); + assert!(!request.capture_reasoning); + assert!( + request + .clone() + .with_reasoning_capture(true) + .capture_reasoning + ); assert_eq!(request.with_openai_chat().api_kind, LlmApiKind::OpenAiChat); } + #[test] + fn reasoning_capture_switch_does_not_change_provider_request_body() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://example.com/v1".to_string(), + "secret".to_string(), + "model-a".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid"); + let request = LlmRunRequest::single_turn("系统", "用户"); + let normal = serde_json::to_value(build_request_body(&request, &config, false)) + .expect("normal body should serialize"); + let capture = serde_json::to_value(build_request_body( + &request.clone().with_reasoning_capture(true), + &config, + false, + )) + .expect("capture body should serialize"); + assert_eq!(normal, capture); + } + fn native_responses_output_fixture() -> Vec { vec![ serde_json::json!({ @@ -4324,9 +4666,10 @@ mod tests { #[test] fn native_responses_history_round_trips_reasoning_and_tool_result() { let output = native_responses_output_fixture(); - let response = parse_responses_response( + let response = parse_responses_response_with_capture( LlmProvider::OpenAiCompatible, "model", + false, &serde_json::json!({"id":"resp_1", "status":"completed", "output":output}).to_string(), ) .expect("native response"); @@ -4803,10 +5146,12 @@ mod tests { ] }"#; - let response = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw) - .expect("tool-only response should parse"); + let response = + parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw) + .expect("tool-only response should parse"); assert_eq!(response.text, ""); + assert!(response.reasoning.is_empty()); assert_eq!(response.finish_reason.as_deref(), Some("tool_use")); assert_eq!( response.tool_calls, @@ -4830,19 +5175,44 @@ mod tests { ] }"#; - let response = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw) - .expect("mixed response should parse"); + let response = + parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw) + .expect("mixed response should parse"); assert_eq!(response.text, "我来帮你查询。"); assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls[0].arguments, "{}"); } + #[test] + fn anthropic_response_captures_thinking_only_when_enabled() { + let raw = r#"{ + "id": "msg_thinking", + "model": "model-a", + "content": [ + { "type": "thinking", "thinking": "先分析需求。" }, + { "type": "text", "text": "最终答案" } + ], + "stop_reason": "end_turn" + }"#; + + let captured = + parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", true, raw) + .expect("Anthropic thinking should parse"); + assert_eq!(captured.text, "最终答案"); + assert_eq!(captured.reasoning, "先分析需求。"); + + let hidden = + parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw) + .expect("Anthropic response should parse without capture"); + assert!(hidden.reasoning.is_empty()); + } + #[test] fn anthropic_response_without_text_or_tool_calls_is_empty() { let raw = r#"{ "id": "msg_3", "model": "model-a", "content": [] }"#; - let error = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw) + let error = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw) .expect_err("empty content should fail"); assert_eq!(error, LlmError::EmptyResponse); @@ -5091,21 +5461,39 @@ mod tests { #[test] fn chat_response_excludes_standalone_reasoning_fields_from_text() { - let response = parse_chat_completions_response( + let response = parse_chat_completions_response_with_capture( LlmProvider::OpenAiCompatible, "fallback-model", + false, r#"{"id":"chat_reasoning_fields","choices":[{"message":{"reasoning_content":"内部推理","reasoning":"内部分析","content":null,"tool_calls":[{"id":"call_noop","type":"function","function":{"name":"noop","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}"#, ) .expect("tool call should keep the response valid without visible content"); assert_eq!(response.text, ""); + assert!(response.reasoning.is_empty()); + assert_eq!(response.tool_calls.len(), 1); + } + + #[test] + fn chat_response_captures_reasoning_without_mixing_into_text() { + let response = parse_chat_completions_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"choices":[{"message":{"reasoning_content":"先分析。","content":[{"type":"reasoning","text":"再检查。"},{"type":"text","text":"答案"}]},"finish_reason":"stop"}]}"#, + ) + .expect("chat reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先分析。再检查。"); } #[test] fn chat_response_filters_reasoning_parts_and_preserves_visible_parts() { - let response = parse_chat_completions_response( + let response = parse_chat_completions_response_with_capture( LlmProvider::OpenAiCompatible, "fallback-model", + false, r#"{"id":"chat_content_parts","choices":[{"message":{"content":[{"type":"reasoning","text":"内部推理"},{"type":"analysis","text":"内部分析"},{"type":"reasoning_content","text":"内部推理补充"},{"type":"thinking","text":"内部思考"},{"type":"text","text":"可见"},{"type":"output_text","text":"答案"}]},"finish_reason":"stop"}]}"#, ) .expect("visible chat content parts should parse"); @@ -5115,9 +5503,10 @@ mod tests { #[test] fn chat_response_preserves_visible_content_with_tool_calls() { - let response = parse_chat_completions_response( + let response = parse_chat_completions_response_with_capture( LlmProvider::OpenAiCompatible, "fallback-model", + false, r#"{"id":"chat_visible_tool_call","choices":[{"message":{"content":[{"type":"analysis","text":"内部分析"},{"type":"text","text":"先检查项目。"}],"tool_calls":[{"id":"call_project_index","type":"function","function":{"name":"project_index","arguments":"{\"path\":\"/tmp/game\"}"}}]},"finish_reason":"tool_calls"}]}"#, ) .expect("chat response with visible content and tool calls should parse"); @@ -5135,9 +5524,10 @@ mod tests { #[test] fn responses_response_filters_reasoning_parts_and_preserves_output_text() { - let response = parse_responses_response( + let response = parse_responses_response_with_capture( LlmProvider::OpenAiCompatible, "fallback-model", + false, r#"{"id":"responses_content_parts","output":[{"type":"message","content":[{"type":"analysis","text":"内部分析"},{"type":"output_text","text":"最终答案"}]}],"status":"completed"}"#, ) .expect("visible Responses content parts should parse"); @@ -5145,6 +5535,86 @@ mod tests { assert_eq!(response.text, "最终答案"); } + #[test] + fn responses_response_captures_reasoning_summary() { + let response = parse_responses_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"id":"resp_reasoning","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"},{"type":"summary_text","text":"再回答。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}],"status":"completed"}"#, + ) + .expect("Responses reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先判断。再回答。"); + } + + #[test] + fn responses_response_captures_reasoning_text_content_from_persisted_output() { + let response = parse_responses_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"id":"resp_reasoning_text","output":[{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"先分析需求,再组织方案。"}],"encrypted_content":"opaque"},{"type":"message","content":[{"type":"output_text","text":"正文"}]}],"status":"completed"}"#, + ) + .expect("Responses reasoning_text should parse"); + + assert_eq!(response.text, "正文"); + assert_eq!(response.reasoning, "先分析需求,再组织方案。"); + } + + #[test] + fn responses_response_captures_reasoning_alongside_tool_call() { + let response = parse_responses_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"id":"resp_reasoning_tool","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先分析工具需求。"}]},{"type":"message","content":[{"type":"output_text","text":"我先查询。"}]},{"type":"function_call","call_id":"call_lookup","name":"lookup","arguments":"{\"query\":\"项目\"}"}],"status":"completed"}"#, + ) + .expect("Responses reasoning plus tool call should parse"); + + assert_eq!(response.text, "我先查询。"); + assert_eq!(response.reasoning, "先分析工具需求。"); + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_lookup".to_string(), + name: "lookup".to_string(), + arguments: r#"{"query":"项目"}"#.to_string(), + }] + ); + } + + #[test] + fn stream_events_capture_reasoning_delta_and_terminal_snapshot() { + let chat = parse_sse_event_block( + LlmApiKind::OpenAiChat, + r#"data: {"choices":[{"delta":{"reasoning_content":"思考","content":"答案"}}]}"#, + ) + .expect("chat SSE should parse") + .expect("chat event should exist"); + assert_eq!(chat.reasoning_delta.as_deref(), Some("思考")); + assert_eq!(chat.delta_text.as_deref(), Some("答案")); + + let responses = parse_sse_event_block( + LlmApiKind::OpenAiResponses, + r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":2,"delta":"推理"}"#, + ) + .expect("Responses SSE should parse") + .expect("Responses event should exist"); + assert_eq!(responses.reasoning_delta.as_deref(), Some("推理")); + assert_eq!(responses.reasoning_summary_index, Some(2)); + + let terminal = parse_sse_event_block( + LlmApiKind::OpenAiResponses, + r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"完整推理"}]},{"type":"message","content":[{"type":"output_text","text":"正文"}]}]}}"#, + ) + .expect("terminal SSE should parse") + .expect("terminal event should exist"); + assert_eq!(terminal.reasoning_snapshot.as_deref(), Some("完整推理")); + assert_eq!(terminal.text_snapshot.as_deref(), Some("正文")); + } + #[tokio::test] async fn run_accepts_chat_tool_calls_without_text_content() { let server_url = spawn_mock_server(vec![MockResponse { @@ -6551,9 +7021,10 @@ mod tests { #[test] fn non_stream_responses_keeps_duplicate_call_ids_separate() { // 与上一条成对:同构载荷走非流式解析必须给出同样的两条,两条路径契约不能分叉。 - let response = parse_responses_response( + let response = parse_responses_response_with_capture( LlmProvider::OpenAiCompatible, "fallback", + false, &format!( r#"{{"id":"resp_1","output":[{DUPLICATE_CALL_ID_OUTPUT}],"status":"completed"}}"# ), @@ -6873,6 +7344,185 @@ mod tests { assert_eq!(response.text, "杭州今天多云。"); } + #[tokio::test] + async fn stream_run_captures_reasoning_separately_when_enabled() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"先判断。"}"#, "\n\n", + r#"data: {"type":"response.output_text.delta","delta":"答案"}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let mut updates = Vec::new(); + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_responses() + .with_reasoning_capture(true), + |delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())), + ) + .await + .expect("stream reasoning should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先判断。"); + assert!(updates.iter().any(|(_, reasoning)| reasoning == "先判断。")); + assert!( + updates + .iter() + .all(|(text, reasoning)| !(text.contains("先判断") || reasoning.contains("答案"))) + ); + } + + #[tokio::test] + async fn stream_run_captures_anthropic_thinking_separately_when_enabled() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先分析。"}}"#, "\n\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"答案"}}"#, "\n\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#, "\n\n", + r#"data: {"type":"message_stop"}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let mut updates = Vec::new(); + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_reasoning_capture(true), + |delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())), + ) + .await + .expect("Anthropic thinking stream should parse"); + + assert_eq!(response.text, "答案"); + assert_eq!(response.reasoning, "先分析。"); + assert!( + updates + .iter() + .any(|(text, reasoning)| text.is_empty() && reasoning == "先分析。") + ); + assert!( + updates + .iter() + .any(|(text, reasoning)| text == "答案" && reasoning.is_empty()) + ); + } + + #[tokio::test] + async fn stream_run_keeps_multiple_responses_reasoning_summary_parts() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":0,"delta":"第一段"}"#, "\n\n", + r#"data: {"type":"response.reasoning_summary_text.done","summary_index":0,"text":"第一段"}"#, "\n\n", + r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":1,"delta":"第二段"}"#, "\n\n", + r#"data: {"type":"response.reasoning_summary_text.done","summary_index":1,"text":"第二段"}"#, "\n\n", + // 终态故意不带 reasoning,验证不能依赖 response.completed 恢复前面的 summary part。 + r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let mut updates = Vec::new(); + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_responses() + .with_reasoning_capture(true), + |delta| { + updates.push(( + delta.accumulated_reasoning.clone(), + delta.reasoning_delta.clone(), + )) + }, + ) + .await + .expect("multiple reasoning summary parts should parse"); + + assert_eq!(response.reasoning, "第一段第二段"); + assert!(updates.iter().any(|(accumulated, delta)| { + accumulated == "第一段第二段" && delta == "第二段" + })); + } + + #[tokio::test] + async fn stream_run_keeps_reasoning_parts_separate_across_items() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-a","summary_index":0,"delta":"前置"}"#, "\n\n", + r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-b","summary_index":0,"delta":"后置"}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_responses() + .with_reasoning_capture(true), + |_| {}, + ) + .await + .expect("reasoning items should remain separate"); + + assert_eq!(response.reasoning, "前置后置"); + } + + #[tokio::test] + async fn stream_run_notifies_when_reasoning_snapshot_replaces_non_prefix_part() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-a","summary_index":0,"delta":"旧内容"}"#, "\n\n", + r#"data: {"type":"response.reasoning_summary_text.done","item_id":"item-a","summary_index":0,"text":"新内容"}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let mut updates = Vec::new(); + let response = build_test_client(server_url, 0) + .stream_run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_responses() + .with_reasoning_capture(true), + |delta| { + updates.push(( + delta.accumulated_reasoning.clone(), + delta.reasoning_delta.clone(), + )) + }, + ) + .await + .expect("replacement snapshot should parse"); + + assert_eq!(response.reasoning, "新内容"); + assert!( + updates + .iter() + .any(|(accumulated, delta)| accumulated == "新内容" && delta.is_empty()) + ); + } + #[tokio::test] async fn stream_run_accumulates_parallel_anthropic_tool_calls() { let server_url = spawn_mock_server(vec![MockResponse { diff --git a/server-rs/crates/platform-llm/src/provider_adapter.rs b/server-rs/crates/platform-llm/src/provider_adapter.rs index 13346ebb6..7392ec2d1 100644 --- a/server-rs/crates/platform-llm/src/provider_adapter.rs +++ b/server-rs/crates/platform-llm/src/provider_adapter.rs @@ -419,6 +419,7 @@ pub fn llm_response_from_provider_response( provider, model: response.model().to_string(), text: text_parts.join(""), + reasoning: String::new(), finish_reason: response.finish_reason().map(str::to_string), response_id: response.response_id().map(str::to_string), usage: response.usage().map(|usage| crate::LlmTokenUsage { @@ -807,6 +808,7 @@ mod tests { assert_eq!(mapped.max_output_tokens, Some(2048)); assert_eq!(mapped.request_timeout_ms, Some(3000)); assert!(mapped.enable_web_search); + assert!(!mapped.capture_reasoning); assert_eq!(mapped.function_tools.len(), 1); assert!(mapped.function_tools[0].strict); assert_eq!(mapped.tool_choice, Some(LlmToolChoice::Required)); @@ -882,6 +884,7 @@ mod tests { provider: LlmProvider::OpenAiCompatible, model: "model-1".to_string(), text: "完成".to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("upstream-response".to_string()), usage: Some(LlmTokenUsage { @@ -1089,7 +1092,7 @@ mod tests { ( "text/event-stream", concat!( - "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"可\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"reasoning_content\":\"隐藏\",\"content\":\"可\"},\"finish_reason\":null}]}\n\n", "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"用\"},\"finish_reason\":\"stop\"}]}\n\n", "data: [DONE]\n\n" ), @@ -1097,7 +1100,7 @@ mod tests { } else { ( "application/json", - r#"{"id":"loopback-non-stream","model":"loopback-model","choices":[{"message":{"content":"可用"},"finish_reason":"stop"}]}"#, + r#"{"id":"loopback-non-stream","model":"loopback-model","choices":[{"message":{"reasoning_content":"隐藏","content":"可用"},"finish_reason":"stop"}]}"#, ) }; let response = format!(