支持App Run并行开发并修复资源画布
新增App Run独立开发profile并隔离端口、数据库、Tauri身份与AppData 常驻资源排列切换并避免布局状态挤压操作入口 显式扩展资源画布横向滚动范围并收敛依赖图可见连线 补充启动器、资源布局、界面回归测试与项目文档
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
"dev:app-run": "node scripts/start-tauri-dev.mjs --app-run",
|
||||
"game-chat": "node scripts/start-tauri-dev.mjs --game-chat",
|
||||
"dev-server": "node scripts/start-dev-server.mjs",
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
|
||||
@@ -27,6 +27,12 @@ const tauriConfig = JSON.parse(
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const appRunTauriConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL('../src-tauri/tauri.app-run-dev.conf.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const gameChatReleaseTauriConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url),
|
||||
@@ -1263,6 +1269,33 @@ if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
packageConfig.scripts?.['dev:app-run'] !==
|
||||
'node scripts/start-tauri-dev.mjs --app-run' ||
|
||||
rootPackageConfig.scripts?.['agc:app-run'] !==
|
||||
'npm --prefix apps/ai-game-creator-shell run dev:app-run'
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator app-run dev profile must use the managed Tauri dev launcher',
|
||||
);
|
||||
}
|
||||
|
||||
const appRunWindow = appRunTauriConfig.app?.windows?.[0];
|
||||
if (
|
||||
appRunTauriConfig.productName !==
|
||||
'Genarrative AI Game Creator App Run' ||
|
||||
appRunTauriConfig.identifier !==
|
||||
'world.genarrative.ai-game-creator.app-run' ||
|
||||
appRunTauriConfig.identifier === tauriConfig.identifier ||
|
||||
appRunTauriConfig.build?.devUrl !== 'http://127.0.0.1:3081/' ||
|
||||
appRunWindow?.label !== 'client' ||
|
||||
appRunWindow?.title !== 'AI 游戏创作 · App Run'
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator app-run profile must keep an independent identity, title, and Vite port',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
packageConfig.scripts?.['game-chat'] !==
|
||||
'node scripts/start-tauri-dev.mjs --game-chat'
|
||||
|
||||
@@ -8,17 +8,56 @@ import { fileURLToPath } from 'node:url';
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||||
const viteHost = '127.0.0.1';
|
||||
const vitePort = 3080;
|
||||
const devProfileName = process.env.GENARRATIVE_AGC_DEV_PROFILE || 'default';
|
||||
|
||||
function resolveDevStackProfile(name = 'default') {
|
||||
switch (name) {
|
||||
case 'default':
|
||||
return {
|
||||
name,
|
||||
viteHost: '127.0.0.1',
|
||||
vitePort: 3080,
|
||||
apiPort: 8082,
|
||||
bgfilterWorkerPort: 8083,
|
||||
spacetimePort: 3101,
|
||||
backendDatabase: 'genarrative-game-creator-dev',
|
||||
backendSpacetimeDataDir: resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
),
|
||||
};
|
||||
case 'app-run':
|
||||
return {
|
||||
name,
|
||||
viteHost: '127.0.0.1',
|
||||
vitePort: 3081,
|
||||
apiPort: 8084,
|
||||
bgfilterWorkerPort: 8085,
|
||||
spacetimePort: 3103,
|
||||
backendDatabase: 'genarrative-game-creator-app-run-dev',
|
||||
backendSpacetimeDataDir: resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator-app-run/data',
|
||||
),
|
||||
};
|
||||
default:
|
||||
throw new Error(`未知 AI 游戏创作开发 profile: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const devProfile = resolveDevStackProfile(devProfileName);
|
||||
const {
|
||||
viteHost,
|
||||
vitePort,
|
||||
apiPort,
|
||||
bgfilterWorkerPort,
|
||||
spacetimePort,
|
||||
backendDatabase,
|
||||
backendSpacetimeDataDir,
|
||||
} = devProfile;
|
||||
const viteUrl = `http://${viteHost}:${vitePort}/`;
|
||||
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
|
||||
const defaultApiTarget =
|
||||
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||||
const backendDatabase = 'genarrative-game-creator-dev';
|
||||
const backendSpacetimeDataDir = resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
);
|
||||
process.env.RUST_SERVER_TARGET || `http://127.0.0.1:${apiPort}`;
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const childLifecycles = new WeakMap();
|
||||
|
||||
@@ -72,6 +111,7 @@ function resolveBackendTargetsFromState(
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
fallbackApiTarget = defaultApiTarget,
|
||||
fallbackSpacetimeTarget = `http://127.0.0.1:${spacetimePort}`,
|
||||
} = {},
|
||||
) {
|
||||
const apiServer = state?.services?.['api-server'];
|
||||
@@ -100,7 +140,7 @@ function resolveBackendTargetsFromState(
|
||||
? spacetime.url
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: 'http://127.0.0.1:3101';
|
||||
: fallbackSpacetimeTarget;
|
||||
return {
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
@@ -131,13 +171,24 @@ async function isBackendReady() {
|
||||
);
|
||||
}
|
||||
|
||||
async function readExistingViteServer() {
|
||||
return httpGetText(viteUrl);
|
||||
function resolveViteProfileUrls(profile = devProfile) {
|
||||
const profileViteUrl = `http://${profile.viteHost}:${profile.vitePort}/`;
|
||||
return {
|
||||
viteUrl: profileViteUrl,
|
||||
viteMarkerUrl: `${profileViteUrl}__agc_dev_server.json`,
|
||||
};
|
||||
}
|
||||
|
||||
function isVitePortListening() {
|
||||
async function readExistingViteServer(profile = devProfile) {
|
||||
return httpGetText(resolveViteProfileUrls(profile).viteUrl);
|
||||
}
|
||||
|
||||
function isVitePortListening(profile = devProfile) {
|
||||
return new Promise((resolveRequest) => {
|
||||
const socket = net.connect({ host: viteHost, port: vitePort });
|
||||
const socket = net.connect({
|
||||
host: profile.viteHost,
|
||||
port: profile.vitePort,
|
||||
});
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolveRequest(true);
|
||||
@@ -160,8 +211,11 @@ function isAiGameCreatorServer(response) {
|
||||
);
|
||||
}
|
||||
|
||||
async function readExistingViteMarker() {
|
||||
const response = await httpGetText(viteMarkerUrl, 2000);
|
||||
async function readExistingViteMarker(profile = devProfile) {
|
||||
const response = await httpGetText(
|
||||
resolveViteProfileUrls(profile).viteMarkerUrl,
|
||||
2000,
|
||||
);
|
||||
if (!response || response.statusCode !== 200) {
|
||||
return null;
|
||||
}
|
||||
@@ -173,15 +227,17 @@ async function readExistingViteMarker() {
|
||||
}
|
||||
|
||||
async function preflightExistingVite({
|
||||
readServer = readExistingViteServer,
|
||||
portListening = isVitePortListening,
|
||||
readMarker = readExistingViteMarker,
|
||||
profile = devProfile,
|
||||
readServer = () => readExistingViteServer(profile),
|
||||
portListening = () => isVitePortListening(profile),
|
||||
readMarker = () => readExistingViteMarker(profile),
|
||||
} = {}) {
|
||||
const { viteUrl: profileViteUrl } = resolveViteProfileUrls(profile);
|
||||
const existing = await readServer();
|
||||
if (!existing) {
|
||||
if (await portListening()) {
|
||||
throw new Error(
|
||||
`${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
|
||||
`${profileViteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
|
||||
);
|
||||
}
|
||||
return { status: 'available', apiTarget: '' };
|
||||
@@ -189,7 +245,7 @@ async function preflightExistingVite({
|
||||
|
||||
if (!isAiGameCreatorServer(existing)) {
|
||||
throw new Error(
|
||||
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
|
||||
`${profileViteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +258,7 @@ async function preflightExistingVite({
|
||||
: '';
|
||||
const actualTarget = markerApiTarget || 'unknown';
|
||||
throw new Error(
|
||||
`${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
|
||||
`${profileViteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,27 +563,50 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
throw new Error('等待配套后端和数据库启动超时');
|
||||
}
|
||||
|
||||
function buildBackendStartArguments(profile = devProfile) {
|
||||
return [
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
profile.backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
profile.backendSpacetimeDataDir,
|
||||
'--api-port',
|
||||
String(profile.apiPort),
|
||||
'--bgfilter-worker-port',
|
||||
String(profile.bgfilterWorkerPort),
|
||||
'--spacetime-port',
|
||||
String(profile.spacetimePort),
|
||||
'--no-interactive',
|
||||
];
|
||||
}
|
||||
|
||||
function buildViteStartArguments(profile = devProfile) {
|
||||
return [
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'--config',
|
||||
'vite.config.ts',
|
||||
'--host',
|
||||
profile.viteHost,
|
||||
'--port',
|
||||
String(profile.vitePort),
|
||||
'--strictPort',
|
||||
];
|
||||
}
|
||||
|
||||
async function ensureBackend({
|
||||
onBackendChild = () => {},
|
||||
checkBackendReady = isBackendReady,
|
||||
resolveTargets = readBackendTargets,
|
||||
spawnBackend = () =>
|
||||
spawnChild(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
),
|
||||
spawnChild(npm, buildBackendStartArguments(), { cwd: appRoot }),
|
||||
waitUntilReady = waitForBackendReady,
|
||||
} = {}) {
|
||||
if (await checkBackendReady()) {
|
||||
@@ -569,11 +648,7 @@ async function startVite(apiTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
return spawnChild(
|
||||
npm,
|
||||
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
|
||||
{ cwd: appRoot },
|
||||
);
|
||||
return spawnChild(npm, buildViteStartArguments(), { cwd: appRoot });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -593,6 +668,9 @@ async function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] dev profile ${devProfile.name}: vite=${viteUrl} api=${apiPort} spacetime=${spacetimePort}`,
|
||||
);
|
||||
await preflightExistingVite();
|
||||
const backend = await ensureBackend({
|
||||
onBackendChild(child) {
|
||||
@@ -650,6 +728,8 @@ function isDirectModuleExecution() {
|
||||
}
|
||||
|
||||
export {
|
||||
buildBackendStartArguments,
|
||||
buildViteStartArguments,
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isDirectModuleExecution,
|
||||
@@ -657,6 +737,7 @@ export {
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveDevStackProfile,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
preflightExistingVite,
|
||||
resolveDevStackProfile,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
@@ -15,24 +16,43 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
|
||||
function parseLauncherArguments(argv) {
|
||||
const args = [...argv];
|
||||
const appRun = args[0] === '--app-run';
|
||||
if (appRun) {
|
||||
args.shift();
|
||||
}
|
||||
const gameChat = args[0] === '--game-chat';
|
||||
if (gameChat) {
|
||||
args.shift();
|
||||
}
|
||||
return { gameChat, args };
|
||||
if (appRun && gameChat) {
|
||||
throw new Error('app-run profile 不能与 game-chat 入口同时使用');
|
||||
}
|
||||
return { appRun, gameChat, args };
|
||||
}
|
||||
|
||||
function buildTauriArguments(argv) {
|
||||
const { gameChat, args } = parseLauncherArguments(argv);
|
||||
const { appRun, gameChat, args } = parseLauncherArguments(argv);
|
||||
if (appRun) {
|
||||
return [
|
||||
'dev',
|
||||
'--config',
|
||||
'src-tauri/tauri.app-run-dev.conf.json',
|
||||
...args,
|
||||
];
|
||||
}
|
||||
if (gameChat) {
|
||||
return ['dev', '--', '--', '--game-chat', ...args];
|
||||
}
|
||||
return ['dev', ...args];
|
||||
}
|
||||
|
||||
function spawnTauriCli(argv) {
|
||||
function spawnTauriCli(argv, { profileName = 'default' } = {}) {
|
||||
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
GENARRATIVE_AGC_DEV_PROFILE: profileName,
|
||||
},
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
@@ -46,10 +66,12 @@ async function runTauriDev(
|
||||
terminateTree = terminateChildTree,
|
||||
} = {},
|
||||
) {
|
||||
await preflight();
|
||||
const { appRun } = parseLauncherArguments(argv);
|
||||
const profile = resolveDevStackProfile(appRun ? 'app-run' : 'default');
|
||||
await preflight({ profile });
|
||||
|
||||
const tauriArguments = buildTauriArguments(argv);
|
||||
const child = spawnCli(tauriArguments);
|
||||
const child = spawnCli(tauriArguments, { profileName: profile.name });
|
||||
let resolveShutdown;
|
||||
let shutdownSignal = '';
|
||||
let repeatedSignal = false;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Genarrative AI Game Creator App Run",
|
||||
"identifier": "world.genarrative.ai-game-creator.app-run",
|
||||
"build": {
|
||||
"devUrl": "http://127.0.0.1:3081/"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "client",
|
||||
"title": "AI 游戏创作 · App Run",
|
||||
"url": "index.html",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3740,9 +3740,9 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-workbench-toolbar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 8px 12px;
|
||||
@@ -3751,19 +3751,33 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-workbench-tabs,
|
||||
.game-workbench-view-actions {
|
||||
.game-workbench-view-actions,
|
||||
.game-resource-layout-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.game-workbench-tabs {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #f3ded3;
|
||||
}
|
||||
|
||||
.game-workbench-view-actions {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-resource-layout-switch {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.game-workbench-tabs button,
|
||||
.game-workbench-view-actions button {
|
||||
display: inline-flex;
|
||||
@@ -3801,11 +3815,19 @@ iframe.preview-frame {
|
||||
|
||||
.game-resource-reorder-status {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
max-width: min(240px, 28vw);
|
||||
overflow: hidden;
|
||||
color: #9a725f;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-reorder-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-run-unavailable {
|
||||
margin: 0;
|
||||
padding: 7px 14px;
|
||||
@@ -3913,21 +3935,6 @@ iframe.preview-frame {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task path {
|
||||
stroke: #918b87;
|
||||
stroke-width: 1.4px;
|
||||
stroke-dasharray: 4 7;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-trunk {
|
||||
stroke-width: 1.7px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-branch {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge.is-cyclic,
|
||||
.game-resource-dependency-edge.is-cyclic path {
|
||||
stroke-dashoffset: 4;
|
||||
@@ -3938,11 +3945,6 @@ iframe.preview-frame {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-dependency-marker--task path {
|
||||
fill: #918b87;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -5227,7 +5229,7 @@ iframe.preview-frame {
|
||||
|
||||
.game-workbench-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-view-actions {
|
||||
@@ -5252,10 +5254,9 @@ iframe.preview-frame {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.game-workbench-toolbar,
|
||||
.game-workbench-view-actions {
|
||||
.game-workbench-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-tabs {
|
||||
|
||||
-305
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
type ProjectResourceGraph,
|
||||
type ProjectResourceReferenceEdge,
|
||||
type ProjectResourceTaskFlow,
|
||||
} from './resourceDependencyGraphModel';
|
||||
|
||||
type Point = {
|
||||
@@ -50,22 +49,8 @@ export type ResourceDependencyOverlayHandle = {
|
||||
clearDragPreview: () => void;
|
||||
};
|
||||
|
||||
type TaskFlowPathRefs = {
|
||||
sourceBranches: Map<string, SVGPathElement>;
|
||||
targetBranches: Map<string, SVGPathElement>;
|
||||
trunk: SVGPathElement | null;
|
||||
};
|
||||
|
||||
type TaskFlowSectionGeometry = NonNullable<
|
||||
ReturnType<typeof taskFlowGeometry>
|
||||
> & {
|
||||
section: ProjectResourceCanvasSection;
|
||||
};
|
||||
|
||||
const SECTION_SELECTOR = '[data-resource-section-plane]';
|
||||
const TASK_FLOW_HUB_GAP = 20;
|
||||
const CONNECTION_MAX_HANDLE = 180;
|
||||
const TASK_FLOW_BRANCH_MAX_HANDLE = 96;
|
||||
const SELF_REFERENCE_LOOP_WIDTH = 56;
|
||||
const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18;
|
||||
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
|
||||
@@ -103,28 +88,6 @@ function connectionPath(source: Point, target: Point) {
|
||||
}, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`;
|
||||
}
|
||||
|
||||
function taskFlowBranchPath(source: Point, target: Point) {
|
||||
const horizontalDistance = Math.abs(target.x - source.x);
|
||||
if (horizontalDistance < 1) {
|
||||
const direction = target.y >= source.y ? 1 : -1;
|
||||
const handle = Math.min(
|
||||
TASK_FLOW_BRANCH_MAX_HANDLE,
|
||||
Math.abs(target.y - source.y) * 0.5,
|
||||
);
|
||||
return `M ${source.x} ${source.y} C ${source.x} ${
|
||||
source.y + direction * handle
|
||||
}, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`;
|
||||
}
|
||||
const direction = target.x >= source.x ? 1 : -1;
|
||||
const handle = Math.min(
|
||||
TASK_FLOW_BRANCH_MAX_HANDLE,
|
||||
horizontalDistance * 0.5,
|
||||
);
|
||||
return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${
|
||||
source.y
|
||||
}, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`;
|
||||
}
|
||||
|
||||
function rectCenter(rect: Rect): Point {
|
||||
return {
|
||||
x: rect.x + rect.width / 2,
|
||||
@@ -132,10 +95,6 @@ function rectCenter(rect: Rect): Point {
|
||||
};
|
||||
}
|
||||
|
||||
function average(values: readonly number[]) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function rectAnchor(rect: Rect, direction: 1 | -1): Point {
|
||||
return {
|
||||
x: direction === 1 ? rect.x + rect.width : rect.x,
|
||||
@@ -174,84 +133,6 @@ function referenceGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
function taskFlowGeometry(
|
||||
flow: ProjectResourceTaskFlow,
|
||||
rectByResourceId: RectLookup,
|
||||
) {
|
||||
const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
return rect ? [{ resourceId, rect }] : [];
|
||||
});
|
||||
const targetRects = flow.targetResourceIds.flatMap((resourceId) => {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
return rect ? [{ resourceId, rect }] : [];
|
||||
});
|
||||
if (sourceRects.length === 0 || targetRects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sourceCenterX = average(
|
||||
sourceRects.map(({ rect }) => rectCenter(rect).x),
|
||||
);
|
||||
const targetCenterX = average(
|
||||
targetRects.map(({ rect }) => rectCenter(rect).x),
|
||||
);
|
||||
const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1;
|
||||
const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({
|
||||
resourceId,
|
||||
point: rectAnchor(rect, direction),
|
||||
}));
|
||||
const targetAnchors = targetRects.map(({ resourceId, rect }) => ({
|
||||
resourceId,
|
||||
point: rectAnchor(rect, direction === 1 ? -1 : 1),
|
||||
}));
|
||||
const sourceHub: Point = {
|
||||
x:
|
||||
(direction === 1
|
||||
? Math.max(...sourceAnchors.map(({ point }) => point.x))
|
||||
: Math.min(...sourceAnchors.map(({ point }) => point.x))) +
|
||||
direction * TASK_FLOW_HUB_GAP,
|
||||
y: average(sourceAnchors.map(({ point }) => point.y)),
|
||||
};
|
||||
const targetHub: Point = {
|
||||
x:
|
||||
(direction === 1
|
||||
? Math.min(...targetAnchors.map(({ point }) => point.x))
|
||||
: Math.max(...targetAnchors.map(({ point }) => point.x))) -
|
||||
direction * TASK_FLOW_HUB_GAP,
|
||||
y: average(targetAnchors.map(({ point }) => point.y)),
|
||||
};
|
||||
return { sourceAnchors, targetAnchors, sourceHub, targetHub };
|
||||
}
|
||||
|
||||
function taskFlowSectionGeometries(
|
||||
flow: ProjectResourceTaskFlow,
|
||||
rectByResourceId: RectLookup,
|
||||
sectionByResourceId: ReadonlyMap<string, ProjectResourceCanvasSection>,
|
||||
): TaskFlowSectionGeometry[] {
|
||||
return RESOURCE_SECTIONS.flatMap((section) => {
|
||||
const geometry = taskFlowGeometry(
|
||||
{
|
||||
...flow,
|
||||
sourceResourceIds: flow.sourceResourceIds.filter(
|
||||
(resourceId) => sectionByResourceId.get(resourceId) === section,
|
||||
),
|
||||
targetResourceIds: flow.targetResourceIds.filter(
|
||||
(resourceId) => sectionByResourceId.get(resourceId) === section,
|
||||
),
|
||||
},
|
||||
rectByResourceId,
|
||||
);
|
||||
return geometry ? [{ ...geometry, section }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function taskFlowRenderKey(
|
||||
flowId: string,
|
||||
section: ProjectResourceCanvasSection,
|
||||
) {
|
||||
return `${flowId}\n${section}`;
|
||||
}
|
||||
|
||||
export const ResourceDependencyOverlay = forwardRef<
|
||||
ResourceDependencyOverlayHandle,
|
||||
ResourceDependencyOverlayProps
|
||||
@@ -262,7 +143,6 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
const overlayRef = useRef<SVGSVGElement>(null);
|
||||
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const taskFlowPathRefs = useRef(new Map<string, TaskFlowPathRefs>());
|
||||
const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>(
|
||||
null,
|
||||
);
|
||||
@@ -349,128 +229,12 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
}
|
||||
return result;
|
||||
}, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]);
|
||||
const sectionByResourceId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
positions.map((position) => [position.resourceId, position.section]),
|
||||
),
|
||||
[positions],
|
||||
);
|
||||
graphRef.current = graph;
|
||||
positionByResourceIdRef.current = new Map(
|
||||
positions.map((position) => [position.resourceId, position]),
|
||||
);
|
||||
rectByResourceIdRef.current = rectByResourceId;
|
||||
|
||||
const taskFlowRenderEntries = useMemo(
|
||||
() =>
|
||||
graph.taskFlows.flatMap((flow) =>
|
||||
taskFlowSectionGeometries(
|
||||
flow,
|
||||
rectByResourceId,
|
||||
sectionByResourceId,
|
||||
).map((geometry) => ({
|
||||
flow,
|
||||
geometry,
|
||||
renderKey: taskFlowRenderKey(flow.id, geometry.section),
|
||||
})),
|
||||
),
|
||||
[graph.taskFlows, rectByResourceId, sectionByResourceId],
|
||||
);
|
||||
|
||||
const renderTaskFlows = useMemo(
|
||||
() =>
|
||||
taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => {
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
|
||||
flow.cyclic ? ' is-cyclic' : ''
|
||||
}`;
|
||||
return (
|
||||
<g
|
||||
key={renderKey}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
data-edge-id={flow.id}
|
||||
data-resource-section={geometry.section}
|
||||
data-source-task-id={flow.sourceTaskId}
|
||||
data-target-task-id={flow.targetTaskId}
|
||||
data-cyclic={flow.cyclic || undefined}
|
||||
>
|
||||
<title>{`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${
|
||||
flow.cyclic ? '(检测到依赖环)' : ''
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(renderKey);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(renderKey, refs);
|
||||
}
|
||||
if (node) {
|
||||
refs.sourceBranches.set(resourceId, node);
|
||||
} else {
|
||||
refs.sourceBranches.delete(resourceId);
|
||||
}
|
||||
}}
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="source"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(point, geometry.sourceHub)}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(renderKey);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(renderKey, refs);
|
||||
}
|
||||
refs.trunk = node;
|
||||
}}
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(renderKey);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(renderKey, refs);
|
||||
}
|
||||
if (node) {
|
||||
refs.targetBranches.set(resourceId, node);
|
||||
} else {
|
||||
refs.targetBranches.delete(resourceId);
|
||||
}
|
||||
}}
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="target"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(geometry.targetHub, point)}
|
||||
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}),
|
||||
[markerPrefix, taskFlowRenderEntries],
|
||||
);
|
||||
|
||||
const renderReferenceEdges = useMemo(
|
||||
() =>
|
||||
graph.referenceEdges.map((edge) => {
|
||||
@@ -508,17 +272,6 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
[graph.referenceEdges, markerPrefix, rectByResourceId],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const activeRenderKeys = new Set(
|
||||
taskFlowRenderEntries.map((entry) => entry.renderKey),
|
||||
);
|
||||
for (const renderKey of taskFlowPathRefs.current.keys()) {
|
||||
if (!activeRenderKeys.has(renderKey)) {
|
||||
taskFlowPathRefs.current.delete(renderKey);
|
||||
}
|
||||
}
|
||||
}, [taskFlowRenderEntries]);
|
||||
|
||||
const updateAffectedGeometry = useCallback(
|
||||
(
|
||||
affectedResourceIds: ReadonlySet<string>,
|
||||
@@ -550,7 +303,6 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
index?.referenceEdgeIds.forEach((edgeId) =>
|
||||
affectedEdgeIds.add(edgeId),
|
||||
);
|
||||
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
|
||||
}
|
||||
for (const edgeId of affectedEdgeIds) {
|
||||
const referenceEdge = currentGraph.referenceEdgeById.get(edgeId);
|
||||
@@ -560,49 +312,6 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
if (geometry && path) {
|
||||
path.setAttribute('d', geometry.path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const flow = currentGraph.taskFlowById.get(edgeId);
|
||||
if (!flow) {
|
||||
continue;
|
||||
}
|
||||
const sectionByResourceId = new Map(
|
||||
Array.from(
|
||||
positionByResourceIdRef.current.values(),
|
||||
(position) => [position.resourceId, position.section] as const,
|
||||
),
|
||||
);
|
||||
for (const geometry of taskFlowSectionGeometries(
|
||||
flow,
|
||||
rectLookup,
|
||||
sectionByResourceId,
|
||||
)) {
|
||||
const paths = taskFlowPathRefs.current.get(
|
||||
taskFlowRenderKey(flow.id, geometry.section),
|
||||
);
|
||||
if (!paths) {
|
||||
continue;
|
||||
}
|
||||
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
|
||||
paths.sourceBranches
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(point, geometry.sourceHub),
|
||||
);
|
||||
});
|
||||
paths.trunk?.setAttribute(
|
||||
'd',
|
||||
connectionPath(geometry.sourceHub, geometry.targetHub),
|
||||
);
|
||||
geometry.targetAnchors.forEach(({ resourceId, point }) => {
|
||||
paths.targetBranches
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(geometry.targetHub, point),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -654,22 +363,8 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
>
|
||||
<path d="M 1 1 L 8 4.5 L 1 8 z" />
|
||||
</marker>
|
||||
<marker
|
||||
id={`${markerPrefix}-task-flow-arrow`}
|
||||
className="game-resource-dependency-marker game-resource-dependency-marker--task"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7.25"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
viewBox="0 0 8 8"
|
||||
>
|
||||
<path d="M 1 1.25 L 7 4 L 1 6.75 z" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{renderTaskFlows}
|
||||
{renderReferenceEdges}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,10 @@ import {
|
||||
type LocalGamePreviewFrameHandle,
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
resourceCanvasContentWidth,
|
||||
resourceCanvasSectionExtent,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
normalizeProjectResourceGraph,
|
||||
@@ -809,6 +812,14 @@ export default function ProjectDevelopmentView({
|
||||
),
|
||||
[resourcePositionsByCategory],
|
||||
);
|
||||
const resourceCanvasWidth = useMemo(
|
||||
() =>
|
||||
resourceCanvasContentWidth(
|
||||
resourceLayout.positions,
|
||||
sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0,
|
||||
),
|
||||
[resourceLayout.positions, sortMode],
|
||||
);
|
||||
const focusedResource =
|
||||
resources.find((resource) => resource.id === focusedResourceId) ?? null;
|
||||
const focusedReplacementBindings =
|
||||
@@ -1630,17 +1641,8 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
{mode === 'resources' && !focusedResource ? (
|
||||
{mode === 'resources' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={sortMode === 'dependency' ? 'is-active' : ''}
|
||||
aria-pressed={sortMode === 'dependency'}
|
||||
onClick={() => setSortMode('dependency')}
|
||||
>
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
按依赖
|
||||
</button>
|
||||
<span
|
||||
className="game-resource-reorder-status"
|
||||
role="status"
|
||||
@@ -1648,15 +1650,30 @@ export default function ProjectDevelopmentView({
|
||||
>
|
||||
{resourceLayoutSaving ? '保存中' : resourceLayoutNotice}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={sortMode === 'type' ? 'is-active' : ''}
|
||||
aria-pressed={sortMode === 'type'}
|
||||
onClick={() => setSortMode('type')}
|
||||
<div
|
||||
className="game-resource-layout-switch"
|
||||
role="group"
|
||||
aria-label="资源排列方式"
|
||||
>
|
||||
<ListFilter size={15} aria-hidden="true" />
|
||||
按类型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sortMode === 'dependency' ? 'is-active' : ''}
|
||||
aria-pressed={sortMode === 'dependency'}
|
||||
onClick={() => setSortMode('dependency')}
|
||||
>
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
按依赖
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sortMode === 'type' ? 'is-active' : ''}
|
||||
aria-pressed={sortMode === 'type'}
|
||||
onClick={() => setSortMode('type')}
|
||||
>
|
||||
<ListFilter size={15} aria-hidden="true" />
|
||||
按类型
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'run' && currentRunnableVersion ? (
|
||||
<label className="game-run-version-picker">
|
||||
@@ -1980,7 +1997,10 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
aria-busy={resourceLayoutSaving}
|
||||
>
|
||||
<div className="game-resource-canvas-content">
|
||||
<div
|
||||
className="game-resource-canvas-content"
|
||||
style={{ width: `${resourceCanvasWidth}px` }}
|
||||
>
|
||||
{sortMode === 'dependency' ? (
|
||||
<ResourceDependencyOverlay
|
||||
key={`${projectPath}:${manifest.projectId}`}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
|
||||
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
|
||||
export const RESOURCE_CANVAS_SECTION_HORIZONTAL_PADDING = 12;
|
||||
|
||||
const sectionOrder: ProjectResourceCanvasSection[] = [
|
||||
'document',
|
||||
@@ -323,3 +324,14 @@ export function resourceCanvasSectionExtent(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resourceCanvasContentWidth(
|
||||
positions: ProjectResourceCanvasPosition[],
|
||||
trailingGutter = 0,
|
||||
) {
|
||||
return (
|
||||
resourceCanvasSectionExtent(positions).width +
|
||||
Math.max(0, Math.round(trailingGutter)) +
|
||||
RESOURCE_CANVAS_SECTION_HORIZONTAL_PADDING
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function overlayView(
|
||||
}
|
||||
|
||||
describe('ResourceDependencyOverlay', () => {
|
||||
it('renders exact references and one aggregated task trunk without cartesian paths', async () => {
|
||||
it('renders exact references without the noisy aggregated task-flow layer', async () => {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
||||
position(resourceId, (index % 3) * 220, Math.floor(index / 3) * 120),
|
||||
@@ -167,27 +167,11 @@ describe('ResourceDependencyOverlay', () => {
|
||||
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]');
|
||||
expect(taskFlow).not.toBeNull();
|
||||
const taskPaths = Array.from(
|
||||
taskFlow?.querySelectorAll<SVGPathElement>('path') ?? [],
|
||||
);
|
||||
expect(taskPaths).toHaveLength(7);
|
||||
expect(
|
||||
taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')),
|
||||
).toBe(false);
|
||||
expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3);
|
||||
expect(
|
||||
overlay
|
||||
.querySelector('marker[id$="-task-flow-arrow"]')
|
||||
?.getAttribute('markerUnits'),
|
||||
).toBe('userSpaceOnUse');
|
||||
expect(overlay.querySelector('[data-edge-kind="task-flow"]')).toBeNull();
|
||||
expect(overlay.querySelector('marker[id$="-task-flow-arrow"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('omits cross-section task endpoints and keeps same-section task flow groups', async () => {
|
||||
it('keeps exact references across sections without rendering task flows', async () => {
|
||||
const graph = graphFixture();
|
||||
const positions = [
|
||||
position('source:one', 0, 0, 'document'),
|
||||
@@ -206,21 +190,12 @@ describe('ResourceDependencyOverlay', () => {
|
||||
);
|
||||
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const taskFlow = await waitFor(() => {
|
||||
const flows = overlay.querySelectorAll('[data-edge-kind="task-flow"]');
|
||||
expect(flows).toHaveLength(1);
|
||||
return flows.item(0);
|
||||
});
|
||||
expect(taskFlow.getAttribute('data-resource-section')).toBe('art');
|
||||
expect(taskFlow.querySelectorAll('path')).toHaveLength(4);
|
||||
expect(
|
||||
Array.from(taskFlow.querySelectorAll('[data-resource-id]')).map((node) =>
|
||||
node.getAttribute('data-resource-id'),
|
||||
),
|
||||
).toEqual(['source:two', 'target:one', 'target:two']);
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
||||
).toHaveLength(1);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
expect(overlay.querySelector('[data-edge-kind="task-flow"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters hidden endpoints and updates path geometry when positions change', async () => {
|
||||
@@ -372,13 +347,13 @@ describe('ResourceDependencyOverlay', () => {
|
||||
render(overlayView(graph, positions, new Set(graph.resourceIds)));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelectorAll('[data-edge-kind]')).toHaveLength(3),
|
||||
expect(overlay.querySelectorAll('[data-edge-kind]')).toHaveLength(2),
|
||||
);
|
||||
expect(overlay.querySelector('.is-highlighted')).toBeNull();
|
||||
expect(overlay.querySelector('.is-dimmed')).toBeNull();
|
||||
});
|
||||
|
||||
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
|
||||
it('does not render task-flow paths for a 4096-resource topology', async () => {
|
||||
const resourceIds = Array.from(
|
||||
{ length: 4096 },
|
||||
(_, index) => `resource:${index}`,
|
||||
@@ -424,11 +399,7 @@ describe('ResourceDependencyOverlay', () => {
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
render(overlayView(graph, positions, visible, overlayRef));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
|
||||
const setAttribute = vi.spyOn(SVGElement.prototype, 'setAttribute');
|
||||
try {
|
||||
act(() =>
|
||||
@@ -441,7 +412,7 @@ describe('ResourceDependencyOverlay', () => {
|
||||
const geometryUpdates = setAttribute.mock.calls.filter(
|
||||
([name]) => name === 'd',
|
||||
);
|
||||
expect(geometryUpdates).toHaveLength(6);
|
||||
expect(geometryUpdates).toHaveLength(0);
|
||||
} finally {
|
||||
setAttribute.mockRestore();
|
||||
}
|
||||
|
||||
@@ -851,6 +851,20 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(
|
||||
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
|
||||
).not.toBeNull();
|
||||
const layoutSwitch = screen.getByRole('group', {
|
||||
name: '资源排列方式',
|
||||
});
|
||||
expect(
|
||||
within(layoutSwitch).getByRole('button', { name: '按依赖' }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(layoutSwitch).getByRole('button', { name: '按类型' }),
|
||||
).not.toBeNull();
|
||||
expect(layoutSwitch.closest('.game-workbench-view-actions')).not.toBeNull();
|
||||
const canvasContent = document.querySelector<HTMLElement>(
|
||||
'.game-resource-canvas-content',
|
||||
);
|
||||
expect(canvasContent?.style.width).toMatch(/^\d+px$/u);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const searchInput = screen.getByLabelText(
|
||||
@@ -895,6 +909,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-card\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-toolbar\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*max-content minmax\(0, 1fr\)/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-layout-switch\s*\{[^}]*flex:\s*0 0 auto[^}]*flex-wrap:\s*nowrap/s,
|
||||
);
|
||||
expect(styles).not.toMatch(/\.game-resource-card\.is-dragging/);
|
||||
|
||||
fireEvent.click(artReceiptCard!);
|
||||
@@ -903,7 +923,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'resources.focused.document',
|
||||
);
|
||||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '按类型' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '按类型' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '按依赖' }).getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
|
||||
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
|
||||
const receiptFocus = screen.getByRole('region', {
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
createEmptyResourceCanvasLayout,
|
||||
moveResourceCanvasPosition,
|
||||
reconcileResourceCanvasLayout,
|
||||
resourceCanvasContentWidth,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_ROW_GAP,
|
||||
RESOURCE_CANVAS_SECTION_HORIZONTAL_PADDING,
|
||||
type ResourceCanvasItem,
|
||||
} from '../src/view/project-development/resourceCanvasLayoutModel';
|
||||
|
||||
@@ -134,6 +136,31 @@ describe('resource canvas layout model', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the furthest card and the trailing visual gutter inside the horizontal scroll range', () => {
|
||||
const furthestX = 1764;
|
||||
const trailingGutter = 64;
|
||||
const width = resourceCanvasContentWidth(
|
||||
[
|
||||
{
|
||||
resourceId: 'furthest-resource',
|
||||
section: 'document',
|
||||
x: furthestX,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
],
|
||||
trailingGutter,
|
||||
);
|
||||
|
||||
expect(width).toBe(
|
||||
furthestX +
|
||||
RESOURCE_CANVAS_CARD_WIDTH +
|
||||
RESOURCE_CANVAS_COLUMN_GAP +
|
||||
trailingGutter +
|
||||
RESOURCE_CANVAS_SECTION_HORIZONTAL_PADDING,
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts type defaults by subtype before media type and label with an id fallback', () => {
|
||||
const typeLayout = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('project-type-order', 'type'),
|
||||
|
||||
@@ -6,10 +6,13 @@ import { join, resolve } from 'node:path';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBackendStartArguments,
|
||||
buildViteStartArguments,
|
||||
ensureBackend,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveDevStackProfile,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
@@ -21,6 +24,63 @@ import {
|
||||
const expectedDatabase = 'genarrative-game-creator-dev';
|
||||
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
|
||||
|
||||
describe('AI 游戏创作开发 profile', () => {
|
||||
test('app-run 使用独立端口、数据库和 SpacetimeDB 数据目录', () => {
|
||||
const profile = resolveDevStackProfile('app-run');
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
name: 'app-run',
|
||||
viteHost: '127.0.0.1',
|
||||
vitePort: 3081,
|
||||
apiPort: 8084,
|
||||
bgfilterWorkerPort: 8085,
|
||||
spacetimePort: 3103,
|
||||
backendDatabase: 'genarrative-game-creator-app-run-dev',
|
||||
});
|
||||
expect(profile.backendSpacetimeDataDir).toBe(
|
||||
resolve('server-rs/.spacetimedb/ai-game-creator-app-run/data'),
|
||||
);
|
||||
expect(buildBackendStartArguments(profile)).toEqual([
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
'genarrative-game-creator-app-run-dev',
|
||||
'--spacetime-data-dir',
|
||||
resolve('server-rs/.spacetimedb/ai-game-creator-app-run/data'),
|
||||
'--api-port',
|
||||
'8084',
|
||||
'--bgfilter-worker-port',
|
||||
'8085',
|
||||
'--spacetime-port',
|
||||
'3103',
|
||||
'--no-interactive',
|
||||
]);
|
||||
expect(buildViteStartArguments(profile)).toEqual([
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'--config',
|
||||
'vite.config.ts',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3081',
|
||||
'--strictPort',
|
||||
]);
|
||||
});
|
||||
|
||||
test('未知 profile 失败关闭', () => {
|
||||
expect(() => resolveDevStackProfile('another-worktree')).toThrow(
|
||||
'未知 AI 游戏创作开发 profile',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function backendState(spacetimeDataDir?: string) {
|
||||
return {
|
||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||
@@ -306,4 +366,21 @@ describe('AI 游戏创作 3080 启动前预检', () => {
|
||||
}),
|
||||
).resolves.toEqual({ status: 'available', apiTarget: '' });
|
||||
});
|
||||
|
||||
test('app-run 只预检自己的 3081,不引用默认 3080', async () => {
|
||||
await expect(
|
||||
preflightExistingVite({
|
||||
profile: resolveDevStackProfile('app-run'),
|
||||
readServer: async () => agcHtml,
|
||||
portListening: async () => true,
|
||||
readMarker: async () => ({
|
||||
schemaVersion: 1,
|
||||
app: 'ai-game-creator-shell',
|
||||
apiTarget: 'http://127.0.0.1:8084',
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'http://127.0.0.1:3081/ is already running with API target http://127.0.0.1:8084',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
buildTauriArguments,
|
||||
parseLauncherArguments,
|
||||
runTauriDev,
|
||||
} from '../scripts/start-tauri-dev.mjs';
|
||||
|
||||
@@ -31,6 +32,26 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']);
|
||||
});
|
||||
|
||||
test('app-run 使用独立 Tauri 配置并保留 CLI 参数', () => {
|
||||
expect(buildTauriArguments(['--app-run', '--no-watch'])).toEqual([
|
||||
'dev',
|
||||
'--config',
|
||||
'src-tauri/tauri.app-run-dev.conf.json',
|
||||
'--no-watch',
|
||||
]);
|
||||
expect(parseLauncherArguments(['--app-run'])).toEqual({
|
||||
appRun: true,
|
||||
gameChat: false,
|
||||
args: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('app-run 与 game-chat 不允许混用', () => {
|
||||
expect(() =>
|
||||
buildTauriArguments(['--app-run', '--game-chat']),
|
||||
).toThrow('app-run profile 不能与 game-chat 入口同时使用');
|
||||
});
|
||||
|
||||
test('game-chat 参数进入应用参数区且保留项目参数', () => {
|
||||
expect(
|
||||
buildTauriArguments([
|
||||
@@ -50,6 +71,38 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
});
|
||||
|
||||
describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
test('app-run 预检 3081 profile 并把 profile 传给 Tauri 子进程', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 1234,
|
||||
exitCode: 0,
|
||||
signalCode: null,
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const preflight = vi.fn(async () => {});
|
||||
const spawnCli = vi.fn(() => child);
|
||||
|
||||
await expect(
|
||||
runTauriDev(['--app-run'], {
|
||||
preflight,
|
||||
spawnCli,
|
||||
waitForCli: async () => ({ type: 'exit', code: 0, signal: null }),
|
||||
terminateTree: async () => ({ stopped: true, forced: false }),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(preflight).toHaveBeenCalledWith({
|
||||
profile: expect.objectContaining({ name: 'app-run', vitePort: 3081 }),
|
||||
});
|
||||
expect(spawnCli).toHaveBeenCalledWith(
|
||||
[
|
||||
'dev',
|
||||
'--config',
|
||||
'src-tauri/tauri.app-run-dev.conf.json',
|
||||
],
|
||||
{ profileName: 'app-run' },
|
||||
);
|
||||
});
|
||||
|
||||
test('3080 预检失败时不启动 Tauri CLI', async () => {
|
||||
const spawnCli = vi.fn();
|
||||
|
||||
|
||||
@@ -262,17 +262,17 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。
|
||||
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化与精确资源引用的浏览器几何。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
|
||||
- `asset-reference` 表示精确资源引用,使用明亮橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- `task-flow` 表示同一资源类型内的任务产物流转,使用灰色圆头虚线;文档、项目版本、美术、音频之间不得绘制跨分区虚线。任务依赖按 `sourceTaskId -> targetTaskId + section` 分区聚合为一条主线,两端只保留同分区资源并绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
|
||||
- `task-flow` 继续作为 Rust 只读拓扑、依赖深度和局部连接索引的数据,不再在资源画布绘制灰色虚线。真实项目的任务产物数量较多,聚合主线与分支仍会形成大面积交叉,不能作为默认用户可读关系;禁止前端重新把 task flow 展开为资源笛卡尔积连线。
|
||||
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
|
||||
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
|
||||
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏。
|
||||
- 资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。
|
||||
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。
|
||||
- `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对与资源分区聚合,禁止为了计算深度或绘线生成资源笛卡尔积。
|
||||
- `ResizeObserver` 在单个图层生命周期只允许构造一次。资源画布内容宽度必须显式覆盖最右卡片外边界、卡片间距和 section 横向内边距,不能依赖绝对定位卡片隐式撑开滚动范围;dependency section 在此基础上额外提供至少 `64px` 右侧视觉 gutter,确保最右卡片、自环和箭头都可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对聚合供深度计算使用,不进入 SVG 绘制。
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
@@ -544,12 +544,13 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
### 7.3 P1 资源依赖关系图验收
|
||||
|
||||
1. dependency 模式显示明亮橙色实线资源引用,并只在同一资源类型分区内显示灰色虚线任务流;跨类型不显示虚线,type 模式没有图层或连线。
|
||||
1. dependency 模式只显示明亮橙色实线资源引用,不显示灰色任务流虚线;type 模式没有图层或连线。资源列表态和资源聚焦态右上角都必须保留“按依赖 / 按类型”切换。
|
||||
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
|
||||
3. 多资源任务依赖按资源类型分区后,各分区只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线或跨分区虚线。
|
||||
3. 多资源任务依赖继续在 Rust read model 中按任务对聚合,不产生 `S×T` 数据或可见虚线。
|
||||
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
|
||||
5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 横向滚动终点必须完整露出最右资源卡及右侧安全留白;布局状态文字可以截断或隐藏,但不得压缩、遮挡“按依赖 / 按类型”切换。
|
||||
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-06 app-run 以独立开发 profile 支持多工作树并行运行
|
||||
|
||||
- 背景:多个 Git worktree 能隔离代码和分支,但不会隔离宿主机端口、Tauri identifier、AppData、数据库或本地 data dir。默认 AGC 固定使用 `3080 / 8082 / 8083 / 3101`,另一工作树运行时当前窗口可能被同名实例混淆,默认启动器则会为避免串台直接失败关闭。
|
||||
- 决策:保留 `npm run agc` 默认行为,新增 `npm run agc:app-run`;两者复用同一 `start-tauri-dev.mjs` 与 `start-dev-stack.mjs`。`app-run` profile 使用 `3081 / 8084 / 8085 / 3103`、专用数据库与 SpacetimeDB data dir,并通过独立 Tauri 配置隔离 identifier、AppData 和窗口标题。profile 随 Tauri 子进程环境传给 `beforeDevCommand`,禁止只切 Vite 而继续连接默认后端。
|
||||
- 边界:不修改、停止或复用其它工作树进程,不改默认 AGC、game-chat release、生产配置或业务契约;未知 profile 和 profile 自身端口冲突均失败关闭。
|
||||
- 验证方式:启动器单测锁定 profile 端口、数据库、data dir、3081 预检、Tauri config 与环境传递;配置门禁锁定专用脚本、identifier、标题和 devUrl;并行 smoke 同时核对默认端口仍由另一工作树持有,app-run 端口进程 cwd 指向当前工作树。
|
||||
|
||||
|
||||
## 2026-08-06 资源依赖画布移除任务流虚线并常驻布局切换
|
||||
|
||||
- 背景:真实项目会把大量任务产物投影到文档区,灰色 task-flow 聚合主线与分支仍形成大面积交叉,遮挡卡片并增加阅读负担;同时布局状态提示的最小内容宽度可能挤压右侧“按依赖 / 按类型”入口,资源聚焦态还会主动隐藏整组入口。
|
||||
- 决策:Rust `ProjectResourceGraph` 继续保留 task flow、producer、环和 dependency depth,供可信自动排列与诊断使用;前端 `ResourceDependencyOverlay` 只绘制明确的橙色 `asset-reference`,不再绘制灰色 task-flow 虚线或 marker。资源列表态与中央聚焦态均常驻“按依赖 / 按类型”切换;切换按钮放入不可收缩的独立控件组,工具栏状态文案只能截断或隐藏,不能把操作入口挤出。画布内容宽度显式按最右卡片外边界、section 内边距与 dependency 视觉 gutter 计算,不能依赖绝对定位卡片隐式撑开横向滚动范围。
|
||||
- 边界:不修改 manifest、资源图 Tauri DTO、依赖深度、layout sidecar、SpacetimeDB 或资源卡拖动边界;type 模式继续不挂载 SVG,dependency 模式仍使用完整 Rust 拓扑排列资源。
|
||||
- 验证方式:SVG 单测断言含 task flow 的 read model 只产生精确引用 path;AppSurface 覆盖独立布局切换控件组、type 模式卸载图层和聚焦态切换按钮常驻;布局模型以最右 `x` 断言滚动内容宽度包含卡片、列间距、`64px` gutter 与 section 内边距;追加 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-04 工作台 P2/P6 以不可变子版本和编辑态参数文件实现显式写入
|
||||
|
||||
- 背景:P1 已建立唯一可运行版本和不可变快照,P3-P5 已建立一次性会话、正式切片和可信悬停信息;飞书后续要求资源替换与数值微调,但禁止原地改版本、运行中热写 iframe、信任展示文案或让自然语言生成任意代码 / 路径。
|
||||
|
||||
@@ -59,6 +59,14 @@ AI 游戏创作独立客户端常用短命令:
|
||||
npm run agc
|
||||
```
|
||||
|
||||
同一台开发机上已有另一工作树运行默认 AGC 时,当前 `app-run` 工作树使用独立 profile:
|
||||
|
||||
```bash
|
||||
npm run agc:app-run
|
||||
```
|
||||
|
||||
该 profile 固定使用 Vite `3081`、API `8084`、BgFilter worker `8085`、SpacetimeDB `3103`,并隔离数据库、SpacetimeDB data dir、Tauri identifier、AppData 和窗口标题。它不会探测、复用或停止默认 profile 的 `3080 / 8082 / 8083 / 3101` 进程;`npm run agc` 的默认行为保持不变。
|
||||
|
||||
需要构建只能打开“游戏运行 + 聊天”页面的独立 release 包时使用:
|
||||
|
||||
```bash
|
||||
@@ -314,7 +322,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
|
||||
|
||||
定向命令必须实际匹配到 V1.10 用例,`0 tests` 不算通过。真实 Provider fixture 不得把工具顺序、processId、readiness 文本所在 chunk 或 OS PID 写进任务提示;验收器只按持久 action identity、fixture 计数、私有输出和公共泄漏扫描判定。三项门禁实际通过后才能把日期、Provider、数量和 PASS 结果写入技术方案或 decision log;未运行或被外部配置阻断时只记录 `BLOCKED` / 未验收事实。
|
||||
|
||||
`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path <absolute-path>]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`。
|
||||
`npm run agc` 会启动默认 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。并行工作树使用 `npm run agc:app-run`,启动器把 `app-run` profile 通过子进程环境传给同一 `beforeDevCommand`,改用独立 `3081 / 8084 / 8085 / 3103` 端口、数据库、data dir 和 Tauri identity;禁止手工只改 Vite 端口而继续连接默认后端或 AppData。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path <absolute-path>]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`。
|
||||
|
||||
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,五个 dev 服务依次使用 `start` 到 `start + 4`,其中 BgFilter worker 固定为 `start + 4`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 把第五个服务纳入原有统一端口探测与漂移逻辑。
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 多工作树只隔离代码,不会自动隔离 AGC 运行资源
|
||||
|
||||
- 现象:两个工作树同时运行 `npm run agc` 时,后启动者因 `3080` 已占用而失败,或用户在两个同名窗口间误把另一工作树的页面当作当前修改结果。
|
||||
- 原因:worktree 只隔离 Git 工作目录;端口、Tauri identifier、AppData、SpacetimeDB 数据库和 data dir 都属于宿主机共享资源。只修改 Vite 端口仍会让代理、Runner 或数据库串到默认实例。
|
||||
- 处理:默认工作树继续使用 `npm run agc`;当前 app-run 使用 `npm run agc:app-run`,由统一 profile 同时隔离 `3081 / 8084 / 8085 / 3103`、数据库、data dir、Tauri identifier、AppData 和窗口标题。不要手工终止无法证明归属的进程,也不要只覆盖单个端口。
|
||||
- 验证:分别用 `lsof -a -p <pid> -d cwd -Fn` 核对两组监听进程 cwd;app-run 窗口标题必须为“AI 游戏创作 · App Run”,Vite marker API target 必须指向 app-run 的实际 API。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Linux 生产脚本门禁不能假设本地也是 GNU userland
|
||||
|
||||
- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。
|
||||
|
||||
@@ -362,7 +362,7 @@ game-project/
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。2026-08-04 起运行入口不再读取 `code-prototype` 任务状态,只读取 manifest 的正式 `runnableVersions + currentRunnableVersionId`;无版本时保持视觉不可用但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列并在资源列表态、聚焦态常驻右上角;dependency 模式只额外绘制当前 manifest 与资源投影可证明的精确资源引用。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
|
||||
- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。2026-08-04 起 P3 在该生命周期上接入运行会话与 Host/Game Bridge;P3 阶段自身未接入资源替换、下一版本、测试切片编排或参数调整,P2 / P6 的当前实现以本节后续对应专节为准。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
@@ -402,10 +402,10 @@ game-project/
|
||||
- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、独立 `dependencyDepths`、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。
|
||||
- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。
|
||||
- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`;External Editor 返回并保存在 `source.taskId` 的 `task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。
|
||||
- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。
|
||||
- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow,供依赖深度、环检测和局部连接索引使用;前端 SVG 不再绘制 task flow。局部连接索引仍保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。
|
||||
- 资源引用图和完整任务依赖图在 Rust 分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
|
||||
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。
|
||||
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar。
|
||||
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。图层只绘制橙色实线 `asset-reference`,不绘制灰色 task flow 虚线;资源引用使用连续贝塞尔曲线和不随线宽缩放的稳定用户空间箭头。不引入 D3、React Flow 或其它图表依赖。
|
||||
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。`.game-resource-canvas-content` 使用 `resourceCanvasContentWidth(...)` 显式覆盖最右坐标、卡片宽度、列间距与 section 横向内边距,避免绝对定位卡片溢出却没有进入 `scrollWidth`;dependency 模式再追加 `64px` 右侧视觉 gutter,确保最右卡片、闭环和箭头均可滚动显示。该宽度只影响显示层,不改卡片坐标或 layout sidecar。
|
||||
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
|
||||
- 基础 positions 保持稳定;卡片 Pointer Move 不进入 SVG preview,只有搜索、选择、项目切换、真实 positions 或 section origin 变化才重新协调图层。SVG 几何从不持久化。
|
||||
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、稳定 Observer、type 模式卸载与项目切换销毁。局部拖动更新与真实 Chromium 拖动性能目标暂缓。
|
||||
|
||||
@@ -58,7 +58,9 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
|
||||
|
||||
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||
|
||||
AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。
|
||||
AI 游戏创作客户端默认使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。
|
||||
|
||||
同一台机器需要并行运行另一工作树时,`app-run` 工作树使用 `npm run agc:app-run`。该命令仍复用同一 Tauri / dev-stack 启动器,但使用独立 profile:Vite `3081`、API `8084`、BgFilter worker `8085`、SpacetimeDB `3103`、数据库 `genarrative-game-creator-app-run-dev`、独立 SpacetimeDB data dir,以及独立的 Tauri identifier、AppData 和“AI 游戏创作 · App Run”窗口标题。profile 只检查和清理自己创建的进程树,不复用、不终止默认 profile 或其它工作树进程。若 profile 自己的端口被占用则失败关闭,不能漂回默认端口。
|
||||
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck",
|
||||
"desktop-shell:test": "cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml",
|
||||
"agc": "npm --prefix apps/ai-game-creator-shell run dev",
|
||||
"agc:app-run": "npm --prefix apps/ai-game-creator-shell run dev:app-run",
|
||||
"agc:dev": "npm --prefix apps/ai-game-creator-shell run dev",
|
||||
"agc:game-chat": "npm --prefix apps/ai-game-creator-shell run game-chat --",
|
||||
"agc:serve": "npm run agc:typecheck && npm --prefix apps/ai-game-creator-shell run dev-stack",
|
||||
|
||||
Reference in New Issue
Block a user