diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3b4cf7c4d..3dbd5cd07 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -46,6 +46,7 @@ module.exports = { { files: [ 'apps/admin-web/src/pages/*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx', 'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx', ], rules: { @@ -72,6 +73,7 @@ module.exports = { 'src/components/image-editor/**/*.{ts,tsx}', 'src/components/project/**/*.{ts,tsx}', 'src/components/platform-entry/PlatformActiveProfileView*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog*.tsx', 'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx', 'src/components/platform-entry/PlatformEntryFlowShell.tsx', 'src/components/platform-entry/PlatformProfileApiKeysModal.tsx', diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index 2562ed0f6..08f6235c7 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -2,7 +2,7 @@ name: genarrative-dev-stack-port-routing short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。 description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 -version: 1.1.0 +version: 1.2.0 author: Hermes Agent license: MIT metadata: @@ -17,7 +17,7 @@ metadata: ## 适用场景 -- 修改 `scripts/dev.mjs`、`scripts/dev-utils.mjs`、`scripts/dev-stack-port-utils.mjs`。 +- 修改 `scripts/dev.mjs`、`scripts/dev-utils.mjs`、`scripts/dev-stack-port-utils.mjs` 或 AI 游戏创作客户端 dev 启动器。 - 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。 - 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。 - 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。 @@ -32,10 +32,11 @@ metadata: 3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:/v1/ping`。 4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:/admin/`。 5. 独立 BgFilter worker:`8083`,就绪检查为 `http://127.0.0.1:/readyz`。 +6. AI 游戏创作 Vite:非 Linux 兼容首选 `3080`;Linux 使用当前用户端口段的 `start + 5`。 端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。 -Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`、`bgfilterWorker = start + 4`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。 +Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`、`bgfilterWorker = start + 4`、`agcVite = start + 5`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。 ## 实现入口 @@ -51,9 +52,12 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` - `scripts/dev.mjs` - 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER`、`RUST_SERVER_TARGET`。 - 完整栈按 SpacetimeDB、publish、BgFilter worker readiness、api-server readiness、主站 Vite、后台 Vite 顺序启动。 - - Linux 下会先申请系统级端口段并把它映射成五个 dev 端口;自动分配从 `10000-10099` 起,Windows 则把第五个服务纳入原有统一参数解析与端口漂移逻辑。 + - Linux 下会先申请系统级端口段并映射成六个预留槽位;主 dev 栈使用前五个,AGC Vite 使用 `start + 5`,自动分配从 `10000-10099` 起。Windows 的主 dev 栈和 AGC 则各自沿用统一端口探测与漂移逻辑。 - 完整栈和 `dev:api-server` 把两个 Rust 进程作为同一重启单元,先全部停止,再先启动 BgFilter worker、后启动 api-server;不要为同一份 Rust 源码创建两个并发 `cargo` watcher。 - 单模块命令复用同一套参数和 env 解析。 +- `apps/ai-game-creator-shell/scripts/dev-port.mjs` + - 复用系统级用户端口段,解析 AGC Vite 的 `start + 5` 首选槽位。 + - 把最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 同步给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI `--config` 同步 `build.devUrl`,并通过 Vite CLI `--port` 同步 `strictPort` 监听。 ## 必须保持的传递链路 @@ -69,6 +73,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 8. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 9. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/bgfilter-worker/spacetime` 必须显示最终实际地址。 10. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 +11. AI 游戏创作客户端:外层启动器解析最终 AGC Vite 端口后,通过 Tauri CLI `--config` 覆盖 `build.devUrl`,把同一 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 与配套后端端口解析器,并用 Vite CLI `--port` 启动严格监听;后端端口漂移必须跳过该预留端口。 如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。 @@ -128,6 +133,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap - [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 - [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / Token,Rust watch 只触发一次组合重启。 - [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 +- [ ] `npm run agc` / `npm run agc:game-chat` 在 Linux 使用用户段 `start + 5`,Tauri、Vite、marker 和预检使用同一最终端口。 - [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。 - [ ] 修改中文文件后运行 `npm run check:encoding`。 diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 91e8f5e2d..be97f7404 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -62,6 +62,14 @@ const viteConfigSource = fs.readFileSync( new URL('../vite.config.ts', import.meta.url), 'utf8', ); +const devPortSource = fs.readFileSync( + new URL('../scripts/dev-port.mjs', import.meta.url), + 'utf8', +); +const startTauriDevSource = fs.readFileSync( + new URL('../scripts/start-tauri-dev.mjs', import.meta.url), + 'utf8', +); const appSource = [ readSourceTree(new URL('../src/', import.meta.url), '.ts'), readSourceTree(new URL('../src/', import.meta.url), '.tsx'), @@ -1217,7 +1225,7 @@ if ( if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { throw new Error( - 'AI game creator shell Tauri devUrl must stay on the fixed Vite dev port', + 'AI game creator shell Tauri config must retain the non-launcher fallback devUrl', ); } @@ -1227,12 +1235,41 @@ if (!viteConfigSource.includes("host: '127.0.0.1'")) { ); } -if (!viteConfigSource.includes('port: 3080')) { +if ( + !viteConfigSource.includes('port: 3080') || + !viteConfigSource.includes('port: server.config.server.port') +) { throw new Error( - 'AI game creator shell Vite dev port must match Tauri devUrl', + 'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port', ); } +for (const snippet of [ + 'mapDevPortsToPortRange', + 'agcVitePort', + 'resolveAgcDevEndpoint', + 'GENARRATIVE_AGC_VITE_PORT', +]) { + if (!devPortSource.includes(snippet)) { + throw new Error( + `AI game creator shell dev port resolver drifted: ${snippet}`, + ); + } +} + +for (const snippet of [ + 'resolveAgcDevEndpoint', + 'withAgcDevEndpointEnv', + "'--config'", + 'configOverride', +]) { + if (!startTauriDevSource.includes(snippet)) { + throw new Error( + `AI game creator shell Tauri dev port injection drifted: ${snippet}`, + ); + } +} + if (!viteConfigSource.includes('strictPort: true')) { throw new Error( 'AI game creator shell Vite dev server must not drift away from Tauri devUrl', @@ -1253,7 +1290,7 @@ if ( ) ) { throw new Error( - 'AI game creator shell beforeDevCommand must reuse or start the fixed Vite dev server', + 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); } @@ -1463,12 +1500,15 @@ if ( } for (const snippet of [ - 'const port = 3080', + 'resolveAgcDevEndpoint', + 'withAgcDevEndpointEnv', "response.body.includes('AI 游戏创作')", 'function isPortListening()', - 'reuse existing Vite dev server', + 'cannot be safely reused', 'non-HTTP or unrecognized server', - "'--config', 'vite.config.ts'", + "'--config'", + "'vite.config.ts'", + "'--port'", ]) { if (!devServerSource.includes(snippet)) { throw new Error( diff --git a/apps/ai-game-creator-shell/scripts/dev-port.mjs b/apps/ai-game-creator-shell/scripts/dev-port.mjs new file mode 100644 index 000000000..2e47a7d92 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/dev-port.mjs @@ -0,0 +1,111 @@ +import { + findAvailablePort, + formatPortDecision, + mapDevPortsToPortRange, + normalizePort, + reserveLinuxDevPortRange, +} from '../../../scripts/dev-stack-port-utils.mjs'; + +const agcDevHost = '127.0.0.1'; +const legacyAgcDevPort = 3080; +const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT'; + +function readConfiguredAgcDevPort(env = process.env) { + const rawPort = String(env[agcVitePortEnvKey] ?? '').trim(); + if (!rawPort) { + return null; + } + + const port = normalizePort(rawPort, -1); + if (port < 1024) { + throw new Error(`${agcVitePortEnvKey} 必须是 1024-65535 的有效端口`); + } + return port; +} + +function createAgcDevEndpoint(port, portRange = null) { + const url = `http://${agcDevHost}:${port}/`; + return { + host: agcDevHost, + port, + url, + markerUrl: `${url}__agc_dev_server.json`, + portRange, + }; +} + +function readAgcDevEndpoint(env = process.env) { + return createAgcDevEndpoint( + readConfiguredAgcDevPort(env) ?? legacyAgcDevPort, + ); +} + +async function resolveAgcDevEndpoint({ + env = process.env, + platform = process.platform, + strictConfigured = false, + reservePortRange = reserveLinuxDevPortRange, + findPort = findAvailablePort, +} = {}) { + const configuredPort = readConfiguredAgcDevPort(env); + let portRange = null; + let preferredPort = configuredPort ?? legacyAgcDevPort; + + if (platform === 'linux') { + const allocation = await reservePortRange({ env }); + if (!allocation?.range) { + throw new Error('无法取得当前 Linux 用户的 dev 端口段'); + } + portRange = allocation.range; + const mappedAgcVitePort = mapDevPortsToPortRange(portRange)?.agcVitePort; + if (!Number.isInteger(mappedAgcVitePort)) { + throw new Error( + `当前 Linux dev 端口段 ${portRange.label} 缺少 AGC Vite 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`, + ); + } + if (configuredPort != null && configuredPort < mappedAgcVitePort) { + throw new Error( + `${agcVitePortEnvKey} ${configuredPort} 占用了 Linux dev 端口段 ${portRange.label} 的前五个服务槽位`, + ); + } + preferredPort = configuredPort ?? mappedAgcVitePort; + } + + const port = await findPort({ + host: agcDevHost, + preferredPort, + portRange, + strict: strictConfigured && configuredPort != null, + }); + console.log( + formatPortDecision({ + name: 'ai-game-creator-shell', + host: agcDevHost, + preferredPort, + resolvedPort: port, + }), + ); + if (portRange) { + console.log(`[ai-game-creator-shell] dev port-range: ${portRange.label}`); + } + + return createAgcDevEndpoint(port, portRange); +} + +function withAgcDevEndpointEnv(endpoint, env = process.env) { + return { + ...env, + [agcVitePortEnvKey]: String(endpoint.port), + }; +} + +export { + agcDevHost, + agcVitePortEnvKey, + createAgcDevEndpoint, + legacyAgcDevPort, + readAgcDevEndpoint, + readConfiguredAgcDevPort, + resolveAgcDevEndpoint, + withAgcDevEndpointEnv, +}; diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs index f1f76ff31..355e29197 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs @@ -3,10 +3,11 @@ import http from 'node:http'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; +import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs'; + const appRoot = fileURLToPath(new URL('..', import.meta.url)); -const host = '127.0.0.1'; -const port = 3080; -const devUrl = `http://${host}:${port}/`; +const endpoint = await resolveAgcDevEndpoint(); +const { host, port, url: devUrl } = endpoint; function readExistingServer() { return new Promise((resolve) => { @@ -69,8 +70,10 @@ function isPortListening() { const existing = await readExistingServer(); if (existing) { if (isAiGameCreatorServer(existing)) { - console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${devUrl}`); - process.exit(0); + console.error( + `[ai-game-creator-shell] ${devUrl} is already running but cannot be safely reused. Stop that process before starting the dev server.`, + ); + process.exit(1); } console.error( `[ai-game-creator-shell] ${devUrl} is already in use by another server. Stop that process before starting Tauri dev.`, @@ -88,9 +91,20 @@ if (await isPortListening()) { const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const child = spawn( npm, - ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'], + [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--config', + 'vite.config.ts', + '--port', + String(endpoint.port), + ], { cwd: appRoot, + env: withAgcDevEndpointEnv(endpoint), stdio: 'inherit', // Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL). shell: true, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 562e9baaf..2bec7f09e 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -5,13 +5,16 @@ import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + agcVitePortEnvKey, + readAgcDevEndpoint, + resolveAgcDevEndpoint, + withAgcDevEndpointEnv, +} from './dev-port.mjs'; + 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 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'; @@ -131,13 +134,13 @@ async function isBackendReady() { ); } -async function readExistingViteServer() { - return httpGetText(viteUrl); +async function readExistingViteServer(endpoint = readAgcDevEndpoint()) { + return httpGetText(endpoint.url); } -function isVitePortListening() { +function isVitePortListening(endpoint = readAgcDevEndpoint()) { return new Promise((resolveRequest) => { - const socket = net.connect({ host: viteHost, port: vitePort }); + const socket = net.connect({ host: endpoint.host, port: endpoint.port }); socket.once('connect', () => { socket.destroy(); resolveRequest(true); @@ -160,8 +163,8 @@ function isAiGameCreatorServer(response) { ); } -async function readExistingViteMarker() { - const response = await httpGetText(viteMarkerUrl, 2000); +async function readExistingViteMarker(endpoint = readAgcDevEndpoint()) { + const response = await httpGetText(endpoint.markerUrl, 2000); if (!response || response.statusCode !== 200) { return null; } @@ -173,15 +176,16 @@ async function readExistingViteMarker() { } async function preflightExistingVite({ + endpoint = readAgcDevEndpoint(), readServer = readExistingViteServer, portListening = isVitePortListening, readMarker = readExistingViteMarker, } = {}) { - const existing = await readServer(); + const existing = await readServer(endpoint); if (!existing) { - if (await portListening()) { + if (await portListening(endpoint)) { throw new Error( - `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + `${endpoint.url} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, ); } return { status: 'available', apiTarget: '' }; @@ -189,11 +193,11 @@ async function preflightExistingVite({ if (!isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + `${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`, ); } - const marker = await readMarker(); + const marker = await readMarker(endpoint); const markerApiTarget = marker?.schemaVersion === 1 && marker?.app === 'ai-game-creator-shell' && @@ -202,7 +206,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.`, + `${endpoint.url} 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.`, ); } @@ -549,7 +553,7 @@ async function ensureBackend({ } } -async function startVite(apiTarget) { +async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { const { apiUrl } = readBackendTargets(); if (apiUrl !== apiTarget) { throw new Error( @@ -557,22 +561,32 @@ async function startVite(apiTarget) { ); } - const existing = await readExistingViteServer(); + const existing = await readExistingViteServer(endpoint); if (existing) { if (isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`, + `${endpoint.url} is already running and cannot be safely reused. Stop it before starting Tauri dev.`, ); } throw new Error( - `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + `${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`, ); } return spawnChild( npm, - ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'], - { cwd: appRoot }, + [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--config', + 'vite.config.ts', + '--port', + String(endpoint.port), + ], + { cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) }, ); } @@ -593,7 +607,9 @@ async function main() { } try { - await preflightExistingVite(); + const endpoint = await resolveAgcDevEndpoint({ strictConfigured: true }); + process.env[agcVitePortEnvKey] = String(endpoint.port); + await preflightExistingVite({ endpoint }); const backend = await ensureBackend({ onBackendChild(child) { backendChild = child; @@ -607,7 +623,7 @@ async function main() { throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); } - viteChild = await startVite(backend.targets.apiUrl); + viteChild = await startVite(backend.targets.apiUrl, endpoint); if (shutdownSignal) { stopChild(viteChild, shutdownSignal); throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`); diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 5c6bba17e..135254457 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -8,6 +8,11 @@ import { terminateChildTree, waitForChildTermination, } from './start-dev-stack.mjs'; +import { + readAgcDevEndpoint, + resolveAgcDevEndpoint, + withAgcDevEndpointEnv, +} from './dev-port.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); @@ -22,17 +27,37 @@ function parseLauncherArguments(argv) { return { gameChat, args }; } -function buildTauriArguments(argv) { +function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const { gameChat, args } = parseLauncherArguments(argv); + const configOverride = JSON.stringify({ build: { devUrl } }); if (gameChat) { - return ['dev', '--', '--', '--game-chat', ...args]; + return [ + 'dev', + '--config', + configOverride, + '--', + '--', + '--game-chat', + ...args, + ]; } - return ['dev', ...args]; + const separatorIndex = args.indexOf('--'); + if (separatorIndex < 0) { + return ['dev', ...args, '--config', configOverride]; + } + return [ + 'dev', + ...args.slice(0, separatorIndex), + '--config', + configOverride, + ...args.slice(separatorIndex), + ]; } -function spawnTauriCli(argv) { +function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, + env, shell: false, }); } @@ -40,16 +65,20 @@ function spawnTauriCli(argv) { async function runTauriDev( argv = process.argv.slice(2), { + resolveDevEndpoint = resolveAgcDevEndpoint, preflight = preflightExistingVite, spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, } = {}, ) { - await preflight(); + const endpoint = await resolveDevEndpoint(); + await preflight({ endpoint }); - const tauriArguments = buildTauriArguments(argv); - const child = spawnCli(tauriArguments); + const tauriArguments = buildTauriArguments(argv, endpoint.url); + const child = spawnCli(tauriArguments, { + env: withAgcDevEndpointEnv(endpoint), + }); let resolveShutdown; let shutdownSignal = ''; let repeatedSignal = false; diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts new file mode 100644 index 000000000..1e2a1009b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + createAgcDevEndpoint, + resolveAgcDevEndpoint, + withAgcDevEndpointEnv, +} from '../scripts/dev-port.mjs'; + +describe('AI 游戏创作 dev 端口', () => { + test('Linux 使用用户端口段的 start + 5 槽位', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const endpoint = await resolveAgcDevEndpoint({ + env: { USER: 'alice', LOGNAME: 'alice' }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort, + }); + + expect(endpoint).toMatchObject({ + port: 10005, + url: 'http://127.0.0.1:10005/', + markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json', + }); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPort: 10005, + portRange: { start: 10000, end: 10099, label: '10000-10099' }, + strict: false, + }), + ); + }); + + test('Linux 槽位被占时接受本用户端口段内的漂移结果', async () => { + const endpoint = await resolveAgcDevEndpoint({ + env: { USER: 'alice', LOGNAME: 'alice' }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort: async ({ preferredPort, portRange }) => { + expect(preferredPort).toBe(10005); + expect(portRange?.label).toBe('10000-10099'); + return 10006; + }, + }); + + expect(endpoint.port).toBe(10006); + }); + + test('父启动器选定端口后子启动器严格使用同一端口', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + await resolveAgcDevEndpoint({ + env: { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_AGC_VITE_PORT: '10007', + }, + platform: 'linux', + strictConfigured: true, + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort, + }); + + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 10007, strict: true }), + ); + }); + + test('旧五端口段无法安全扩容时 AGC 明确失败而不越界', async () => { + await expect( + resolveAgcDevEndpoint({ + env: { USER: 'alice', LOGNAME: 'alice' }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10004, label: '10000-10004' }, + }), + findPort: async () => 10005, + }), + ).rejects.toThrow('缺少 AGC Vite 槽位'); + }); + + test('Linux 显式 AGC 端口不能占用前五个服务槽位', async () => { + await expect( + resolveAgcDevEndpoint({ + env: { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_AGC_VITE_PORT: '10004', + }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort: async () => 10004, + }), + ).rejects.toThrow('前五个服务槽位'); + }); + + test('非 Linux 保留 3080 兼容优先端口并允许统一漂移', async () => { + const findPort = vi.fn(async () => 3081); + const endpoint = await resolveAgcDevEndpoint({ + env: {}, + platform: 'win32', + findPort, + }); + + expect(endpoint.port).toBe(3081); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 3080, portRange: null }), + ); + }); + + test('最终端口通过单一环境变量传给 Tauri 和 Vite 子进程', () => { + expect( + withAgcDevEndpointEnv(createAgcDevEndpoint(10008), { KEEP_ME: 'yes' }), + ).toMatchObject({ + KEEP_ME: 'yes', + GENARRATIVE_AGC_VITE_PORT: '10008', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 07e5d6a4f..4b0cad61f 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -253,7 +253,14 @@ describe('AI 游戏创作启动子进程生命周期', () => { }); }); -describe('AI 游戏创作 3080 启动前预检', () => { +describe('AI 游戏创作动态端口启动前预检', () => { + const endpoint = { + host: '127.0.0.1', + port: 10005, + url: 'http://127.0.0.1:10005/', + markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json', + portRange: { start: 10000, end: 10099, label: '10000-10099' }, + }; const agcHtml = { statusCode: 200, body: 'AI 游戏创作', @@ -262,6 +269,7 @@ describe('AI 游戏创作 3080 启动前预检', () => { test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { await expect( preflightExistingVite({ + endpoint, readServer: async () => agcHtml, portListening: async () => true, readMarker: async () => ({ @@ -278,6 +286,7 @@ describe('AI 游戏创作 3080 启动前预检', () => { test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => { await expect( preflightExistingVite({ + endpoint, readServer: async () => agcHtml, portListening: async () => true, readMarker: async () => ({ @@ -292,15 +301,17 @@ describe('AI 游戏创作 3080 启动前预检', () => { test('HTTP 探测无响应但端口已监听时失败关闭', async () => { await expect( preflightExistingVite({ + endpoint, readServer: async () => null, portListening: async () => true, }), ).rejects.toThrow('non-HTTP or unrecognized server'); }); - test('3080 未监听时允许继续启动', async () => { + test('当前动态端口未监听时允许继续启动', async () => { await expect( preflightExistingVite({ + endpoint, readServer: async () => null, portListening: async () => false, }), diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 86a4ba18f..43cca5548 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -15,6 +15,15 @@ import { runTauriDev, } from '../scripts/start-tauri-dev.mjs'; +const testEndpoint = { + host: '127.0.0.1', + port: 10005, + url: 'http://127.0.0.1:10005/', + markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json', + portRange: { start: 10000, end: 10099, label: '10000-10099' }, +}; +const resolveTestEndpoint = async () => testEndpoint; + async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -28,18 +37,42 @@ async function waitForFile(path: string, timeoutMs = 5000) { describe('AI 游戏创作 Tauri dev 启动参数', () => { test('普通 dev 参数原样交给 Tauri CLI', () => { - expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']); + expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([ + 'dev', + '--no-watch', + '--config', + '{"build":{"devUrl":"http://127.0.0.1:10005/"}}', + ]); + }); + + test('动态 devUrl 配置位于用户 Tauri 配置之后且不越过参数分隔符', () => { + expect( + buildTauriArguments( + ['--config', 'custom.json', '--', '--', '--example-app-arg'], + testEndpoint.url, + ), + ).toEqual([ + 'dev', + '--config', + 'custom.json', + '--config', + '{"build":{"devUrl":"http://127.0.0.1:10005/"}}', + '--', + '--', + '--example-app-arg', + ]); }); test('game-chat 参数进入应用参数区且保留项目参数', () => { expect( - buildTauriArguments([ - '--game-chat', - '--project-path', - '/tmp/example-game', - ]), + buildTauriArguments( + ['--game-chat', '--project-path', '/tmp/example-game'], + testEndpoint.url, + ), ).toEqual([ 'dev', + '--config', + '{"build":{"devUrl":"http://127.0.0.1:10005/"}}', '--', '--', '--game-chat', @@ -50,17 +83,18 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => { }); describe('AI 游戏创作 Tauri dev 生命周期', () => { - test('3080 预检失败时不启动 Tauri CLI', async () => { + test('动态端口预检失败时不启动 Tauri CLI', async () => { const spawnCli = vi.fn(); await expect( runTauriDev([], { + resolveDevEndpoint: resolveTestEndpoint, preflight: async () => { - throw new Error('stale 3080'); + throw new Error('stale AGC Vite'); }, spawnCli, }), - ).rejects.toThrow('stale 3080'); + ).rejects.toThrow('stale AGC Vite'); expect(spawnCli).not.toHaveBeenCalled(); }); @@ -74,6 +108,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { kill: vi.fn(), }); const result = await runTauriDev([], { + resolveDevEndpoint: resolveTestEndpoint, preflight: async () => { order.push('preflight'); }, @@ -132,6 +167,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { let cliChild; try { const result = await runTauriDev([], { + resolveDevEndpoint: resolveTestEndpoint, preflight: async () => {}, spawnCli: () => { cliChild = spawnChild( @@ -187,6 +223,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { let cliChild; try { const result = await runTauriDev([], { + resolveDevEndpoint: resolveTestEndpoint, preflight: async () => {}, spawnCli: () => { cliChild = spawnChild( diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index ea761219d..841af3e2d 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -47,16 +47,23 @@ export default defineConfig({ { name: 'genarrative-ai-game-creator-dev-marker', configureServer(server) { - server.middlewares.use('/__agc_dev_server.json', (_request, response) => { - response.setHeader('Content-Type', 'application/json; charset=utf-8'); - response.end( - JSON.stringify({ - schemaVersion: 1, - app: 'ai-game-creator-shell', - apiTarget, - }), - ); - }); + server.middlewares.use( + '/__agc_dev_server.json', + (_request, response) => { + response.setHeader( + 'Content-Type', + 'application/json; charset=utf-8', + ); + response.end( + JSON.stringify({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + port: server.config.server.port, + apiTarget, + }), + ); + }, + ); }, }, ], diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b41fc1d3d..623089891 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6838,3 +6838,11 @@ - External v1 的二次 allowlist 裁剪同样保留 `prompt / actualPrompt`,契约验收以 `serialize_atomic_editor_generation_job_result` 最终 JSON 为准,不只测上游 builder。图标/UI 正常与 source-only fallback 同时保留 `ok / prompt / actualPrompt`,fallback 的尺寸/model/价格也从本次生成上下文显式携带,不依赖可选 project resource。Editor Agent 图片生成/修改 DTO 允许 compact payload 不携带 `provider`。inline 八类 provider 生成的已成功 billing guard 延迟到 owner handler 完成 durable receipt 提交才 disarm;procedure 发出前的明确失败/取消退款,发出后回包前的传输不确定或取消保留扣款。 - 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。 - 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。 + +## 2026-08-08 AGC Vite 纳入统一用户端口段 + +- 背景:Linux 主开发栈已按用户分配 `100` 端口段,但后加入的 AGC Tauri 壳仍固定监听全机共享的 `3080`。同机任一用户的旧客户端都会阻塞其它用户,且 marker 中出现的动态 API 端口无法解决 Vite 本身的跨用户冲突。 +- 决策:端口段正式增加第六个槽位 `agc-vite = start + 5`,端口段最小长度同步改为 `6`。Linux AGC 首选该槽位并只在当前用户段内漂移;Windows / macOS 保留 `3080` 兼容首选并统一探测漂移,不读取 Linux 系统注册表。 +- 一致性:`start-tauri-dev.mjs` 是端口选择权威,最终端口通过 Tauri CLI `--config` 覆盖 `build.devUrl`,通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,并通过 Vite CLI `--port` 启动严格监听。父启动器选定端口后,子启动器只允许严格使用同一端口,配套后端漂移必须跳过该预留端口,竞态占用必须失败关闭。 +- 安全边界:动态端口不恢复旧 Vite 复用。无法证明 worktree 归属的监听器仍不复用、不主动终止;同用户多 worktree 通过段内漂移并行,不通过共享未知服务并行。 +- 验证:公共端口映射、Linux 默认槽位与段内漂移、非 Linux 兼容漂移、Tauri 动态配置、启动前预检、进程树收束、AGC typecheck / 配置门禁、编码检查和差异检查必须通过。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index d25fd8b3f..6d16908d6 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -334,9 +334,9 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - 定向命令必须实际匹配到 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 ]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 +`npm run agc` 会启动 Tauri 开发客户端;其启动器先解析 AGC Vite 实际端口,再把同一地址动态注入 Tauri `devUrl`,`beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 并严格启动该端口上的 Vite。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path ]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 -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 把第五个服务纳入原有统一端口探测与漂移逻辑。 +Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口。五个 dev 服务依次使用 `start` 到 `start + 4`,AGC Vite 使用 `start + 5`;同用户后续启动复用自己的固定段,AGC 首选槽位被占时只在该段内继续漂移。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段。该机制只在 Linux 生效,Windows 继续使用统一端口探测与漂移逻辑。 本地 `npm run dev`、`npm run dev:spacetime`、`npm run dev:api-server` 和 `npm run dev:bgfilter-worker` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 Rust 服务启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f83854965..7ef524baa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2300,6 +2300,14 @@ - 验证:deploy 工作区应直接出现 `build//web.tar.gz` 与 `web.tar.gz.sha256`;后续仍由 `scripts/deploy/production-web-deploy.sh` 执行 checksum 校验和解压 smoke。 - 关联:`jenkins/Jenkinsfile.production-web-deploy`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## Copy Artifact Production 模式下来源 Job 必须显式授权 + +- 现象:Deploy / Publish / Import 在 `copyArtifacts` 立即报 `Unable to find project for artifact copy: `,但 Jenkins 中的来源 Job、指定构建号和归档产物都存在。 +- 原因:Copy Artifact 已启用推荐的 `Production` 模式,但产物生产者的 Jenkinsfile 没有 `copyArtifactPermission`;插件会把权限不足伪装成“找不到项目”。 +- 处理:在产物生产者的 Declarative Pipeline `options` 内精确授权固定消费者:Stdb Build 授权 Stdb Publish,API Build 授权 API Deploy,Web Build 授权 Web Deploy,Database Export 授权 Database Import。不使用 `*`,不通过全局 `Job/Read` 扩权,不把插件退回 Migration 模式规避。 +- 验证:运行 `npm run check:production-ops`;上线后先运行一次四个产物生产者中本次需要的 Job,确认 live `config.xml` 出现 `CopyArtifactPermissionProperty`,再重跑消费者。 +- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-database-export`、`scripts/check-production-ops-guardrails.mjs`。 + ## Jenkins 生产流水线拉 Git 统一走本机 SSH - 后续更新:2026-07-14 起所有生产 Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 统一使用本机 SSH 地址 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git` 与凭据 `genarrative-local-gitea-ssh`,不再保留局域网 IP、HTTP 内网地址或公网 fallback。 @@ -4233,8 +4241,9 @@ - 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。 - 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。 - 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 +- 2026-08-08 后续统一:上述 `3080` 是事故发生时的历史实现,不再是当前 Linux 启动口径。AGC Vite 已纳入系统级用户端口段,首选 `start + 5`,占用时只在本用户段内漂移;外层启动器把最终端口写入 Tauri CLI 动态 `build.devUrl` 和子进程 `GENARRATIVE_AGC_VITE_PORT`,并用 Vite CLI `--port` 启动严格监听。`beforeDevCommand`、配套后端预留、WebView 与 Vite `strictPort` 必须使用同一值。Windows / macOS 仅把 `3080` 保留为兼容首选并允许统一漂移。未知归属监听器仍不得复用或主动终止,但其它用户固定 `3080` 不再阻塞 Linux 当前用户启动。 - Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc//stat`,只把同 PGID 的非 `Z / X` 成员视为存活;`/proc` 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。 -- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 +- 验证:定向测试必须覆盖用户段 `start + 5` 映射、同段占用漂移、父子启动器严格复用最终端口、动态 Tauri `--config`、marker 与预检地址一致、未知归属监听器拒绝复用、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 - 关联:`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/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。 ## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 2c846bb1a..322461402 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -21,7 +21,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A - 小程序 WebView 外壳:`miniprogram/`。 - 法律文本:`media/files/user_agreement.md`、`media/files/privacy_policy.md`、`media/files/disclaimer.md`。 -桌面端侧边栏的一级入口为 `创作 / 项目 / 我的`;移动端底部 dock 只保留 `我的`。`/creation` 是桌面端独立创作工具主页,`/project` 是桌面端画布项目入口,`/profile` 是桌面端和移动端共用的“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力。移动端直达 `/creation`、`/project` 或 `/editor/canvas`,以及从首页触发项目 / 画布动作时,只显示桌面端创作提示,不挂载对应工具页面。 +桌面端侧边栏的一级入口为 `创作 / 项目 / 我的`;移动端底部 dock 只保留 `我的`,根入口默认展示“我的”并保持该 Tab 选中。移动端每次进入站点壳时先显示原有 IP 欢迎遮罩,明确“移动端仅支持作品展示”,遮罩只能通过“好”按钮关闭,关闭后本次页面生命周期内不再重复,刷新后重新显示。`/creation` 是桌面端独立创作工具主页,`/project` 是桌面端画布项目入口,`/profile` 是桌面端和移动端共用的“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力。移动端直达 `/creation`、`/project` 或 `/editor/canvas`,以及从首页触发项目 / 画布动作时,只显示与移除前一致的桌面端创作主页提示,不挂载对应工具页面;提示页底部继续保留唯一的“我的”Tab 作为返回入口。 ## 当前后端路线 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9a7b6a8e0..7f7dd337f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -77,7 +77,7 @@ ## 2026-08-03 game-chat 开发态同源与持久输出修复 -- 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 +- 开发态启动必须在 Tauri CLI 之前解析并预检 AGC Vite 最终地址。Linux 使用系统级用户端口段的 `start + 5` 槽位并只在本段内漂移,Windows / macOS 以 `3080` 为兼容首选;启动器通过 `GENARRATIVE_AGC_VITE_PORT` 绑定 `beforeDevCommand` 和配套后端预留,通过 Tauri CLI `--config` 绑定 `build.devUrl`,并通过 Vite CLI `--port` 绑定 `strictPort` 监听。任何竞态中已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 - game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能进入 Supervisor 决策前零 child 的单主 lane;Supervisor 持久 intent 后先运行 `code-prototype`,由它按权威审计结果选择复用或暂时委派受限美术,再在同一主 Run 完成接入、静态 smoke、desktop/mobile 试玩、单轮确定性收束和自动预览。若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。 - source-aware lane 的主 Run 或其经授权美术 child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source、持久工作流决策、单主 route 与 child delegation 解析本轮已开放工作,不得从旧七节点图硬编码重启 Director、验证或试玩节点;未授权 child、第二个活跃美术 child,或缺少成功 `asset.list` 审计的美术委派仍严格失败关闭。 - 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream`。`streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。 @@ -591,7 +591,7 @@ game-project/ ## 当前最小落地 - `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。 -- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code` 与 `/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`;Tauri dev 下 `/api` 走固定 3080 Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`。 +- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code` 与 `/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`;Tauri dev 下 `/api` 走本轮动态 AGC Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`。 - `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent`、`--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。 - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 @@ -617,7 +617,7 @@ game-project/ - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 - 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 -- `npm run ai-game-creator-shell:dev` 的 Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`,Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite,若端口被其它服务占用则直接失败并提示释放端口。 +- `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl`、`GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite,最终端口被竞态占用时直接失败。 - AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。 - `start-dev-stack.mjs` 在 POSIX 下以独立进程组托管后端和 Vite,关闭 Tauri 或任一子进程失败时必须收束整组;macOS 不注册仅支持 Windows/Linux 的 api-server 进程指标 observable callback,避免每轮指标采集重复输出平台不支持告警。 - Unix 下 Agent DB、External Runner owner 和 tool-plan handoff 的相对句柄复核必须同时比较设备号、inode 和文件类型;`libc::stat` 的 `st_dev / st_ino` 先按 Rust `MetadataExt` 的 Unix 口径规范为 `u64` 再比较,保持 Linux 和 macOS 的同一安全语义,不得为了通过 macOS 编译而删除路径替换检测。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 66196847f..995951811 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -54,13 +54,13 @@ npm run dev:api-server npm run dev:bgfilter-worker ``` -Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`、`bgfilter-worker = start + 4`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 / 8083 优先端口统一探测并漂移,不读这个系统级注册表。父 API 与 worker 始终使用解析后的实际 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`,不能写死 `8083`。 +Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块命令和 `npm run agc` / `npm run agc:game-chat` 会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`、`bgfilter-worker = start + 4`、`agc-vite = start + 5`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;同用户已有 worktree 占用首选 AGC 槽位时,AGC 只在本用户段内继续漂移。`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 / 8083 与 AGC 兼容优先端口 3080 统一探测并漂移,不读这个系统级注册表。父 API 与 worker 始终使用解析后的实际 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`,不能写死 `8083`。 后端日志默认写入 `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` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 @@ -583,6 +583,8 @@ cat /var/lib/genarrative/health-patrol/status.json 如需接外部告警,可在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_WEBHOOK_URL`;脚本只会在 `WARNING` 或 `CRITICAL` 时向该 webhook 发送 JSON。未配置 webhook 时,告警来源是 systemd 失败状态、journal 和状态文件。 +Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止四条白名单再次丢失。 + `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 875a7a0ea..3a548f4c4 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,7 +12,7 @@ - `/project` 展示当前账号的图片编辑器项目,项目卡继续进入 `/editor/canvas`。 - `/profile` 是“我的”稳定路由,保留头像与昵称编辑、陶泥号复制、泥点余额与账单、累计统计、泥点充值、兑换码、玩家社区、反馈与建议、通用设置、开发者 API Key 和法律信息等平台公共能力。 - 桌面顶栏保留现役项目 / 素材搜索、泥点入口和账号胶囊。搜索只筛选当前编辑器项目与已读取的公开编辑器素材,不恢复旧公开作品号搜索、旧广场、旧作品详情或旧运行态。 -- 桌面端公共侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只保留“我的”,不暴露“创作 / 项目”。移动端直达 `/creation`、`/project` 或 `/editor/canvas` 时显示桌面端创作提示,不挂载创作主页、项目列表或图片画布;从移动端首页触发项目或画布动作时也只显示同一提示。 +- 桌面端公共侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只保留“我的”,不暴露“创作 / 项目”,根入口默认展示“我的”并保持该 Tab 选中。移动端每次进入站点壳时先显示移除前的 IP 欢迎遮罩,明确“移动端仅支持作品展示”;遮罩不允许通过背景或 Escape 关闭,只能点击“好”,关闭后本次页面生命周期内不再重复,刷新后重新显示。移动端直达 `/creation`、`/project` 或 `/editor/canvas` 时显示移除前的桌面端创作主页提示,不挂载创作主页、项目列表或图片画布;提示页底部继续保留唯一的“我的”Tab 作为返回入口。三类工具统一阻断沿用 2026-08-03 的现役产品边界,不回退为旧源码只拦截 `/creation` 的较弱行为。 现役入口和公共资料能力只能依赖 `creation-home`、`project`、`image-editor`、公共组件及 `services/platform-entry` 等现役模块。Vite 模块门禁会拒绝 `components/rpg-entry`、`services/rpg-entry`、旧玩法目录和旧平台业务模块进入依赖图;Tailwind `@source`、TypeScript `include`、ESLint ignore 或 Vite watch ignore 都不能替代这条运行时依赖门禁。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index c3e8d7608..05849573c 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -7,6 +7,7 @@ pipeline { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + copyArtifactPermission('Genarrative-Api-Deploy') } environment { diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index cf2574bd6..442ab0e83 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -5,6 +5,7 @@ pipeline { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + copyArtifactPermission('Genarrative-Database-Import') } environment { diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 6d748c4ef..c326c9cb7 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -7,6 +7,7 @@ pipeline { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + copyArtifactPermission('Genarrative-Stdb-Module-Publish') } environment { diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 9a543a9a6..200483d69 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -7,6 +7,7 @@ pipeline { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + copyArtifactPermission('Genarrative-Web-Deploy') } environment { diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 5c85def1a..05b816928 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -23,6 +23,11 @@ const checks = [ includes: 'cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml', reason: 'API 生产构建必须执行 api-server 全 target 编译检查。', }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: "copyArtifactPermission('Genarrative-Api-Deploy')", + reason: 'Copy Artifact Production 模式下,API Build 必须显式授权 API Deploy 读取归档。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'npm run check:rustfmt', @@ -38,6 +43,12 @@ const checks = [ includes: 'npm run check:admin-account-procedures', reason: 'Stdb module 生产构建必须执行管理员 procedure smoke。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: "copyArtifactPermission('Genarrative-Stdb-Module-Publish')", + reason: + 'Copy Artifact Production 模式下,Stdb Build 必须显式授权 Stdb Publish 读取归档。', + }, { file: 'jenkins/Jenkinsfile.production-web-build', includes: 'npm run check:production-ops', @@ -63,6 +74,17 @@ const checks = [ includes: 'npm run test', reason: 'Web 生产构建必须执行当前维护范围内的 Vitest。', }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: "copyArtifactPermission('Genarrative-Web-Deploy')", + reason: 'Copy Artifact Production 模式下,Web Build 必须显式授权 Web Deploy 读取归档。', + }, + { + file: 'jenkins/Jenkinsfile.production-database-export', + includes: "copyArtifactPermission('Genarrative-Database-Import')", + reason: + 'Copy Artifact Production 模式下,Database Export 必须显式授权 Database Import 读取归档。', + }, { file: 'server-rs/crates/spacetime-module/src/migration.rs', includes: diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs index 37a3f013d..6563314f9 100644 --- a/scripts/dev-stack-port-utils.mjs +++ b/scripts/dev-stack-port-utils.mjs @@ -16,6 +16,8 @@ const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges const LINUX_DEV_PORT_RANGE_POOL_START = 10000; const LINUX_DEV_PORT_RANGE_POOL_END = 39999; const LINUX_DEV_PORT_RANGE_BLOCK_SIZE = 100; +const LEGACY_DEV_PORT_RANGE_MIN_SIZE = 5; +const DEV_PORT_RANGE_MIN_SIZE = 6; function toListenHosts(host) { if (host === '0.0.0.0') { @@ -38,7 +40,7 @@ export function normalizePort(value, fallback) { return port; } -export function parsePortRangeSpec(value) { +function parsePortRangeSpecWithMinimum(value, minimumSize) { const spec = String(value ?? '').trim(); if (!spec) { return null; @@ -55,23 +57,33 @@ export function parsePortRangeSpec(value) { throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`); } - if (end - start + 1 < 5) { - throw new Error(`端口段至少需要 5 个端口: ${spec}`); + if (end - start + 1 < minimumSize) { + throw new Error(`端口段至少需要 ${minimumSize} 个端口: ${spec}`); } return {start, end, label: `${start}-${end}`}; } -function normalizePortRange(portRange) { +export function parsePortRangeSpec(value) { + return parsePortRangeSpecWithMinimum(value, DEV_PORT_RANGE_MIN_SIZE); +} + +function normalizePortRange( + portRange, + {minimumSize = DEV_PORT_RANGE_MIN_SIZE} = {}, +) { if (!portRange) { return null; } if (typeof portRange === 'string') { - return parsePortRangeSpec(portRange); + return parsePortRangeSpecWithMinimum(portRange, minimumSize); } - return parsePortRangeSpec(`${portRange.start}-${portRange.end}`); + return parsePortRangeSpecWithMinimum( + `${portRange.start}-${portRange.end}`, + minimumSize, + ); } export function getLinuxDevPortRangeRegistryPaths(env = process.env) { @@ -108,7 +120,9 @@ export function getLinuxDevPortRangeSpec(env = process.env) { } export function mapDevPortsToPortRange(portRange) { - const normalizedRange = normalizePortRange(portRange); + const normalizedRange = normalizePortRange(portRange, { + minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE, + }); if (!normalizedRange) { return null; } @@ -119,6 +133,10 @@ export function mapDevPortsToPortRange(portRange) { spacetimePort: normalizedRange.start + 2, adminWebPort: normalizedRange.start + 3, bgfilterWorkerPort: normalizedRange.start + 4, + agcVitePort: + normalizedRange.start + 5 <= normalizedRange.end + ? normalizedRange.start + 5 + : null, range: normalizedRange, }; } @@ -252,9 +270,12 @@ function readLinuxPortRangeRegistry(registryPath) { return registry; } -function safeNormalizePortRange(portRange) { +function safeNormalizePortRange( + portRange, + {minimumSize = LEGACY_DEV_PORT_RANGE_MIN_SIZE} = {}, +) { try { - return normalizePortRange(portRange); + return normalizePortRange(portRange, {minimumSize}); } catch { return null; } @@ -264,6 +285,32 @@ function rangesOverlap(left, right) { return left.start <= right.end && right.start <= left.end; } +function tryExpandLegacyPortRange(registry, username, portRange) { + const normalizedRange = safeNormalizePortRange(portRange); + if (!normalizedRange) { + return null; + } + if ( + normalizedRange.end - normalizedRange.start + 1 >= + DEV_PORT_RANGE_MIN_SIZE + ) { + return normalizedRange; + } + + const expandedEnd = normalizedRange.start + DEV_PORT_RANGE_MIN_SIZE - 1; + if (expandedEnd > 65535) { + return null; + } + const expandedRange = { + start: normalizedRange.start, + end: expandedEnd, + label: `${normalizedRange.start}-${expandedEnd}`, + }; + return findRangeConflict(registry, expandedRange, username) + ? null + : expandedRange; +} + function findRangeConflict(registry, portRange, excludingUsername = '') { const rangeToCheck = safeNormalizePortRange(portRange); if (!rangeToCheck) { @@ -380,11 +427,16 @@ export async function reserveLinuxDevPortRange({ const current = registry.allocations[username]; if (current) { + const expandedRange = tryExpandLegacyPortRange( + registry, + username, + current.range, + ); registry.updatedAt = now; registry.allocations[username] = { ...current, username, - range: current.range, + range: expandedRange ?? current.range, updatedAt: now, }; atomicWriteJsonFile(registryPath, registry); @@ -482,7 +534,9 @@ export async function findAvailablePort({ portRange = null, strict = false, }) { - const range = normalizePortRange(portRange); + const range = normalizePortRange(portRange, { + minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE, + }); const startPort = normalizePort(preferredPort, 0); if (startPort === 0 && range) { @@ -563,8 +617,11 @@ async function reserveEphemeralPort(host, reservedPorts) { throw new Error(`无法为 ${host} 分配临时可用端口`); } -export async function resolveDevStackPorts(config) { - const reservedPorts = new Set(); +export async function resolveDevStackPorts( + config, + {reservedPorts: initialReservedPorts = new Set()} = {}, +) { + const reservedPorts = new Set(initialReservedPorts); const entries = [ ['spacetime', config.spacetime], ['api', config.api], diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts index fdaa19642..bdd8f44c4 100644 --- a/scripts/dev-stack-port-utils.test.ts +++ b/scripts/dev-stack-port-utils.test.ts @@ -1,4 +1,4 @@ -import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; import {createServer} from 'node:net'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -47,7 +47,7 @@ async function reserveConsecutivePorts() { } describe('dev stack port utils', () => { - it('解析端口段并映射到五个 dev 端口', () => { + it('解析端口段并映射到五个 dev 服务和 AGC 端口', () => { expect(parsePortRangeSpec('10000-10099')).toEqual({ start: 10000, end: 10099, @@ -59,9 +59,10 @@ describe('dev stack port utils', () => { spacetimePort: 10002, adminWebPort: 10003, bgfilterWorkerPort: 10004, + agcVitePort: 10005, }); - expect(() => parsePortRangeSpec('10000-10003')).toThrow( - '端口段至少需要 5 个端口', + expect(() => parsePortRangeSpec('10000-10004')).toThrow( + '端口段至少需要 6 个端口', ); }); @@ -122,6 +123,21 @@ describe('dev stack port utils', () => { expect(new Set(Object.values(resolvedPorts)).size).toBe(5); }); + it('解析 dev 服务时跳过父启动器预留的 AGC Vite 端口', async () => { + const server = await reservePort(0); + const preferredPort = server.address().port; + await new Promise((resolve) => server.close(resolve)); + + const resolvedPorts = await resolveDevStackPorts( + { + api: {host: '127.0.0.1', preferredPort}, + }, + {reservedPorts: new Set([preferredPort])}, + ); + + expect(resolvedPorts.api).toBeGreaterThan(preferredPort); + }); + it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => { const rangeStart = 10000; const rangeEnd = 10300; @@ -142,6 +158,95 @@ describe('dev stack port utils', () => { const linuxIt = process.platform === 'linux' ? it : it.skip; + linuxIt('Linux 升级时保留旧 v4 五端口记录并阻止重复分配', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); + const registryPath = join(tempRoot, 'registry.json'); + const lockPath = join(tempRoot, 'registry.lock'); + + try { + writeFileSync( + registryPath, + JSON.stringify({ + version: 4, + updatedAt: '2026-08-01T00:00:00.000Z', + allocations: { + alice: { + username: 'alice', + range: {start: 10000, end: 10004, label: '10000-10004'}, + claimedAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + source: 'manual', + }, + }, + }), + ); + + const bobAllocation = await reserveLinuxDevPortRange({ + env: { + USER: 'bob', + LOGNAME: 'bob', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + }, + username: 'bob', + requestedRange: null, + registryPath, + lockPath, + }); + const registry = JSON.parse(readFileSync(registryPath, 'utf8')); + + expect(bobAllocation.range.label).toBe('10100-10199'); + expect(registry.allocations.alice.range.label).toBe('10000-10004'); + expect(registry.allocations.bob.range.label).toBe('10100-10199'); + } finally { + rmSync(tempRoot, {recursive: true, force: true}); + } + }); + + linuxIt('Linux 当前用户的旧五端口记录会在无冲突时扩出 AGC 槽位', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); + const registryPath = join(tempRoot, 'registry.json'); + const lockPath = join(tempRoot, 'registry.lock'); + + try { + writeFileSync( + registryPath, + JSON.stringify({ + version: 4, + updatedAt: '2026-08-01T00:00:00.000Z', + allocations: { + alice: { + username: 'alice', + range: {start: 10000, end: 10004, label: '10000-10004'}, + claimedAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + source: 'manual', + }, + }, + }), + ); + + const allocation = await reserveLinuxDevPortRange({ + env: { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + }, + username: 'alice', + requestedRange: null, + registryPath, + lockPath, + }); + + expect(allocation.range.label).toBe('10000-10005'); + expect( + JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range + .label, + ).toBe('10000-10005'); + } finally { + rmSync(tempRoot, {recursive: true, force: true}); + } + }); + linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => { const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); const registryPath = join(tempRoot, 'registry.json'); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index c02e13f7a..8e0d3bbed 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1447,7 +1447,17 @@ class DevRunner { return; } - const resolvedPorts = await resolveDevStackPorts(portConfig); + const reservedPorts = new Set(); + const agcVitePort = normalizePort( + this.baseEnv.GENARRATIVE_AGC_VITE_PORT, + 0, + ); + if (agcVitePort > 0) { + reservedPorts.add(agcVitePort); + } + const resolvedPorts = await resolveDevStackPorts(portConfig, { + reservedPorts, + }); for (const [name, resolvedPort] of Object.entries(resolvedPorts)) { const config = portConfig[name]; diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 3b1ca3b98..4e8778888 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -187,6 +187,38 @@ describe('dev scheduler argument routing', () => { } }); + linuxTest( + 'Linux 配套后端不会漂移到父启动器预留的 AGC Vite 端口', + async () => { + const tempDir = mkdtempSync( + join(tmpdir(), 'genarrative-dev-port-range-'), + ); + const baseEnv = { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir, + GENARRATIVE_AGC_VITE_PORT: '22002', + }; + try { + const { command, explicitOptions, options } = parseArgs( + ['backend'], + baseEnv, + ); + const runner = new DevRunner(options, baseEnv, explicitOptions); + + await runner.prepareLinuxPortRange(command); + await runner.resolvePorts(command); + + expect(runner.options.spacetimePort).not.toBe(22002); + expect(runner.options.apiPort).not.toBe(22002); + expect(runner.options.bgfilterWorkerPort).not.toBe(22002); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + linuxTest( 'Linux 桌面壳显式指定 web-port 时不被系统级端口段改写', async () => { diff --git a/scripts/vite-retired-css-plugin.test.ts b/scripts/vite-retired-css-plugin.test.ts index cabf5f58f..2bc063111 100644 --- a/scripts/vite-retired-css-plugin.test.ts +++ b/scripts/vite-retired-css-plugin.test.ts @@ -311,5 +311,10 @@ describe('retired creation template asset boundary', () => { isForbiddenPublicAssetPath('/generated-editor-images/asset.png'), ).toBe(true); expect(isForbiddenPublicAssetPath('/creation-home/logo.png')).toBe(false); + expect( + isForbiddenPublicAssetPath( + '/branding/mobile-home-welcome-taonier-ip.png', + ), + ).toBe(false); }); }); diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx new file mode 100644 index 000000000..fcde1abd6 --- /dev/null +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx @@ -0,0 +1,97 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import { + PlatformActiveMobileWelcomeDialog, + shouldOpenActiveMobileWelcomeDialog, + shouldShowActiveMobileWelcomeDialog, +} from './PlatformActiveMobileWelcomeDialog'; + +test('shows the active mobile welcome when entering the mobile site shell', () => { + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + }), + ).toBe(true); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: true, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + }), + ).toBe(false); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'profile', + platformTab: 'profile', + pathname: '/profile', + }), + ).toBe(true); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'creation-home', + platformTab: 'creation-home', + pathname: '/creation', + }), + ).toBe(true); +}); + +test('keeps the active mobile welcome dismissed across site navigation', () => { + expect( + shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + isDismissed: false, + }), + ).toBe(true); + expect( + shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'profile', + platformTab: 'profile', + pathname: '/profile', + isDismissed: true, + }), + ).toBe(false); +}); + +test('renders the restored copy and closes with the acknowledge button', () => { + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy(); + expect( + screen.getByRole('heading', { level: 2, name: '欢迎' }), + ).toBeTruthy(); + expect( + document.querySelector('.platform-mobile-home-welcome-dialog__copy') + ?.textContent, + ).toBe( + '尼嚎,新陶泥er!移动端仅支持作品展示体验创作工具请使用电脑端访问该地址', + ); + expect( + document + .querySelector('.platform-mobile-home-welcome-dialog__icon') + ?.getAttribute('src'), + ).toBe('/branding/mobile-home-welcome-taonier-ip.png'); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx new file mode 100644 index 000000000..16aef64b9 --- /dev/null +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx @@ -0,0 +1,82 @@ +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { UnifiedModal } from '../common/UnifiedModal'; + +type MobileHomeWelcomeDecisionInput = { + isDesktopLayout: boolean; + selectionStage: string; + platformTab: string; + pathname: string; +}; + +type MobileHomeWelcomeOpenInput = MobileHomeWelcomeDecisionInput & { + isDismissed: boolean; +}; + +type PlatformActiveMobileWelcomeDialogProps = { + open: boolean; + platformThemeClass: string; + onClose: () => void; +}; + +export function shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout, +}: MobileHomeWelcomeDecisionInput) { + return !isDesktopLayout; +} + +export function shouldOpenActiveMobileWelcomeDialog({ + isDismissed, + ...decisionInput +}: MobileHomeWelcomeOpenInput) { + return ( + !isDismissed && shouldShowActiveMobileWelcomeDialog(decisionInput) + ); +} + +export function PlatformActiveMobileWelcomeDialog({ + open, + platformThemeClass, + onClose, +}: PlatformActiveMobileWelcomeDialogProps) { + return ( + + 好 + + } + > + +

欢迎

+

+ 尼嚎,新陶泥er! +
+ 移动端仅支持作品展示 +
+ 体验创作工具请使用电脑端访问该地址 +

+
+ ); +} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index eef0b0e1d..1b6e2e872 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -483,6 +483,15 @@ describe('PlatformEntryActiveFlowShell', () => { .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), ).toEqual(['我的']); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBe('page'); + expect( + screen.queryByRole('main', { name: '陶泥儿创作主页' }), + ).toBeNull(); fireEvent.click(within(navigation).getByRole('button', { name: '我的' })); expect(window.location.pathname).toBe('/profile'); @@ -491,6 +500,48 @@ describe('PlatformEntryActiveFlowShell', () => { }); }); + it('restores the original blocking welcome on mobile until acknowledged', async () => { + responsiveMock.isDesktopLayout = false; + const { rerender } = render( + , + ); + + expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy(); + expect( + screen.getByText( + '尼嚎,新陶泥er!移动端仅支持作品展示体验创作工具请使用电脑端访问该地址', + ), + ).toBeTruthy(); + expect( + document + .querySelector('.platform-mobile-home-welcome-dialog__icon') + ?.getAttribute('src'), + ).toBe('/branding/mobile-home-welcome-taonier-ip.png'); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull(); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBe('page'); + + rerender( + , + ); + expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull(); + }); + it.each(['creation-home', 'project', 'image-editor'] as const)( 'shows the desktop guide instead of mounting the %s page on mobile', async (selectionStage) => { @@ -506,25 +557,52 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); + expect(screen.getByText('请在桌面端打开创作主页')).toBeTruthy(); + expect( + screen.queryByRole('main', { name: '陶泥儿创作主页' }), + ).toBeNull(); expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); - expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); + expect( + screen.queryByRole('main', { name: '图片画布编辑器' }), + ).toBeNull(); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + expect( + within(navigation) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['我的']); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBeNull(); }, ); - it('shows the desktop guide instead of the editor after opening a canvas tool from the mobile root', async () => { + it('returns from the mobile tool guide through the only profile tab', async () => { + responsiveMock.isDesktopLayout = false; + render(); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + fireEvent.click(within(navigation).getByRole('button', { name: '我的' })); + + expect(window.location.pathname).toBe('/profile'); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + }); + + it('does not mount creation actions on the mobile root', async () => { responsiveMock.isDesktopLayout = false; render(); - fireEvent.click( - await screen.findByRole('button', { name: '打开音乐工具' }), - ); - - expect( - await screen.findByRole('main', { name: '桌面端创作提示' }), - ).toBeTruthy(); - expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '打开音乐工具' })).toBeNull(); + expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); }); it('switches between creation and projects and passes search to the active page', async () => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 32310a11d..dd9511914 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -30,6 +30,10 @@ import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; +import { + PlatformActiveMobileWelcomeDialog, + shouldOpenActiveMobileWelcomeDialog, +} from './PlatformActiveMobileWelcomeDialog'; import { resolveActivePublicUserCode, resolveActiveUserAvatarLabel, @@ -137,6 +141,30 @@ function ActiveBottomNavButton({ ); } +function MobileProfileDock({ + active, + onOpenProfile, +}: { + active: boolean; + onOpenProfile: () => void; +}) { + return ( +
+ +
+ ); +} + function ActivePlatformBrand() { return (

- 请在桌面端打开创作工具 + 请在桌面端打开创作主页

图片画布、项目管理和素材库需要更大的操作空间。 @@ -224,7 +252,21 @@ export function PlatformEntryFlowShellImpl({ const [activeSearchKeyword, setActiveSearchKeyword] = useState(''); const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] = useState(false); + const [isMobileHomeWelcomeDismissed, setIsMobileHomeWelcomeDismissed] = + useState(false); const isDesktopLayout = usePlatformDesktopLayout(); + const platformThemeClass = + authUi?.platformTheme === 'dark' + ? 'platform-theme--dark' + : 'platform-theme--light'; + const shouldOpenMobileHomeWelcome = shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout, + selectionStage, + platformTab: selectionStage, + pathname: + typeof window === 'undefined' ? '/' : window.location.pathname, + isDismissed: isMobileHomeWelcomeDismissed, + }); const currentWalletOwnerUserId = authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; const walletOwnerUserId = usePlatformWalletStore( @@ -276,7 +318,9 @@ export function PlatformEntryFlowShellImpl({ void refreshDashboard(); }, [refreshDashboard]); - const isProfileStage = selectionStage === 'profile'; + const isProfileStage = + selectionStage === 'profile' || + (!isDesktopLayout && selectionStage === 'platform'); const profileCenter = usePlatformProfileCenterController({ activeTab: isProfileStage ? 'profile' : 'project', @@ -348,15 +392,23 @@ export function PlatformEntryFlowShellImpl({ if ((!isDesktopLayout && isMobileDesktopGuideOpen) || isMobileToolStage) { return ( - { - setIsMobileDesktopGuideOpen(false); - if (selectionStage === 'platform') { - return; - } - setSelectionStage('platform', { path: '/' }); - }} - /> + <> + { + setIsMobileDesktopGuideOpen(false); + if (selectionStage === 'platform') { + return; + } + setSelectionStage('platform', { path: '/' }); + }} + /> + + setIsMobileHomeWelcomeDismissed(true)} + /> + ); } @@ -580,19 +632,10 @@ export function PlatformEntryFlowShellImpl({ -

- -
+ @@ -661,6 +704,11 @@ export function PlatformEntryFlowShellImpl({ orderId={profileCenter.wechatRechargeOrderConfirmationState.orderId} /> ) : null} + setIsMobileHomeWelcomeDismissed(true)} + /> ); } diff --git a/vite.config.ts b/vite.config.ts index 2db56028a..b0d0a6c17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -190,7 +190,6 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none); const RETIRED_PUBLIC_ASSET_PATHS = [ '/audio', '/branding/jump-hop-taonier-character.png', - '/branding/mobile-home-welcome-taonier-ip.png', '/anthro-cat-illustrations', '/bark-battle-assets', '/character', diff --git a/vitest.config.ts b/vitest.config.ts index 25e54e9b9..e6c6c899e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -66,6 +66,7 @@ export default defineConfig({ 'src/components/image-editor/**/*.test.tsx', 'src/components/project/**/*.test.ts', 'src/components/project/**/*.test.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx', 'src/components/platform-entry/PlatformActiveProfileView.test.tsx', 'src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx', 'src/components/platform-entry/PlatformProfileModalShell.test.tsx',