diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 5d7797c64..a3e1b3b95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -10,6 +10,7 @@ mod json_sidecar; mod models; mod planning_approval; mod planning_coordinator; +mod planning_hydrate; mod planning_provider_usage; mod planning_storage; mod planning_submit; @@ -28,6 +29,7 @@ pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; pub(crate) use planning_approval::*; pub(crate) use planning_coordinator::*; +pub(crate) use planning_hydrate::*; pub(crate) use planning_provider_usage::*; pub(crate) use planning_storage::*; pub(crate) use planning_submit::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs new file mode 100644 index 000000000..1c5caf85c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs @@ -0,0 +1,520 @@ +use super::*; + +use std::collections::BTreeMap; +use std::path::Path; + +pub(crate) const PLAN_GDD_STATE_VIEW_SCHEMA_VERSION: &str = "plan-gdd-state-view.v1"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateViewV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: Option, + pub(crate) state: String, + pub(crate) session: Option, + pub(crate) versions: Vec, + pub(crate) display_gdd: Option, + pub(crate) pending_approval: Option, + pub(crate) approved_gdd_ref: Option, + pub(crate) recovery_pending: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateSessionView { + pub(crate) session_id: String, + pub(crate) session_revision: u32, + pub(crate) session_fingerprint: String, + pub(crate) phase: String, + pub(crate) clarification_round: u32, + pub(crate) repair_depth: u32, + pub(crate) accumulated_agent_millis: u64, + pub(crate) active_run_id: Option, + pub(crate) awaiting_answer_for: Option, + pub(crate) decision_state_counts: PlanGddStateDecisionCounts, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateAwaitingAnswerView { + pub(crate) delegation_id: String, + pub(crate) request_id: String, + pub(crate) question_id: String, + pub(crate) round: u32, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateDecisionCounts { + pub(crate) confirmed: u32, + pub(crate) default_pending: u32, + pub(crate) prototype_pending: u32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateVersionView { + pub(crate) gdd_ref: PlanGddRef, + pub(crate) status: String, + pub(crate) approval_request_id: String, + pub(crate) created_at_utc: String, + pub(crate) decision: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStateDecisionView { + pub(crate) action: String, + pub(crate) decided_at_utc: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanGddStatePendingApprovalView { + pub(crate) gdd_ref: PlanGddRef, + pub(crate) pending_action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) approval_request_id: String, + pub(crate) session_id: String, + pub(crate) run_id: String, +} + +fn plan_gdd_state_error(code: &'static str, detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new(code, detail) +} + +fn pending_approval_view( + gdd: &PlanGddV1, + pending: Option<&PlanGddApprovalPendingV1>, +) -> Result { + let expected = PlanGddStatePendingApprovalView { + gdd_ref: PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }, + pending_action_id: gdd.submission_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + approval_request_id: gdd.approval_request_id.clone(), + session_id: gdd.session_id.clone(), + run_id: gdd.created_by_run_id.clone(), + }; + if let Some(pending) = pending { + if !pending_matches_gdd(pending, gdd) { + return Err(plan_gdd_state_error( + "PLAN_CORRUPT_AUTHORITY", + "approval pending 与当前 GDD 权威身份不一致", + )); + } + if pending.status != "awaiting_decision" { + return Err(plan_gdd_state_error( + "PLAN_CORRUPT_AUTHORITY", + "未决定 GDD 的 approval pending 状态非法", + )); + } + } + Ok(expected) +} + +fn build_pending_approval( + gdds: &[PlanGddV1], + approvals: &[PlanGddApprovalV1], + pending: Option<&PlanGddApprovalPendingV1>, +) -> Result, PlanningStorageError> { + let Some(latest) = gdds.last() else { + return Ok(None); + }; + if approvals + .iter() + .any(|receipt| receipt.version == latest.version) + { + return Ok(None); + } + // `awaiting_gdd_approval` is installed by plan.submit_gdd before the + // acceptance gate has passed. It is therefore not proof that the user + // may decide yet. The gdd-approval pending sidecar is the durable marker + // that the gate has passed; without it the caller must keep waiting or + // retry projection recovery and must not expose a decision action. + if pending.is_none() { + return Ok(None); + } + pending_approval_view(latest, pending).map(Some) +} + +fn build_awaiting_answer( + root: &Path, + session: &PlanSessionV1, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result<(Option, bool), PlanningStorageError> { + if session.phase != "awaiting_user_input" { + return Ok((None, false)); + } + let Some(delivery) = deliveries + .iter() + .find(|delivery| delivery.delegation_id == session.latest_delegation_id) + else { + return Ok((None, true)); + }; + let Some(result) = delivery.structured_result.as_ref().filter(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.clarification_answers_sha256.is_none() + }) else { + return Ok((None, true)); + }; + let Some(question) = result.user_input_questions.first() else { + return Ok((None, true)); + }; + let pending = match read_game_creator_agent_runtime_pending_tool_action( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session.root_run_id, + ) { + Ok(value) => value, + Err(_) => return Ok((None, true)), + }; + if pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL + || !static_delegate_clarification_pending_matches_delivery_at(root, &pending) + .map_err(|error| plan_gdd_state_error("PLAN_SESSION_RECOVERY_REQUIRED", error))? + { + return Ok((None, true)); + } + let request_id = pending + .action + .input + .get("requestId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "用户澄清 pending 缺少 requestId", + ) + })?; + let (_, round) = static_delegate_lineage_counters(deliveries, &delivery.delegation_id); + if round == u32::MAX { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "无法从静态委派谱系派生澄清轮次", + )); + } + Ok(( + Some(PlanGddStateAwaitingAnswerView { + delegation_id: delivery.delegation_id.clone(), + request_id: request_id.to_string(), + question_id: question.id.clone(), + round, + }), + false, + )) +} + +fn build_session_view( + root: &Path, + session: &PlanSessionV1, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result<(PlanGddStateSessionView, bool), PlanningStorageError> { + let (repair_depth, clarification_round) = if deliveries + .iter() + .any(|delivery| delivery.delegation_id == session.latest_delegation_id) + { + let counters = static_delegate_lineage_counters(deliveries, &session.latest_delegation_id); + if counters.0 == u32::MAX || counters.1 == u32::MAX { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "无法从静态委派谱系派生 planning session 轮次", + )); + } + counters + } else if session.applied_answers.is_empty() { + (0, 0) + } else { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "planning session 缺少 latestDelegationId 对应的静态委派谱系", + )); + }; + let mut decision_state_counts = PlanGddStateDecisionCounts::default(); + for decision in &session.decisions_summary { + match decision.state.as_str() { + "confirmed" => decision_state_counts.confirmed += 1, + "default_pending" => decision_state_counts.default_pending += 1, + "prototype_pending" => decision_state_counts.prototype_pending += 1, + _ => { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "planning session 含未知决定状态", + )); + } + } + } + let (awaiting_answer_for, recovery_pending) = build_awaiting_answer(root, session, deliveries)?; + Ok(( + PlanGddStateSessionView { + session_id: session.session_id.clone(), + session_revision: session.session_revision, + session_fingerprint: session.session_fingerprint.clone(), + phase: session.phase.clone(), + clarification_round, + repair_depth, + accumulated_agent_millis: session.accumulated_agent_millis, + active_run_id: session.active_run_id.clone(), + awaiting_answer_for, + decision_state_counts, + }, + recovery_pending, + )) +} + +fn build_state_view_locked( + root: &Path, + project_id: &str, + gdds: Vec, + approvals: Vec, + session: Option, + pending: Option, + mut recovery_pending: bool, +) -> Result { + for gdd in &gdds { + if gdd.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "GDD projectId 与当前项目 manifest 不一致", + )); + } + } + if let Some(session) = session.as_ref() { + if session.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "planning session projectId 与当前项目 manifest 不一致", + )); + } + } + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; + let deliveries = if session.is_some() { + crate::delegation::list_static_delegate_deliveries_at(root) + .map_err(|error| plan_gdd_state_error("PLAN_SESSION_RECOVERY_REQUIRED", error))? + } else { + Vec::new() + }; + let session_view = if let Some(session) = session.as_ref() { + let (view, session_recovery_pending) = build_session_view(root, session, &deliveries)?; + recovery_pending |= session_recovery_pending; + Some(view) + } else { + None + }; + + let receipt_by_version = approvals + .iter() + .map(|receipt| (receipt.version, receipt)) + .collect::>(); + let approved_version = approvals + .iter() + .filter(|receipt| receipt.action == "approve") + .map(|receipt| receipt.version) + .max(); + let versions = gdds + .iter() + .map(|gdd| { + let (status, decision) = match receipt_by_version.get(&gdd.version) { + None => ("ready_for_approval".to_string(), None), + Some(receipt) => { + let status = match receipt.action.as_str() { + "approve" if Some(gdd.version) == approved_version => "approved", + "approve" => "superseded", + "revise" => "revision_requested", + "reject" => "rejected", + _ => unreachable!("validated approval action"), + }; + ( + status.to_string(), + Some(PlanGddStateDecisionView { + action: receipt.action.clone(), + decided_at_utc: receipt.decided_at_utc.clone(), + }), + ) + } + }; + PlanGddStateVersionView { + gdd_ref: PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }, + status, + approval_request_id: gdd.approval_request_id.clone(), + created_at_utc: gdd.created_at_utc.clone(), + decision, + } + }) + .collect::>(); + let approved_gdd = approved_version + .and_then(|version| gdds.iter().find(|gdd| gdd.version == version).cloned()); + let pending_gdd = gdds + .iter() + .find(|gdd| !receipt_by_version.contains_key(&gdd.version)); + let pending_approval = build_pending_approval(&gdds, &approvals, pending.as_ref())?; + let display_gdd = pending_gdd + .cloned() + .or_else(|| approved_gdd.clone()) + .or_else(|| gdds.last().cloned()); + let state = if pending_gdd.is_some() { + "ready_for_approval" + } else if session + .as_ref() + .is_some_and(|value| matches!(value.phase.as_str(), "collecting" | "awaiting_user_input")) + { + "draft" + } else if let Some(latest) = gdds + .last() + .and_then(|gdd| receipt_by_version.get(&gdd.version)) + { + match latest.action.as_str() { + "approve" => "approved", + "revise" => "revision_requested", + "reject" => "rejected", + _ => unreachable!("validated approval action"), + } + } else if session.is_some() { + "draft" + } else { + "not_started" + }; + if gdds.is_empty() + && session.as_ref().is_some_and(|value| { + value.latest_submitted_ref.is_some() || value.last_decision_ref.is_some() + }) + { + return Err(plan_gdd_state_error( + "PLAN_CORRUPT_AUTHORITY", + "planning session 引用了不存在的 GDD 权威事实", + )); + } + Ok(PlanGddStateViewV1 { + schema_version: PLAN_GDD_STATE_VIEW_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + gdd_id: gdds.first().map(|gdd| gdd.gdd_id.clone()), + state: state.to_string(), + session: session_view, + versions, + display_gdd, + pending_approval, + approved_gdd_ref: approved_gdd.map(|gdd| PlanGddRef { + gdd_id: gdd.gdd_id, + version: gdd.version, + fingerprint: gdd.fingerprint, + }), + recovery_pending, + }) +} + +pub(crate) fn hydrate_game_creator_plan_gdd_state_at( + root: &Path, +) -> Result { + let manifest = crate::project::read_existing_manifest_for_project(root) + .map_err(|error| plan_gdd_state_error("PLAN_PROJECT_ID_MISMATCH", error))?; + let project_id = manifest.project_id.trim(); + if project_id.is_empty() { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "项目 manifest 缺少 projectId", + )); + } + + let mut recovery_pending = reconcile_plan_gdd_approval_projections_at(root) + .map_err(|error| plan_gdd_state_error(error.code(), error.to_string()))?; + let _lock = crate::project::acquire_project_write_lock(root, "planning.hydrate") + .map_err(|error| plan_gdd_state_error("PLAN_STORAGE_IO", error))?; + let gdds = read_plan_gdd_chain_locked(root)?; + let approvals = read_plan_gdd_approvals_locked(root)?; + let session = read_plan_session_with_recovery_locked(root)?; + let pending = if gdds.last().is_some_and(|latest| { + approvals + .iter() + .any(|receipt| receipt.version == latest.version) + }) { + None + } else { + read_plan_gdd_approval_pending_locked(root)? + }; + let _index = read_plan_gdd_index_with_recovery_locked(root, ¤t_plan_timestamp_utc())?; + if let Some(pending) = pending.as_ref() { + if pending.status != "awaiting_decision" { + recovery_pending = true; + } + } + let unapproved_gdd = gdds.iter().find(|gdd| { + !approvals + .iter() + .any(|receipt| receipt.version == gdd.version) + }); + if let Some(gdd) = unapproved_gdd { + let Some(session) = session.as_ref() else { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "未审批 GDD 缺少对应 planning session", + )); + }; + let expected_ref = PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }; + if session.latest_submitted_ref.as_ref() != Some(&expected_ref) + || session.phase != "awaiting_gdd_approval" + || session.active_run_id.is_some() + { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "未审批 GDD 与 planning session 的提交 successor 不一致", + )); + } + } + if let (Some(session), Some(first_gdd)) = (session.as_ref(), gdds.first()) { + if session.gdd_id != first_gdd.gdd_id { + return Err(plan_gdd_state_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "planning session 的 GDD lineage identity 不一致", + )); + } + } + build_state_view_locked( + root, + project_id, + gdds, + approvals, + session, + pending, + recovery_pending, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hydrate_initialized_project_without_planning_returns_not_started_without_creating_storage() { + let temporary = tempfile::tempdir().expect("create hydrate fixture"); + let root = temporary.path().join("project"); + crate::project::init_local_game_project_at(&root, "hydrate-empty", "空策划项目") + .expect("initialize hydrate fixture"); + + let view = hydrate_game_creator_plan_gdd_state_at(&root).expect("hydrate empty project"); + + assert_eq!(view.schema_version, PLAN_GDD_STATE_VIEW_SCHEMA_VERSION); + assert_eq!(view.project_id, "hydrate-empty"); + assert_eq!(view.state, "not_started"); + assert!(view.gdd_id.is_none()); + assert!(view.session.is_none()); + assert!(view.versions.is_empty()); + assert!(view.display_gdd.is_none()); + assert!(view.pending_approval.is_none()); + assert!(view.approved_gdd_ref.is_none()); + assert!(!view.recovery_pending); + assert!(!root.join(PLAN_STORAGE_ROOT).exists()); + } +} 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 4816c145c..29a7497a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,23 @@ use super::*; +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct HydratePlanGddStateInput { + project_path: String, +} + +fn parse_hydrate_plan_gdd_state_input( + request: &tauri::ipc::Request<'_>, +) -> Result { + match request.body() { + tauri::ipc::InvokeBody::Json(value) => serde_json::from_value(value.clone()) + .map_err(|error| format!("hydrate_game_creator_plan_gdd_state 输入无效:{error}")), + tauri::ipc::InvokeBody::Raw(_) => { + Err("hydrate_game_creator_plan_gdd_state 只接受 JSON 输入 {projectPath}".to_string()) + } + } +} + const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { @@ -1248,6 +1266,16 @@ pub(crate) fn decide_game_creator_plan_gdd( Ok(result) } +#[tauri::command] +pub(crate) fn hydrate_game_creator_plan_gdd_state( + request: tauri::ipc::Request<'_>, +) -> Result { + let input = parse_hydrate_plan_gdd_state_input(&request)?; + let root = validated_local_project_directory_path(input.project_path.trim())?; + enforce_project_permission_policy(&root, "conversation.read")?; + hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string()) +} + #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d5e1e4856..9099ef240 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2209,6 +2209,7 @@ fn main() { reject_game_creator_agent_runtime_task, answer_game_creator_agent_runtime_user_input, decide_game_creator_plan_gdd, + hydrate_game_creator_plan_gdd_state, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ece9e3a79..143026428 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -81,6 +81,8 @@ import type { MemoryScope, MemoryWriteMode, OpenCanvasProjectResult, + PlanGddDecisionAction, + PlanGddStateViewV1, PendingCommand, PendingUiConfirmation, ProjectPermissionPolicy, @@ -602,6 +604,7 @@ export function App({ : { status }, ); } + const [chatInput, setChatInput] = useState(() => (supervisorChatOnly || gameChatOnly) && initialProjectPath ? readSupervisorChatDraft(initialProjectPath) @@ -617,6 +620,133 @@ export function App({ useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); + const [planGddState, setPlanGddState] = useState( + null, + ); + const planGddStateRef = useRef(null); + planGddStateRef.current = planGddState; + const planGddHydrateSequenceRef = useRef(0); + const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); + const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); + const [planGddError, setPlanGddError] = useState(null); + const planGddDecisionResponseIdsRef = useRef(new Map()); + + const hydratePlanGddState = useCallback( + async (nextProjectPath?: string) => { + const targetProjectPath = + nextProjectPath?.trim() || localProjectPathRef.current || projectPath; + const invoke = resolveTauriInvoke(); + if (!invoke || !targetProjectPath.trim()) { + setPlanGddState(null); + return; + } + const requestSequence = ++planGddHydrateSequenceRef.current; + setPlanGddHydrateBusy(true); + setPlanGddError(null); + try { + const nextState = await invoke( + 'hydrate_game_creator_plan_gdd_state', + { projectPath: targetProjectPath }, + ); + if ( + requestSequence === planGddHydrateSequenceRef.current && + localProjectPathRef.current === targetProjectPath + ) { + setPlanGddState(nextState); + } + } catch (error) { + if ( + requestSequence === planGddHydrateSequenceRef.current && + localProjectPathRef.current === targetProjectPath + ) { + setPlanGddError(String(error)); + } + } finally { + if (requestSequence === planGddHydrateSequenceRef.current) { + setPlanGddHydrateBusy(false); + } + } + }, + [projectPath], + ); + + const decidePlanGdd = useCallback( + async (action: PlanGddDecisionAction, comment: string | null) => { + const current = planGddStateRef.current; + const pending = current?.pendingApproval; + const targetProjectPath = + resolveChatProjectPath(localProject) ?? projectPath; + const invoke = resolveTauriInvoke(); + if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) { + throw new Error('当前没有可提交的 GDD 审批决定'); + } + const responseKey = `${pending.approvalRequestId}:${action}`; + const responseId = + planGddDecisionResponseIdsRef.current.get(responseKey) ?? + `gdd-response-${crypto.randomUUID()}`; + planGddDecisionResponseIdsRef.current.set(responseKey, responseId); + setPlanGddDecisionBusy(true); + setPlanGddError(null); + try { + await invoke('decide_game_creator_plan_gdd', { + projectPath: targetProjectPath, + gddId: pending.gddRef.gddId, + version: pending.gddRef.version, + fingerprint: pending.gddRef.fingerprint, + pendingActionId: pending.pendingActionId, + approvalRequestId: pending.approvalRequestId, + responseId, + action, + comment, + }); + await hydratePlanGddState(targetProjectPath); + } catch (error) { + setPlanGddError(String(error)); + throw error; + } finally { + setPlanGddDecisionBusy(false); + } + }, + [hydratePlanGddState, localProject, projectPath], + ); + + useEffect(() => { + const targetProjectPath = localProject?.projectPath; + if (!targetProjectPath) { + setPlanGddState(null); + setPlanGddError(null); + return; + } + void hydratePlanGddState(targetProjectPath); + }, [hydratePlanGddState, localProject?.projectPath]); + + useEffect(() => { + if (!localProject?.projectPath || !projectSupervisorRuntime) { + return; + } + void hydratePlanGddState(localProject.projectPath); + }, [ + hydratePlanGddState, + localProject?.projectPath, + projectSupervisorRuntime?.phase, + projectSupervisorRuntime?.status, + projectSupervisorRuntime?.updatedAt, + ]); + + useEffect(() => { + const hydrateOnResume = () => { + if (document.visibilityState === 'hidden' || !localProject?.projectPath) { + return; + } + void hydratePlanGddState(localProject.projectPath); + }; + window.addEventListener('focus', hydrateOnResume); + document.addEventListener('visibilitychange', hydrateOnResume); + return () => { + window.removeEventListener('focus', hydrateOnResume); + document.removeEventListener('visibilitychange', hydrateOnResume); + }; + }, [hydratePlanGddState, localProject?.projectPath]); const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); @@ -11278,6 +11408,11 @@ export function App({ projectSupervisorRuntime={projectSupervisorRuntime} projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} + planGddState={planGddState} + planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddError={planGddError} + onPlanGddRefresh={() => void hydratePlanGddState()} + onPlanGddDecision={decidePlanGdd} queueAgentRunControlFromPanel={queueAgentRunControlFromPanel} queueOrExecuteProjectIndex={queueOrExecuteProjectIndex} queuePendingCommand={queuePendingCommand} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index cbf05e886..177586f13 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -129,6 +129,159 @@ export interface GenerateLocalGameDraftResult { manifest: GameCreationAppManifest; } +export type PlanGddDecisionAction = 'approve' | 'revise' | 'reject'; + +export interface PlanGddStateViewV1 { + schemaVersion: 'plan-gdd-state-view.v1'; + projectId: string; + gddId: string | null; + state: + | 'not_started' + | 'draft' + | 'ready_for_approval' + | 'revision_requested' + | 'approved' + | 'rejected'; + session: { + sessionId: string; + sessionRevision: number; + sessionFingerprint: string; + phase: + | 'collecting' + | 'awaiting_user_input' + | 'awaiting_gdd_approval' + | 'revision_requested' + | 'approved' + | 'rejected' + | 'recovery_required'; + clarificationRound: number; + repairDepth: number; + accumulatedAgentMillis: number; + activeRunId: string | null; + awaitingAnswerFor: { + delegationId: string; + requestId: string; + questionId: string; + round: number; + } | null; + decisionStateCounts: { + confirmed: number; + defaultPending: number; + prototypePending: number; + }; + } | null; + versions: Array<{ + gddRef: { gddId: string; version: number; fingerprint: string }; + status: + | 'ready_for_approval' + | 'revision_requested' + | 'approved' + | 'rejected' + | 'superseded'; + approvalRequestId: string; + createdAtUtc: string; + decision: { action: PlanGddDecisionAction; decidedAtUtc: string } | null; + }>; + displayGdd: { + schemaVersion: string; + projectId: string; + gddId: string; + version: number; + submissionId: string; + approvalRequestId: string; + actionFingerprint: string; + agentId: string; + source: string; + runProfile: string; + runProfileBindingFingerprint: string; + rootAgentId: string; + rootRunId: string; + delegationId: string; + sessionId: string; + sourceSessionRevision: number; + sourceSessionFingerprint: string; + createdByRunId: string; + createdAtUtc: string; + fingerprint: string; + game: { + title: string; + oneLiner: string; + genre: { primary: string; fusion: string | null }; + artStyle: { + visualType: string; + keywords: string[]; + moodAndColor: string; + mvpArtBoundary: string; + }; + pillars: Array<{ + name: string; + playerFeel: string; + mechanism: string; + decisionState: string; + basis: null; + }>; + coreLoop: string[]; + targetUsers: { + coreUsers: string; + preferences: string; + sessionLength: string; + referenceGames: string[]; + }; + platformFacts: { + runtime: string; + viewports: string[]; + inputs: string[]; + preview: string; + }; + mvpSystems: Array<{ + system: string; + minimalFunction: string; + whyRequired: string; + verifyMethod: string; + decisionState: string; + basis: null; + }>; + outOfScope: string[]; + creatorTips: { + doFirst: string; + deferForNow: string; + howToVerify: string; + expandWhen: string; + }; + }; + decisions: Array<{ + id: string; + topic: string; + state: 'confirmed' | 'default_pending' | 'prototype_pending'; + answerSource: 'user_option' | 'user_freeform' | 'default'; + round: number; + answerSummary: string; + basis: null; + }>; + prototypeValidationItems: Array<{ + id: string; + question: string; + microPrototype: string; + observation: string; + passCriterion: string; + }>; + } | null; + pendingApproval: { + gddRef: { gddId: string; version: number; fingerprint: string }; + pendingActionId: string; + actionFingerprint: string; + approvalRequestId: string; + sessionId: string; + runId: string; + } | null; + approvedGddRef: { + gddId: string; + version: number; + fingerprint: string; + } | null; + recoveryPending: boolean; +} + export interface GameCreatorChatAgentReply { replyText: string; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx new file mode 100644 index 000000000..7373829bd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -0,0 +1,313 @@ +import { useState } from 'react'; + +import type { + PlanGddDecisionAction, + PlanGddStateViewV1, +} from '../../app/types'; + +type GddApprovalCardProps = { + state: PlanGddStateViewV1 | null; + busy: boolean; + error: string | null; + onRefresh: () => void; + onDecision: ( + action: PlanGddDecisionAction, + comment: string | null, + ) => Promise; +}; + +const stateLabels: Record = { + not_started: '未开始', + draft: '策划中', + ready_for_approval: '待审批', + revision_requested: '待修订', + approved: '已批准', + rejected: '已退回', +}; + +const decisionStateLabels = { + confirmed: '已确认', + default_pending: '待确认默认项', + prototype_pending: '待原型验证', +}; + +const visibleGddSections = ( + gdd: NonNullable, +) => [ + { + title: '类型与美术', + content: [ + `类型:${gdd.game.genre.primary}${gdd.game.genre.fusion ? ` / ${gdd.game.genre.fusion}` : ''}`, + `视觉:${gdd.game.artStyle.visualType}`, + `关键词:${gdd.game.artStyle.keywords.join('、')}`, + `氛围:${gdd.game.artStyle.moodAndColor}`, + `MVP 美术边界:${gdd.game.artStyle.mvpArtBoundary}`, + ].join('\n'), + }, + { + title: '游戏支柱', + content: gdd.game.pillars + .map( + (pillar) => `${pillar.name}:${pillar.playerFeel};${pillar.mechanism}`, + ) + .join('\n'), + }, + { title: '核心循环', content: gdd.game.coreLoop.join(' → ') }, + { + title: '目标用户', + content: [ + `核心用户:${gdd.game.targetUsers.coreUsers}`, + `偏好:${gdd.game.targetUsers.preferences}`, + `单局时长:${gdd.game.targetUsers.sessionLength}`, + `参考作品:${gdd.game.targetUsers.referenceGames.join('、') || '无'}`, + ].join('\n'), + }, + { + title: '平台事实', + content: [ + `运行时:${gdd.game.platformFacts.runtime}`, + `视口:${gdd.game.platformFacts.viewports.join('、')}`, + `输入:${gdd.game.platformFacts.inputs.join('、')}`, + `预览:${gdd.game.platformFacts.preview}`, + ].join('\n'), + }, + { + title: 'MVP 系统', + content: gdd.game.mvpSystems + .map( + (system) => + `${system.system}:${system.minimalFunction}\n为什么需要:${system.whyRequired}\n验证:${system.verifyMethod}`, + ) + .join('\n'), + }, + { title: '暂不纳入', content: gdd.game.outOfScope.join('、') }, + { + title: '创作者提示', + content: [ + `先做:${gdd.game.creatorTips.doFirst}`, + `暂缓:${gdd.game.creatorTips.deferForNow}`, + `验证:${gdd.game.creatorTips.howToVerify}`, + `扩展条件:${gdd.game.creatorTips.expandWhen}`, + ].join('\n'), + }, + { + title: '原型验证项', + content: gdd.prototypeValidationItems + .map( + (item) => + `${item.question}\n微型原型:${item.microPrototype}\n观察:${item.observation}\n通过标准:${item.passCriterion}`, + ) + .join('\n'), + }, +]; + +export function GddApprovalCard({ + state, + busy, + error, + onRefresh, + onDecision, +}: GddApprovalCardProps) { + const [commentAction, setCommentAction] = useState | null>(null); + const [comment, setComment] = useState(''); + const [gddDetailsOpen, setGddDetailsOpen] = useState(false); + + if ( + !state?.displayGdd || + (!state.pendingApproval && !state.recoveryPending) + ) { + return null; + } + + const gdd = state.displayGdd; + const pending = state.pendingApproval; + const canDecide = Boolean( + pending && state.state === 'ready_for_approval' && !state.recoveryPending, + ); + + const submitComment = () => { + if (!commentAction || !comment.trim()) { + return; + } + void onDecision(commentAction, comment.trim()) + .then(() => { + setCommentAction(null); + setComment(''); + }) + .catch(() => undefined); + }; + + return ( +
+
+
+ 立项策划 +

{gdd.game.title || `Fast GDD v${gdd.version}`}

+

{gdd.game.oneLiner}

+
+ + {stateLabels[state.state]} + +
+ +
+ {`版本 v${gdd.version}`} + {`指纹 ${gdd.fingerprint.slice(0, 12)}…`} + {`${gdd.decisions.length} 项决定`} +
+ + {gdd.decisions.length > 0 ? ( +
+ {gdd.decisions.map((decision) => ( +
+ {decision.topic} + {decisionStateLabels[decision.state]} + {decision.answerSummary} +
+ ))} +
+ ) : null} + + + + {state.recoveryPending ? ( +
+ 审批状态正在恢复,请保持当前审批版本不变。 + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {pending ? ( +
+ + + +
+ ) : null} + + {commentAction ? ( +
{ + if (event.target === event.currentTarget) { + setCommentAction(null); + setComment(''); + } + }} + > +
+

+ {commentAction === 'revise' ? '填写修改意见' : '填写退回原因'} +

+