按产品口径移除项目快照的客户端可见界面
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 8m6s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m25s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m15s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m54s
Project CI / Frontend tests (pull_request) Successful in 5m11s
Project CI / Native shell tests (pull_request) Successful in 9m9s

- 删除项目快照面板组件与其前端测试,回退工作区头部入口、App 接线、前端类型与面板样式
- 两条快照命令恢复为 native-only 登记,并在注释中说明按产品口径不做客户端界面
- 主规范与里程碑同步:可观测性收敛到 AppData 诊断日志,未决问题补记用户侧不可见
This commit is contained in:
kdletters
2026-09-17 17:03:50 +08:00
parent e8a723c04e
commit 4afdb2f359
9 changed files with 9 additions and 444 deletions
@@ -121,6 +121,10 @@ const allowedUncalledTauriCommands = [
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
'read_local_project_snapshot_state',
'sync_local_project_snapshot',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
-10
View File
@@ -275,7 +275,6 @@ import {
resolveChatProjectPath,
resolvePendingCommandProjectPath,
} from './features/project-workspace/projectCommandPolicy';
import { ProjectSnapshotDialog } from './features/project-workspace/ProjectSnapshotDialog';
import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands';
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
@@ -1920,7 +1919,6 @@ export function App({
const [professionalAgentResultsById, setProfessionalAgentResultsById] =
useState<Record<string, ProjectAgentResultSummary>>({});
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
const [projectSnapshotOpen, setProjectSnapshotOpen] = useState(false);
const [llmConfigStatus, setLlmConfigStatus] =
useState<GameCreatorLlmConfigStatus | null>(null);
const [workspaceStatus, setWorkspaceStatus] = useState(
@@ -13261,7 +13259,6 @@ export function App({
handleRevealCurrentProjectDirectory
}
handleRuntimeConfigOpen={handleRuntimeConfigOpen}
handleProjectSnapshotOpen={() => setProjectSnapshotOpen(true)}
hiddenConversationCount={hiddenConversationCount}
hasEarlierConversationMessages={hasEarlierConversationMessages}
llmConfigStatus={llmConfigStatus}
@@ -13310,13 +13307,6 @@ export function App({
/>
) : null}
{projectSnapshotOpen ? (
<ProjectSnapshotDialog
projectPath={localProject?.projectPath ?? projectPath}
onClose={() => setProjectSnapshotOpen(false)}
/>
) : null}
{selectedAgent ? (
<AgentConversationOverlay
agentConversationBackgroundBusy={agentConversationBackgroundBusy}
@@ -941,40 +941,6 @@ export interface LocalProjectIndexResult {
files: Array<{ path: string; size: number; checksum: string }>;
}
/** 项目快照同步中被跳过、延后或失败的单个路径。 */
export interface LocalProjectSnapshotFailure {
relativePath: string;
code: string;
detail: string;
}
export interface LocalProjectSnapshotState {
projectId: string;
indexPath: string;
indexPresent: boolean;
fileCount: number;
syncRevision: number;
syncedAtMs: number;
enabled: boolean;
}
export interface LocalProjectSnapshotSyncResult {
projectId: string;
trigger: string;
status: string;
syncRevision: number;
uploadedFiles: number;
uploadedBytes: number;
remoteSkippedFiles: number;
deletedFiles: number;
deferredFiles: number;
metadataOnlyFiles: number;
skippedFiles: LocalProjectSnapshotFailure[];
pendingFiles: LocalProjectSnapshotFailure[];
failedFiles: LocalProjectSnapshotFailure[];
syncedAtMs: number;
}
export interface LocalProjectCheckpointResult {
checkpointId: string;
checkpointPath: string;
@@ -1,222 +0,0 @@
import { CloudUpload, Loader2, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
closeDialogOnBackdropMouseDown,
closeDialogOnEscape,
useEscapeToClose,
} from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
LocalProjectSnapshotFailure,
LocalProjectSnapshotState,
LocalProjectSnapshotSyncResult,
} from '../../app/types';
type ProjectSnapshotDialogProps = {
projectPath: string | null;
onClose: () => void;
};
const SNAPSHOT_STATUS_LABELS: Record<string, string> = {
synced: '已同步',
'no-op': '无改动',
partial: '部分成功',
failed: '同步失败',
};
function syncStatusLabel(status: string) {
return SNAPSHOT_STATUS_LABELS[status] ?? status;
}
function formatSyncTime(value: number) {
if (!value) return '尚无记录';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function describePending(
label: string,
entries: LocalProjectSnapshotFailure[],
): string | null {
const first = entries.at(0);
if (!first) return null;
return entries.length === 1
? `${label}:${first.relativePath}(${first.detail})`
: `${label}:${entries.length} 个,例如 ${first.relativePath}(${first.detail})`;
}
/**
* 项目快照面板:显示本机索引状态与最近一次同步结果,并提供手动触发入口。
*/
export function ProjectSnapshotDialog({
projectPath,
onClose,
}: ProjectSnapshotDialogProps) {
const [state, setState] = useState<LocalProjectSnapshotState | null>(null);
const [result, setResult] = useState<LocalProjectSnapshotSyncResult | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const refreshState = useCallback(async () => {
const invoke = resolveTauriInvoke();
const trimmed = projectPath?.trim();
if (!invoke || !trimmed) {
setState(null);
return;
}
setLoading(true);
try {
const next = await invoke<LocalProjectSnapshotState>(
'read_local_project_snapshot_state',
{ projectPath: trimmed },
);
setState(next);
setError(null);
} catch (readError) {
setState(null);
setError(
readError instanceof Error ? readError.message : String(readError),
);
} finally {
setLoading(false);
}
}, [projectPath]);
useEffect(() => {
void refreshState();
}, [refreshState]);
useEscapeToClose(onClose, !busy);
async function runSync() {
const invoke = resolveTauriInvoke();
const trimmed = projectPath?.trim();
if (!invoke || !trimmed) {
setError('需要在 Tauri App 内打开项目');
return;
}
setBusy(true);
setError(null);
try {
setResult(
await invoke<LocalProjectSnapshotSyncResult>(
'sync_local_project_snapshot',
{ projectPath: trimmed },
),
);
await refreshState();
} catch (syncError) {
setResult(null);
setError(
syncError instanceof Error ? syncError.message : String(syncError),
);
} finally {
setBusy(false);
}
}
const details = result
? [
describePending('失败', result.failedFiles),
describePending('本次跳过', result.pendingFiles),
describePending('未纳入同步', result.skippedFiles),
].filter((value): value is string => Boolean(value))
: [];
return createPortal(
<div
className="launcher-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (busy) return;
closeDialogOnBackdropMouseDown(event, onClose);
}}
>
<section
aria-labelledby="project-snapshot-dialog-title"
aria-modal="true"
className="launcher-dialog project-snapshot-dialog"
role="dialog"
onKeyDown={(event) => {
if (busy) return;
closeDialogOnEscape(event, onClose);
}}
>
<h2 id="project-snapshot-dialog-title">项目快照</h2>
{error ? (
<p className="project-snapshot-dialog-error" role="status">
{error}
</p>
) : null}
<dl className="project-snapshot-dialog-facts">
<dt>上传状态</dt>
<dd>{state?.enabled === false ? '已停用' : '已启用'}</dd>
<dt>上次同步</dt>
<dd>{formatSyncTime(state?.syncedAtMs ?? 0)}</dd>
<dt>同步序号</dt>
<dd>{state?.syncRevision ?? 0}</dd>
<dt>已纳管文件</dt>
<dd>{state?.fileCount ?? 0}</dd>
<dt>本地索引</dt>
<dd>{state?.indexPresent ? '已写入' : '尚未写入'}</dd>
{result ? (
<>
<dt>最近一次</dt>
<dd>{syncStatusLabel(result.status)}</dd>
<dt>上传/字节</dt>
<dd>
{result.uploadedFiles} 个 / {result.uploadedBytes} 字节
</dd>
<dt>远端已有</dt>
<dd>{result.remoteSkippedFiles} 个</dd>
<dt>删除/延后</dt>
<dd>
{result.deletedFiles} / {result.deferredFiles}
</dd>
</>
) : null}
</dl>
{details.map((line) => (
<p key={line} className="project-snapshot-dialog-detail">
{line}
</p>
))}
<div className="launcher-dialog-actions">
<button type="button" disabled={busy} onClick={onClose}>
关闭
</button>
<button
type="button"
disabled={busy || loading}
onClick={() => void refreshState()}
>
{loading ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={14} aria-hidden="true" />
)}
刷新
</button>
<button
type="button"
aria-busy={busy}
disabled={busy || !projectPath}
onClick={() => void runSync()}
>
{busy ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<CloudUpload size={14} aria-hidden="true" />
)}
立即同步
</button>
</div>
</section>
</div>,
document.body,
);
}
@@ -156,7 +156,6 @@ type ProjectWorkspaceChatPaneProps = {
) => Promise<void>;
handleRevealCurrentProjectDirectory: () => Promise<void>;
handleRuntimeConfigOpen: () => void;
handleProjectSnapshotOpen: () => void;
hiddenConversationCount: number;
hasEarlierConversationMessages?: boolean;
llmConfigStatus: GameCreatorLlmConfigStatus | null;
@@ -257,7 +256,6 @@ export function ProjectWorkspaceChatPane({
handleProjectSupervisorUserInput,
handleRevealCurrentProjectDirectory,
handleRuntimeConfigOpen,
handleProjectSnapshotOpen,
hiddenConversationCount,
hasEarlierConversationMessages = false,
llmConfigStatus,
@@ -324,13 +322,6 @@ export function ProjectWorkspaceChatPane({
<button type="button" onClick={handleRuntimeConfigOpen}>
配置
</button>
<button
type="button"
disabled={!localProject}
onClick={handleProjectSnapshotOpen}
>
项目快照
</button>
<button type="button" onClick={() => void executeLlmConfigStatus()}>
LLM状态
</button>
-46
View File
@@ -4979,52 +4979,6 @@ h2 {
gap: 6px;
}
.project-snapshot-dialog {
display: grid;
gap: 12px;
}
.project-snapshot-dialog-error {
color: #b45309;
}
.project-snapshot-dialog-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 0;
font-size: 13px;
}
.project-snapshot-dialog-facts dt {
color: #6b7280;
}
.project-snapshot-dialog-facts dd {
margin: 0;
overflow-wrap: anywhere;
}
.project-snapshot-dialog-detail {
margin: 0;
color: #6b7280;
font-size: 12px;
overflow-wrap: anywhere;
}
.project-snapshot-dialog .launcher-dialog-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.project-snapshot-dialog .launcher-dialog-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.resource-reference-picker-scopes {
padding: 0 12px 4px;
}
@@ -1,117 +0,0 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProjectSnapshotDialog } from '../src/features/project-workspace/ProjectSnapshotDialog';
const PROJECT_PATH = 'C:\\games\\demo';
const state = {
projectId: 'gameagent-033b6cf397094f3e8d4e48c380ff4629',
indexPath: 'C:\\AppData\\project-snapshots\\gameagent\\index.json',
indexPresent: true,
fileCount: 6,
syncRevision: 2,
syncedAtMs: 1_789_629_339_000,
enabled: true,
};
const syncResult = {
projectId: state.projectId,
trigger: 'manual',
status: 'synced',
syncRevision: 3,
uploadedFiles: 1,
uploadedBytes: 2048,
remoteSkippedFiles: 0,
deletedFiles: 0,
deferredFiles: 0,
metadataOnlyFiles: 0,
skippedFiles: [],
pendingFiles: [],
failedFiles: [],
syncedAtMs: 1_789_629_400_000,
};
function installInvoke(
implementation: (command: string, args?: Record<string, unknown>) => unknown,
) {
const invoke = vi.fn(implementation);
(window as unknown as { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke },
};
return invoke;
}
describe('ProjectSnapshotDialog', () => {
afterEach(() => {
cleanup();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
});
it('打开时读取本机索引状态并显示同步序号与文件数', async () => {
const invoke = installInvoke((command) => {
if (command === 'read_local_project_snapshot_state') return state;
throw new Error(`unexpected command: ${command}`);
});
render(
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
);
expect(await screen.findByText('2')).toBeTruthy();
expect(screen.getByText('6')).toBeTruthy();
expect(screen.getByText('已写入')).toBeTruthy();
expect(invoke).toHaveBeenCalledWith('read_local_project_snapshot_state', {
projectPath: PROJECT_PATH,
});
});
it('手动同步后显示上传计数与状态,并刷新索引状态', async () => {
let stateReads = 0;
const invoke = installInvoke((command) => {
if (command === 'read_local_project_snapshot_state') {
stateReads += 1;
return state;
}
if (command === 'sync_local_project_snapshot') return syncResult;
throw new Error(`unexpected command: ${command}`);
});
render(
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
);
await waitFor(() => expect(stateReads).toBe(1));
await userEvent.click(screen.getByRole('button', { name: /立即同步/u }));
expect(await screen.findByText('已同步')).toBeTruthy();
expect(screen.getByText('1 个 / 2048 字节')).toBeTruthy();
await waitFor(() => expect(stateReads).toBe(2));
expect(invoke).toHaveBeenCalledWith('sync_local_project_snapshot', {
projectPath: PROJECT_PATH,
});
});
it('同步失败时显示错误而不是伪造成功状态', async () => {
const invoke = installInvoke((command) => {
if (command === 'read_local_project_snapshot_state') return state;
if (command === 'sync_local_project_snapshot') {
throw new Error('authentication-required: 请先登录陶泥儿账号');
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
);
await waitFor(() => expect(invoke).toHaveBeenCalled());
await userEvent.click(screen.getByRole('button', { name: /立即同步/u }));
expect(await screen.findByText(/authentication-required/u)).toBeTruthy();
expect(screen.queryByText('已同步')).toBeNull();
});
});