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/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 7919a671d..628a40d29 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6858,23 +6858,29 @@ export function registerProjectSupervisorSurfaceTests() { await driver.submit('生成同 revision 首次验证'); await driver.emitValidation(1, 1000); - await waitFor(() => { - expect( - driver.invoke.mock.calls.filter( - ([command]) => command === 'start_local_game_preview', - ), - ).toHaveLength(1); - expect(driver.readAuthorization()).toBeNull(); - }); + await waitFor( + () => { + expect( + driver.invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + expect(driver.readAuthorization()).toBeNull(); + }, + { timeout: 3000 }, + ); await driver.submit('同一 Run 新授权后重新验证同一 revision'); - await waitFor(() => { - expect(driver.readAuthorization()).toMatchObject({ - afterRevision: 1, - afterValidatedAt: 1000, - authorizationId: expect.any(String), - }); - }); + await waitFor( + () => { + expect(driver.readAuthorization()).toMatchObject({ + afterRevision: 1, + afterValidatedAt: 1000, + authorizationId: expect.any(String), + }); + }, + { timeout: 3000 }, + ); await driver.emitValidation(1, 2000); expect( await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }), @@ -7149,11 +7155,16 @@ export function registerProjectSupervisorSurfaceTests() { }); expect(screen.queryByText('等待新的运行事件')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '运行详情' })); - expect( - within(screen.getByLabelText('最近运行活动')).getByText( - /新受理 Run 正在执行/, - ), - ).not.toBeNull(); + await waitFor( + () => { + expect( + within(screen.getByLabelText('最近运行活动')).getByText( + /新受理 Run 正在执行/, + ), + ).not.toBeNull(); + }, + { timeout: 2500 }, + ); harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( 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 7c72d6c03..506d0e81b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6819,3 +6819,11 @@ - capability 边界:`assetKind` 只表示素材类别,不直接授予“改造”。`scene.generate` 显式进入已知与可改造 action allowlist;V2 上线前的场景仅在 `assetKind === scene` 且 legacy 字段包含“画面内容”时兼容恢复并告警,不把“画面内容”加入全局 legacy 标题路由,其他类别同名字段继续拒绝改造。 - 影响范围:图片画布场景提交、生成输入解码、改造入口、场景 composer 恢复、api-server 场景配方重建与对应前后端测试;不修改 SpacetimeDB schema、migration、bindings、External v1 路由或 OpenAPI。 - 关联文档:`docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + +## 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 460565737..56f97a6b7 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/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 79f28a330..081dc1b1f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -107,7 +107,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 - 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 diff --git a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md index 6b32b7ce5..eca1179c6 100644 --- a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md +++ b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md @@ -38,6 +38,8 @@ - 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。 - 同一用例既要验证定时器调度参数,又要断言确定的中间帧或中间状态时,必须 mock 定时器回调或使用可控假时钟;不得让真实墙上时间在异步交互期间推进被断言的状态,否则本地通过的用例会在较慢 CI 中偶发失败。 - 测试 React effect 中注册的事件监听时,触发事件前先用可观测的 listener 调用确认注册已完成;异步请求已开始应用有界 `waitFor` 断言确认,不要用无界手工 Promise 等待一次性信号。解除挂起请求时将 Promise 收尾纳入异步 `act`,确保后续 React 更新在断言前已冲刷。 +- 公共 `AutoGrowTextArea` 使用 Lexical `contenteditable`,业务测试通过 `setPlainTextEditorValue` 写入文本、通过 `getPlainTextEditorHost` 断言外层样式;占位文案是独立渲染节点,不读取原生 `placeholder` 属性,也不对编辑根触发 textarea 专属的 `change` 事件。 +- 异步请求失败时,错误状态与调用方的草稿 / 附件恢复可能分属连续两次 React 更新;用例必须对最终恢复结果使用有界 `waitFor`,不能把错误文案刚出现的中间帧当成恢复已经完成。 ## 试点调整 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6202c8391..5dc7ebdd6 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 10e9b5a79..d94db65f4 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 为空。 @@ -579,6 +579,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/package-lock.json b/package-lock.json index 0b00be7e4..ea348739a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -15895,6 +15897,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", + "license": "MIT" + }, + "node_modules/overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "license": "MIT", + "peerDependencies": { + "overlayscrollbars": "^2.0.0", + "react": ">=16.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -31502,6 +31520,17 @@ "wcwidth": "^1.0.1" } }, + "overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==" + }, + "overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "requires": {} + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index b26e54986..71533ac35 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", 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/common/AutoGrowTextArea.test-utils.ts b/src/components/common/AutoGrowTextArea.test-utils.ts new file mode 100644 index 000000000..0351fd607 --- /dev/null +++ b/src/components/common/AutoGrowTextArea.test-utils.ts @@ -0,0 +1,47 @@ +import { act } from '@testing-library/react'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + getNearestEditorFromDOMNode, +} from 'lexical'; + +export function setPlainTextEditorValue( + contentEditable: HTMLElement, + value: string, +) { + const editor = getNearestEditorFromDOMNode(contentEditable); + if (!editor) { + throw new Error('Expected a Lexical editor root'); + } + + act(() => { + editor.update( + () => { + const paragraph = $createParagraphNode(); + value.split('\n').forEach((line, index, lines) => { + if (line) { + paragraph.append($createTextNode(line)); + } + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + }); +} + +export function getPlainTextEditorHost(contentEditable: HTMLElement) { + const host = contentEditable.closest( + '.auto-grow-text-area', + ); + if (!host) { + throw new Error('Expected an auto-grow plain-text editor host'); + } + return host; +} diff --git a/src/components/common/AutoGrowTextArea.test.tsx b/src/components/common/AutoGrowTextArea.test.tsx index 04caaa941..30fe8dc85 100644 --- a/src/components/common/AutoGrowTextArea.test.tsx +++ b/src/components/common/AutoGrowTextArea.test.tsx @@ -1,288 +1,175 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AutoGrowTextArea } from './AutoGrowTextArea'; -afterEach(() => { - vi.unstubAllGlobals(); +beforeEach(() => { + Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect()); + Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList); + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + 'ClipboardEvent', + class ClipboardEvent extends Event { + clipboardData: DataTransfer; + + constructor( + type: string, + init: EventInit & { clipboardData: DataTransfer }, + ) { + super(type, init); + this.clipboardData = init.clipboardData; + } + }, + ); }); +function pastePlainText(editor: HTMLElement, value: string) { + const clipboardData = { + files: [], + getData: (type: string) => (type === 'text/plain' ? value : ''), + items: [], + types: ['text/plain'], + } as unknown as DataTransfer; + fireEvent( + editor, + new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); +} + +function ControlledEditor({ + initialValue = '', + maxLength, +}: { + initialValue?: string; + maxLength?: number; +}) { + const [value, setValue] = useState(initialValue); + return ( + <> + + {value} + + + ); +} + describe('AutoGrowTextArea', () => { - it('uses native field sizing when the browser supports it', () => { - const supports = vi.fn().mockReturnValue(true); - const resizeObserver = vi.fn(); - const onChange = vi.fn(); - vi.stubGlobal('CSS', { supports }); - vi.stubGlobal('ResizeObserver', resizeObserver); + it('keeps React state authoritative and preserves exact line breaks', async () => { + const user = userEvent.setup(); + render(); - render( - , + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '第一行\n第二行', ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(supports).toHaveBeenCalledWith('field-sizing', 'content'); - expect(resizeObserver).not.toHaveBeenCalled(); - expect(input.getAttribute('rows')).toBe('1'); - expect(input.className).toContain('auto-grow-text-area'); - expect(input.className).toContain('[field-sizing:content]'); - expect(input.className).toContain('min-h-10'); - expect(input.className).toContain('max-h-32'); - expect(input.className).toContain('overflow-y-auto'); - expect(input.className).toContain('disabled:cursor-not-allowed'); - expect(input.className).toContain('disabled:opacity-60'); - expect(input.style.height).toBe(''); - - fireEvent.change(input, { target: { value: '新内容' } }); - expect(onChange).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: '外部更新' })); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '外部\n更新', + ); }); - it('falls back to measured sizing, preserves css limits and remeasures width changes', () => { - let resizeObserverCallback!: ResizeObserverCallback; - const observe = vi.fn(); - const disconnect = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation((callback: ResizeObserverCallback) => { - resizeObserverCallback = callback; - return { - observe, - unobserve: vi.fn(), - disconnect, - }; - }), + it('publishes edits through onValueChange and enforces maxLength', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '12345'); + + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'), + ); + expect(editor.textContent).toBe('1234'); + }); + + it('supports undo and redo while keeping React state synchronized', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '草稿'); + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'), ); - const { rerender, unmount } = render( + await user.keyboard('{Control>}z{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe(''); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'); + }); + + it('exposes read-only and disabled accessibility without form controls', () => { + const { container } = render( , ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(observe).toHaveBeenCalledWith(input); - let contentHeight = 92; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 82 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - rerender( + const editor = screen.getByRole('textbox', { name: '只读编辑器' }); + expect(editor.getAttribute('aria-disabled')).toBe('true'); + expect(editor.getAttribute('aria-readonly')).toBe('true'); + expect(editor.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('textarea')).toBeNull(); + expect(container.querySelector('form')).toBeNull(); + }); + + it('uses a distinct internal viewport for overflow content', async () => { + const { container } = render( `第 ${index + 1} 行`).join( + '\n', + )} + onValueChange={vi.fn()} />, ); - expect(input.style.height).toBe('94px'); - expect(input.style.overflowY).toBe('hidden'); - contentHeight = 180; - act(() => { - resizeObserverCallback( - [ - { - target: input, - contentRect: { width: 180 }, - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver, + const viewport = await waitFor(() => { + const element = container.querySelector( + '[data-overlayscrollbars-viewport]', ); + expect(element).not.toBeNull(); + return element!; }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 30; - rerender( - , + const content = container.querySelector( + '[data-overlayscrollbars-content]', ); - expect(input.style.height).toBe('80px'); - expect(input.style.overflowY).toBe('hidden'); - unmount(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(viewport).not.toBe(content); + expect( + viewport.contains( + screen.getByRole('textbox', { name: '滚动编辑器' }), + ), + ).toBe(true); }); - it('remeasures uncontrolled input changes and forwards the consumer input handler', () => { - const onInput = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - render( - , - ); - const input = screen.getByLabelText( - '非受控自动增长输入框', - ) as HTMLTextAreaElement; - let contentHeight = 90; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 42 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - fireEvent.input(input, { - target: { value: '第一行\n第二行\n第三行\n第四行' }, - }); - expect(input.style.height).toBe('92px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 180; - fireEvent.input(input, { - target: { value: '很多行内容'.repeat(30) }, - }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 20; - fireEvent.input(input, { target: { value: '短内容' } }); - expect(input.style.height).toBe('40px'); - expect(input.style.overflowY).toBe('hidden'); - expect(onInput).toHaveBeenCalledTimes(3); - }); - - it('measures content-box height without double-counting padding or borders', () => { - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - const { rerender } = render( - , - ); - const input = screen.getByLabelText( - '内容盒输入框', - ) as HTMLTextAreaElement; - let contentHeight = 120; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => 122, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => 120, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - rerender( - , - ); - expect(input.style.height).toBe('104px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 160; - rerender( - , - ); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - }); }); diff --git a/src/components/common/AutoGrowTextArea.tsx b/src/components/common/AutoGrowTextArea.tsx index 282c7c549..d478fc733 100644 --- a/src/components/common/AutoGrowTextArea.tsx +++ b/src/components/common/AutoGrowTextArea.tsx @@ -1,130 +1,443 @@ +import 'overlayscrollbars/styles/overlayscrollbars.css'; + +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { mergeRegister } from '@lexical/utils'; import { - type InputEventHandler, - type TextareaHTMLAttributes, + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + CLEAR_HISTORY_COMMAND, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + COMPOSITION_END_COMMAND, + COMPOSITION_START_COMMAND, + INSERT_LINE_BREAK_COMMAND, + KEY_ENTER_COMMAND, + type LexicalEditor, + PASTE_COMMAND, +} from 'lexical'; +import type { PartialOptions } from 'overlayscrollbars'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { + type CSSProperties, + useCallback, + useEffect, useLayoutEffect, + useMemo, useRef, } from 'react'; -const DEFAULT_MIN_HEIGHT_PX = 40; -const DEFAULT_MAX_HEIGHT_PX = 128; +const CONTROLLED_VALUE_TAG = 'auto-grow-controlled-value'; +const MAX_LENGTH_TAG = 'auto-grow-max-length'; -function supportsNativeFieldSizing() { - return ( - typeof CSS !== 'undefined' && - typeof CSS.supports === 'function' && - CSS.supports('field-sizing', 'content') - ); +const OVERLAY_SCROLLBAR_OPTIONS = { + overflow: { + x: 'hidden', + y: 'scroll', + }, + scrollbars: { + autoHide: 'never', + clickScroll: false, + dragScroll: true, + // 由 OverlayScrollbars 统一绘制,避免 Edge / Firefox 原生滚动条差异。 + theme: 'os-theme-auto-grow-text-area', + visibility: 'auto', + }, +} satisfies PartialOptions; + +export type AutoGrowTextAreaProps = { + value: string; + onValueChange: (value: string) => void; + className?: string; + style?: CSSProperties; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + maxLength?: number; + onPaste?: (event: ClipboardEvent) => void; + onSubmitRequest?: () => void; + 'aria-label': string; + 'aria-describedby'?: string; + 'aria-invalid'?: boolean | 'false' | 'true'; +}; + +function replaceEditorText(value: string, selectEnd: boolean) { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const lines = value.split('\n'); + + lines.forEach((line, index) => { + if (line) { + paragraph.append($createTextNode(line)); + } + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); + + root.clear().append(paragraph); + if (selectEnd) { + paragraph.selectEnd(); + } } -function parseComputedPixelValue(value: string, fallback: number) { - const parsedValue = Number.parseFloat(value); - return Number.isFinite(parsedValue) ? parsedValue : fallback; +function trimToUtf16Length(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + + let trimmed = value.slice(0, maxLength); + const finalCodeUnit = trimmed.charCodeAt(trimmed.length - 1); + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; } -function resizeFallbackTextArea(textarea: HTMLTextAreaElement) { - textarea.style.height = 'auto'; - - const computedStyle = window.getComputedStyle(textarea); - const minHeight = parseComputedPixelValue( - computedStyle.minHeight, - DEFAULT_MIN_HEIGHT_PX, - ); - const maxHeight = Math.max( - minHeight, - parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX), - ); - const contentHeight = textarea.scrollHeight; - const borderHeight = Math.max( - 0, - textarea.offsetHeight - textarea.clientHeight, - ); - const isBorderBox = computedStyle.boxSizing === 'border-box'; - const paddingHeight = - parseComputedPixelValue(computedStyle.paddingTop, 0) + - parseComputedPixelValue(computedStyle.paddingBottom, 0); - const measuredHeight = isBorderBox - ? contentHeight + borderHeight - : Math.max(0, contentHeight - paddingHeight); - const nextHeight = Math.min( - Math.max(measuredHeight, minHeight), - maxHeight, - ); - - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden'; +function readEditorText(editor: LexicalEditor) { + return editor.getEditorState().read(() => $getRoot().getTextContent()); } -export function AutoGrowTextArea({ - className, - onInput, - rows = 1, +function readPixelValue(value: string, fallback: number) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function ControlledPlainTextPlugin({ value, - ...textareaProps -}: TextareaHTMLAttributes) { - const textareaRef = useRef(null); + onValueChange, + disabled, + readOnly, + maxLength, + onPaste, + onSubmitRequest, + onEditorUpdate, +}: Pick< + AutoGrowTextAreaProps, + | 'value' + | 'onValueChange' + | 'disabled' + | 'readOnly' + | 'maxLength' + | 'onPaste' + | 'onSubmitRequest' +> & { + onEditorUpdate: () => void; +}) { + const [editor] = useLexicalComposerContext(); + const controlledValueRef = useRef(value); + const onValueChangeRef = useRef(onValueChange); + const isComposingRef = useRef(false); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + controlledValueRef.current = value; + onValueChangeRef.current = onValueChange; + + useEffect(() => { + editor.setEditable(!disabled && !readOnly); + }, [disabled, editor, readOnly]); + + useEffect(() => { + const currentText = readEditorText(editor); + if (currentText === value) { return; } - resizeFallbackTextArea(textarea); - }, [value]); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + controlledValueRef.current = value; + editor.update(() => replaceEditorText(value, true), { + discrete: true, + tag: CONTROLLED_VALUE_TAG, + }); + editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); + }, [editor, value]); + + useEffect(() => { + let active = true; + + const publishCurrentValue = () => { + if (!active || isComposingRef.current || editor.isComposing()) { + return; + } + + const editorText = readEditorText(editor); + const nextValue = + typeof maxLength === 'number' + ? trimToUtf16Length(editorText, Math.max(0, maxLength)) + : editorText; + + if (nextValue !== editorText) { + editor.update(() => replaceEditorText(nextValue, true), { + discrete: true, + tag: MAX_LENGTH_TAG, + }); + } + + if (nextValue !== controlledValueRef.current) { + controlledValueRef.current = nextValue; + onValueChangeRef.current(nextValue); + } + }; + + return mergeRegister( + editor.registerUpdateListener(({ tags }) => { + onEditorUpdate(); + if ( + tags.has(CONTROLLED_VALUE_TAG) || + tags.has(MAX_LENGTH_TAG) + ) { + return; + } + publishCurrentValue(); + }), + editor.registerCommand( + COMPOSITION_START_COMMAND, + () => { + isComposingRef.current = true; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + COMPOSITION_END_COMMAND, + () => { + isComposingRef.current = false; + queueMicrotask(publishCurrentValue); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + () => { + active = false; + }, + ); + }, [editor, maxLength, onEditorUpdate]); + + useEffect( + () => + mergeRegister( + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event) { + return false; + } + if ( + event.isComposing || + event.keyCode === 229 || + !onSubmitRequest || + event.shiftKey + ) { + return false; + } + + event.preventDefault(); + onSubmitRequest(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!onPaste || !('clipboardData' in event)) { + return false; + } + + onPaste(event as ClipboardEvent); + return event.defaultPrevented; + }, + COMMAND_PRIORITY_HIGH, + ), + ), + [editor, onPaste, onSubmitRequest], + ); + + useEffect(() => { + if (onSubmitRequest) { return undefined; } - let previousWidth = textarea.getBoundingClientRect().width; - const resizeWhenWidthChanges = (nextWidth: number) => { - if (Math.abs(nextWidth - previousWidth) < 0.5) { - return; - } - previousWidth = nextWidth; - resizeFallbackTextArea(textarea); - }; - - if (typeof ResizeObserver !== 'undefined') { - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - resizeWhenWidthChanges(entry.contentRect.width); + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.isComposing || event.keyCode === 229) { + return false; } - }); - observer.observe(textarea); - return () => observer.disconnect(); - } + event.preventDefault(); + return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onSubmitRequest]); - const handleWindowResize = () => { - resizeWhenWidthChanges(textarea.getBoundingClientRect().width); - }; - window.addEventListener('resize', handleWindowResize); - return () => window.removeEventListener('resize', handleWindowResize); - }, []); + return null; +} +export function AutoGrowTextArea({ + value, + onValueChange, + className, + style, + placeholder, + disabled = false, + readOnly = false, + maxLength, + onPaste, + onSubmitRequest, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, +}: AutoGrowTextAreaProps) { + const initialValueRef = useRef(value); + const hostRef = useRef(null); + const viewportRef = useRef(null); + const contentRef = useRef(null); + const contentEditableRef = useRef(null); + const [initializeOverlayScrollbars, overlayScrollbarsInstance] = + useOverlayScrollbars({ options: OVERLAY_SCROLLBAR_OPTIONS }); + const initialConfig = useMemo( + () => ({ + editable: !disabled && !readOnly, + editorState: () => replaceEditorText(initialValueRef.current, false), + namespace: 'AutoGrowTextArea', + onError(error: Error) { + throw error; + }, + }), + // LexicalComposer intentionally reads its initial configuration once. + [disabled, readOnly], + ); const resolvedClassName = [ - 'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60', + 'auto-grow-text-area', className, ] .filter(Boolean) .join(' '); - const handleInput: InputEventHandler = (event) => { - if (!supportsNativeFieldSizing()) { - resizeFallbackTextArea(event.currentTarget); + + const resizeHost = useCallback(() => { + const host = hostRef.current; + const contentEditable = contentEditableRef.current; + if (!host || !contentEditable) { + return; } - onInput?.(event); - }; + + const style = window.getComputedStyle(host); + const minHeight = readPixelValue(style.minHeight, 40); + const maxHeight = Math.max( + minHeight, + readPixelValue(style.maxHeight, 128), + ); + const borderHeight = + readPixelValue(style.borderTopWidth, 0) + + readPixelValue(style.borderBottomWidth, 0); + const nextHeight = Math.min( + Math.max(contentEditable.scrollHeight + borderHeight, minHeight), + maxHeight, + ); + + host.style.height = `${nextHeight}px`; + overlayScrollbarsInstance()?.update(true); + }, [overlayScrollbarsInstance]); + + const scheduleResize = useCallback(() => { + resizeHost(); + }, [resizeHost]); + + useLayoutEffect(() => { + resizeHost(); + const contentEditable = contentEditableRef.current; + if (!contentEditable) { + return undefined; + } + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', resizeHost); + return () => window.removeEventListener('resize', resizeHost); + } + + const observer = new ResizeObserver(resizeHost); + observer.observe(contentEditable); + return () => observer.disconnect(); + }, [resizeHost]); + + useLayoutEffect(() => { + scheduleResize(); + }, [scheduleResize, value]); + + useEffect(() => { + const host = hostRef.current; + const viewport = viewportRef.current; + const content = contentRef.current; + if (!host || !viewport || !content) { + return; + } + + initializeOverlayScrollbars({ + target: host, + elements: { viewport, content }, + }); + resizeHost(); + }, [initializeOverlayScrollbars, resizeHost]); return ( -