From 9bbefdc37160fd5a67da678d32f0c2b68e914c7f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Direct=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=B7=A8=E9=A1=B5=E9=9D=A2=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增活动 Direct 回合只读快照与状态更新 接入重进项目的忙碌态恢复和运行中项目面板 补充生命周期规范、决策记录与定向测试 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 113 +++++++++++++++++ .../src/agent/runtime_driver/entrypoints.rs | 14 ++- .../src-tauri/src/commands.rs | 6 + .../src-tauri/src/main.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 24 ++++ apps/ai-game-creator-shell/src/app/types.ts | 11 ++ .../agent-runtime/directActiveTurns.ts | 110 ++++++++++++++++ .../app-shell/ActiveProjectRunsPanel.tsx | 119 ++++++++++++++++++ .../features/app-shell/WorkspaceLauncher.tsx | 17 +++ apps/ai-game-creator-shell/src/styles.css | 74 +++++++++++ .../tests/directActiveTurns.test.tsx | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 33 +++++ .../shared-memory/decision-log.md | 3 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 ++ 15 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx create mode 100644 apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index ee32bb97a..23e61f89c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5,6 +5,7 @@ use std::future::Future; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id}; @@ -266,6 +267,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< @@ -303,6 +323,18 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), + project_name: root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string), + started_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: 0, + sequence: 0, }, ); } @@ -331,6 +363,57 @@ impl Drop for DirectTaonierActiveInvocationGuard { } } +pub(crate) fn list_direct_active_turns() -> Result, String> { + let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; + let mut turns = active + .iter() + .map(|(root, invocation)| DirectActiveTurnSnapshot { + project_path: root.to_string_lossy().into_owned(), + project_name: invocation.project_name.clone(), + turn_id: invocation.invocation_id.clone(), + started_at: invocation.started_at, + status: invocation.status.clone(), + activity: invocation.activity.clone(), + updated_at: invocation.updated_at, + sequence: invocation.sequence, + }) + .collect::>(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +pub(crate) fn update_direct_active_turn( + root: &Path, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) { + let Ok(root) = root.canonicalize() else { + return; + }; + let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { + return; + }; + let Ok(mut active) = active.lock() else { + return; + }; + let Some(invocation) = active.get_mut(&root) else { + return; + }; + if invocation.invocation_id != turn_id || sequence < invocation.sequence { + return; + } + invocation.status = status.to_string(); + invocation.activity = activity.map(str::to_string); + invocation.updated_at = updated_at; + invocation.sequence = sequence; +} + pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { let root = root .canonicalize() @@ -4766,6 +4849,36 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { + let root = tempfile::tempdir().expect("active snapshot root"); + let turn_id = "client-turn-snapshot-0001"; + let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) + .expect("active snapshot turn"); + update_direct_active_turn( + root.path(), + turn_id, + "streaming", + Some("response-finalization"), + 3, + 42, + ); + let snapshot = list_direct_active_turns() + .expect("list active turns") + .into_iter() + .find(|turn| turn.turn_id == turn_id) + .expect("snapshot entry"); + assert_eq!(snapshot.status, "streaming"); + assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); + assert_eq!(snapshot.sequence, 3); + assert_eq!(snapshot.updated_at, 42); + drop(guard); + assert!(list_direct_active_turns() + .expect("list after completion") + .into_iter() + .all(|turn| turn.turn_id != turn_id)); + } + #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 2a1b77488..76b85ef7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -74,15 +74,23 @@ impl DirectGameCreatorTurnUpdateEmitter { if !status_is_allowed || !activity_is_allowed { return; } - let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { - return; - }; let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; let updated_at = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; + update_direct_active_turn( + Path::new(&self.project_path), + &self.turn_id, + status, + activity, + sequence, + updated_at, + ); + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; let _ = app.emit( "game-creator-direct-turn-update", GameCreatorDirectTurnUpdateEvent { 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 a17b0e3f3..6b00b2ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5279,6 +5279,12 @@ pub(crate) async fn read_agent_runtime_error_detail( .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns( +) -> Result, String> { + list_direct_active_turns() +} + #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( 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 e68dee027..660ff5699 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2770,6 +2770,7 @@ fn main() { read_local_conversation, read_direct_project_conversation, read_agent_runtime_error_detail, + list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, read_direct_project_history_slice, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9d847517d..7d0e734d6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -57,6 +57,7 @@ import type { DesignView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectActiveTurn, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -1955,6 +1956,29 @@ export function App({ }; const bootstrap = async () => { + try { + const activeTurns = await directInvoke( + 'list_game_creator_direct_active_turns', + ); + const activeTurn = activeTurns.find((turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath), + ); + if (activeTurn && !activeDirectCodexTurnRef.current) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeTurn.turnId, + lastSequence: 0, + receivedDirectUpdate: true, + }; + setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); + setChatAgentBusy(true); + setDirectCodexStatus('running'); + setDirectCodexProgress('正在处理'); + setDirectCodexProgressUpdatedAt(Date.now()); + } + } catch { + // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 + } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 64a79bfab..4093a0b1f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1258,3 +1258,14 @@ export type TauriInvoke = ( command: string, args?: Record, ) => Promise; + +export type GameCreatorDirectActiveTurn = { + projectPath: string; + projectName?: string | null; + turnId: string; + startedAt: number; + status: string; + activity?: string | null; + updatedAt: number; + sequence: number; +}; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts new file mode 100644 index 000000000..0eadbeacd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; + +/** + * 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。 + * "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。 + */ +export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000; + +/** + * 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把 + * "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、 + * 权限问题或审批结论。 + */ +export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3; +const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300; + +/** + * 当前进程里仍在跑的 Direct 回合。 + * + * 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的 + * 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照—— + * 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。 + */ +export function useDirectActiveTurns({ + invoke, + enabled, + pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, +}: { + invoke: TauriInvoke | null | undefined; + enabled: boolean; + pollIntervalMs?: number; +}) { + const [activeTurns, setActiveTurns] = useState( + [], + ); + const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); + const mountedRef = useRef(true); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refreshActiveTurns = useCallback(async () => { + if (!invoke) { + return; + } + // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 + if (inFlightRef.current) { + return inFlightRef.current; + } + const request = (async () => { + for ( + let attempt = 1; + attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS; + attempt++ + ) { + try { + const turns = await invoke( + 'list_game_creator_direct_active_turns', + ); + if (!mountedRef.current) { + return; + } + setActiveTurns(Array.isArray(turns) ? turns : []); + setSnapshotReadFailed(false); + inFlightRef.current = null; + return; + } catch { + if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { + await new Promise((resolve) => + window.setTimeout( + resolve, + DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, + ), + ); + } + } + } + // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 + if (mountedRef.current) { + setSnapshotReadFailed(true); + } + inFlightRef.current = null; + })(); + inFlightRef.current = request; + return request; + }, [invoke]); + + useEffect(() => { + if (!enabled || !invoke) { + setActiveTurns([]); + setSnapshotReadFailed(false); + return; + } + void refreshActiveTurns(); + const timer = window.setInterval( + () => void refreshActiveTurns(), + Math.max(1_000, pollIntervalMs), + ); + return () => window.clearInterval(timer); + }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); + + return { activeTurns, refreshActiveTurns, snapshotReadFailed }; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx new file mode 100644 index 000000000..4147abb9b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -0,0 +1,119 @@ +import type { GameCreatorDirectActiveTurn } from '../../app/types'; +import { projectNameFromPath } from '../agent-runtime'; +import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; + +/** + * 左上角的"正在运行的项目"面板。 + * + * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连), + * 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时 + * 整块不渲染,不留空白占位。 + */ +export type ActiveProjectRunsPanelProps = { + activeTurns: GameCreatorDirectActiveTurn[]; + currentProjectPath?: string | null; + readFailed?: boolean; + onOpenProject?: (projectPath: string) => void; +}; + +const ACTIVE_TURN_STATUS_LABELS: Record = { + accepted: '已受理', + running: '创作中', + streaming: '生成中', + finalizing: '收尾中', + completed: '已完成', + failed: '已失败', +}; + +function activeTurnStatusLabel(status: string) { + return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中'; +} + +function formatActiveTurnElapsed(startedAt: number, now: number) { + const elapsedMs = now - startedAt; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + return ''; + } + const totalMinutes = Math.floor(elapsedMs / 60_000); + if (totalMinutes < 1) { + return '不到 1 分钟'; + } + if (totalMinutes < 60) { + return `${totalMinutes} 分钟`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`; +} + +function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) { + const snapshotName = turn.projectName?.trim(); + return snapshotName || projectNameFromPath(turn.projectPath); +} + +export function ActiveProjectRunsPanel({ + activeTurns, + currentProjectPath = null, + readFailed = false, + onOpenProject, +}: ActiveProjectRunsPanelProps) { + if (activeTurns.length === 0) { + if (!readFailed) { + return null; + } + // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 + return ( + + ); + } + + const now = Date.now(); + const orderedTurns = [...activeTurns].sort( + (left, right) => left.startedAt - right.startedAt, + ); + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 58a188e6f..c0f0edb40 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -25,8 +25,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; +import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel'; import { DeveloperAgentDialogs, DeveloperAgentPanel, @@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({ homeCreationBusy, homeCreationRecoverableProjectPath, } = homeProject; + const directInvoke = resolveTauriInvoke(); + const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({ + invoke: directInvoke, + enabled: true, + }); const switchedToGameRuntime = gameRuntimeSwitch !== null && currentProjectContext !== null && @@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({ ) : null} + { + setProjectPath(nextProjectPath); + void openProject(nextProjectPath, 'open'); + }} + /> + {launcherView === 'home' ? ( 0} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8f92d51fe..6dfa52a06 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -903,6 +903,80 @@ textarea { overflow: hidden; } +.launcher-runs-panel { + position: absolute; + top: 16px; + left: 24px; + z-index: 3; + width: min(280px, calc(100% - 260px)); + padding: 10px 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + box-shadow: 0 8px 24px rgb(31 24 16 / 8%); +} + +.launcher-runs-panel-header { + display: flex; + align-items: center; + gap: 7px; + color: var(--platform-text-strong); + font-size: 12px; +} + +.launcher-runs-panel-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4d9f54; + box-shadow: 0 0 0 3px rgb(77 159 84 / 14%); +} + +.launcher-runs-panel-list { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.launcher-runs-panel-item { + display: grid; + width: 100%; + gap: 2px; + padding: 6px 7px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.launcher-runs-panel-item:hover, +.launcher-runs-panel-item[aria-current='true'] { + background: rgb(255 255 255 / 72%); +} + +.launcher-runs-panel-item:disabled { + cursor: default; +} + +.launcher-runs-panel-name { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-runs-panel-meta, +.launcher-runs-panel-note { + color: var(--platform-text-muted); + font-size: 11px; +} + .launcher-promo { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx new file mode 100644 index 000000000..c075aaef3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; + +afterEach(() => cleanup()); + +it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); +}); + +it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); +}); diff --git a/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..df94a1580 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,51 @@ +# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md` + +## 固定契约 + +只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`): + +- `list_game_creator_direct_active_turns() -> Vec` +- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence` +- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed` + +身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。 + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试 +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表 +- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册 +- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载 +- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用) +- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试 +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正 + +## 修改顺序 + +1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。 +2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。 +3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。 +4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。 +5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整) +- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` +- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` +- 面板组件测试文件单独一条 vitest +- `npm --prefix apps/ai-game-creator-shell run typecheck` +- `npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。 +- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。 +- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。 +- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。 +- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。 diff --git a/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..b8ead65f3 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,33 @@ +# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」 + +## 目标 + +离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。 + +## 边界 + +- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。 +- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。 +- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。 +- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。 +- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。 + +## 验收标准 + +- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。 +- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。 +- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。 +- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。 +- 快照读取失败不得阻断发送、不得显示成业务失败。 +- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:` 仍显示审批提示。 + +## 未决事项 + +- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。 +- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。 +- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd8416d40..3fd4eeb46 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8759,4 +8759,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 +## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 +- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 +- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a232c7612..d9b7463ef 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1397,3 +1397,11 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执 游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 + +## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性 + +Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。 + +壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。 + +活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。