Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d0dd2d721 | |||
| 2befaaef9d | |||
| 20b0bf4dd7 | |||
| b4fd8d9b8b | |||
| fe85fa2a62 | |||
| b14a533b17 | |||
| 42969b2218 | |||
| c4ee75a1af | |||
| 6f2137cbb0 | |||
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -113,11 +113,6 @@ const allowedUncalledTauriCommands = [
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||||
// `start_local_project_asset_generation`。
|
||||
'generate_local_project_asset',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
const agcDevHost = '127.0.0.1';
|
||||
const legacyAgcDevPort = 3080;
|
||||
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
|
||||
const agcAdminWebHost = '127.0.0.1';
|
||||
const legacyAgcAdminWebPort = 3102;
|
||||
// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。
|
||||
const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT';
|
||||
|
||||
function readConfiguredAgcDevPort(env = process.env) {
|
||||
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
|
||||
@@ -103,100 +99,13 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredAgcAdminWebPort(env = process.env) {
|
||||
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
|
||||
if (!rawPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = normalizePort(rawPort, -1);
|
||||
if (port < 1024) {
|
||||
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function createAgcAdminWebEndpoint(port, portRange = null) {
|
||||
const origin = `http://${agcAdminWebHost}:${port}`;
|
||||
return {
|
||||
host: agcAdminWebHost,
|
||||
port,
|
||||
origin,
|
||||
basePath: '/admin/',
|
||||
url: `${origin}/admin/`,
|
||||
portRange,
|
||||
};
|
||||
}
|
||||
|
||||
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
|
||||
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
|
||||
async function resolveAgcAdminWebEndpoint({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
strictConfigured = false,
|
||||
reservedPorts = [],
|
||||
reservePortRange = reserveLinuxDevPortRange,
|
||||
findPort = findAvailablePort,
|
||||
} = {}) {
|
||||
const configuredPort = readConfiguredAgcAdminWebPort(env);
|
||||
let portRange = null;
|
||||
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
|
||||
|
||||
if (platform === 'linux') {
|
||||
const allocation = await reservePortRange({ env });
|
||||
if (!allocation?.range) {
|
||||
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
|
||||
}
|
||||
portRange = allocation.range;
|
||||
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
|
||||
if (!Number.isInteger(mappedAdminWebPort)) {
|
||||
throw new Error(
|
||||
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
|
||||
);
|
||||
}
|
||||
preferredPort = configuredPort ?? mappedAdminWebPort;
|
||||
}
|
||||
|
||||
const reservedPortSet = new Set(
|
||||
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
|
||||
);
|
||||
const port = await findPort({
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
portRange,
|
||||
reservedPorts: reservedPortSet,
|
||||
strict: strictConfigured && configuredPort != null,
|
||||
});
|
||||
console.log(
|
||||
formatPortDecision({
|
||||
name: 'ai-game-creator-shell-admin-web',
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
resolvedPort: port,
|
||||
}),
|
||||
);
|
||||
if (portRange) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
|
||||
);
|
||||
}
|
||||
|
||||
return createAgcAdminWebEndpoint(port, portRange);
|
||||
}
|
||||
|
||||
export {
|
||||
agcAdminWebHost,
|
||||
agcAdminWebPortEnvKey,
|
||||
agcDevHost,
|
||||
agcVitePortEnvKey,
|
||||
createAgcAdminWebEndpoint,
|
||||
createAgcDevEndpoint,
|
||||
legacyAgcAdminWebPort,
|
||||
legacyAgcDevPort,
|
||||
readAgcDevEndpoint,
|
||||
readConfiguredAgcAdminWebPort,
|
||||
readConfiguredAgcDevPort,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
};
|
||||
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
import {
|
||||
agcVitePortEnvKey,
|
||||
readAgcDevEndpoint,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||||
const apiServerExePath = resolve(
|
||||
repoRoot,
|
||||
@@ -34,8 +32,6 @@ const backendSpacetimeDataDir = resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
);
|
||||
// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。
|
||||
const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB';
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const childLifecycles = new WeakMap();
|
||||
|
||||
@@ -204,122 +200,36 @@ function urlPort(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走
|
||||
// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒,
|
||||
// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生
|
||||
// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和
|
||||
// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID
|
||||
// 取命令行,并允许调用方把已知命令行传进来复用。
|
||||
const windowsPortOwnerProbeCommand = [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$queriedPorts = @()',
|
||||
'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {',
|
||||
' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }',
|
||||
'}',
|
||||
'$knownCommandLines = @{}',
|
||||
'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {',
|
||||
' try {',
|
||||
' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {',
|
||||
' $knownCommandLines[[int]$property.Name] = [string]$property.Value',
|
||||
' }',
|
||||
' } catch { }',
|
||||
'}',
|
||||
'$listenerPidByPort = @{}',
|
||||
'foreach ($line in (netstat -ano -p tcp)) {',
|
||||
' $fields = @($line -split "\\s+" | Where-Object { $_ })',
|
||||
' if ($fields.Count -lt 4) { continue }',
|
||||
' if ($fields[0] -ne "TCP") { continue }',
|
||||
' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which',
|
||||
' # is locale-independent unlike the localized netstat State column.',
|
||||
' if ($fields[2] -notmatch ":0$") { continue }',
|
||||
' $localPort = [int]($fields[1].Split(":")[-1])',
|
||||
' if ($queriedPorts -notcontains $localPort) { continue }',
|
||||
' # The PID is the last column; do not hardcode its index.',
|
||||
' if ($fields[-1] -notmatch "^\\d+$") { continue }',
|
||||
' $listenerPidByPort[$localPort] = [int]$fields[-1]',
|
||||
'}',
|
||||
'$result = @()',
|
||||
'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {',
|
||||
' $processId = $listenerPidByPort[$port]',
|
||||
' $name = $null',
|
||||
' $executablePath = $null',
|
||||
' $commandLine = $null',
|
||||
' try {',
|
||||
' $process = [System.Diagnostics.Process]::GetProcessById($processId)',
|
||||
' $name = $process.ProcessName + ".exe"',
|
||||
' try { $executablePath = $process.MainModule.FileName } catch { }',
|
||||
' } catch { }',
|
||||
' if ($knownCommandLines.ContainsKey($processId)) {',
|
||||
' $commandLine = $knownCommandLines[$processId]',
|
||||
' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {',
|
||||
' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }',
|
||||
' }',
|
||||
' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }',
|
||||
'}',
|
||||
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||
].join('\n');
|
||||
|
||||
// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存,
|
||||
// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测
|
||||
// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用
|
||||
// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。
|
||||
// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。
|
||||
const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000;
|
||||
const windowsPortOwnerCommandLineCache = new Map();
|
||||
|
||||
function resolveCommandLineCache({ spawnImpl, env }) {
|
||||
return spawnImpl === spawnSync && env === process.env
|
||||
? windowsPortOwnerCommandLineCache
|
||||
: new Map();
|
||||
}
|
||||
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少
|
||||
// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
function readWindowsPortOwnerIdentities(
|
||||
ports,
|
||||
{
|
||||
spawnImpl = spawnSync,
|
||||
env = process.env,
|
||||
now = Date.now,
|
||||
commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS,
|
||||
commandLineCache = resolveCommandLineCache({ spawnImpl, env }),
|
||||
} = {},
|
||||
{ spawnImpl = spawnSync, env = process.env } = {},
|
||||
) {
|
||||
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
|
||||
if (uniquePorts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownCommandLines = {};
|
||||
for (const [processId, record] of [...commandLineCache]) {
|
||||
if (record && now() - record.at < commandLineTtlMs) {
|
||||
knownCommandLines[processId] = record.commandLine;
|
||||
} else {
|
||||
commandLineCache.delete(processId);
|
||||
}
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...env,
|
||||
GENARRATIVE_QUERY_PORTS: uniquePorts.join(','),
|
||||
};
|
||||
if (Object.keys(knownCommandLines).length > 0) {
|
||||
childEnv.GENARRATIVE_KNOWN_COMMAND_LINES =
|
||||
JSON.stringify(knownCommandLines);
|
||||
}
|
||||
const command = [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
|
||||
'$result = @()',
|
||||
'foreach ($port in $ports) {',
|
||||
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
' if (-not $connection) { continue }',
|
||||
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
|
||||
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
|
||||
'}',
|
||||
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnImpl(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPortOwnerProbeCommand,
|
||||
],
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: childEnv,
|
||||
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
@@ -330,22 +240,9 @@ function readWindowsPortOwnerIdentities(
|
||||
const owners = new Map();
|
||||
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
||||
const port = Number(entry?.port);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
continue;
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
owners.set(port, entry);
|
||||
}
|
||||
const processId = Number(entry?.processId);
|
||||
if (
|
||||
Number.isInteger(processId) &&
|
||||
processId > 0 &&
|
||||
typeof entry?.commandLine === 'string' &&
|
||||
entry.commandLine
|
||||
) {
|
||||
commandLineCache.set(processId, {
|
||||
commandLine: entry.commandLine,
|
||||
at: now(),
|
||||
});
|
||||
}
|
||||
owners.set(port, entry);
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
@@ -982,102 +879,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
);
|
||||
}
|
||||
|
||||
function readAdminWebEnabled(env = process.env) {
|
||||
return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0';
|
||||
}
|
||||
|
||||
// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`:
|
||||
// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。
|
||||
function startAdminWeb(
|
||||
apiUrl,
|
||||
endpoint,
|
||||
{ env = process.env, spawnImpl = spawnChild } = {},
|
||||
) {
|
||||
return spawnImpl(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'--host',
|
||||
endpoint.host,
|
||||
'--port',
|
||||
String(endpoint.port),
|
||||
'--strictPort',
|
||||
],
|
||||
{
|
||||
cwd: adminWebDir,
|
||||
env: {
|
||||
...env,
|
||||
ADMIN_API_TARGET: apiUrl,
|
||||
GENARRATIVE_API_TARGET: apiUrl,
|
||||
GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082),
|
||||
ADMIN_WEB_BASE: endpoint.basePath,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function formatStartupSummary({
|
||||
frontendUrl = '',
|
||||
apiUrl = '',
|
||||
adminWebUrl = '',
|
||||
spacetimeUrl = '',
|
||||
bgfilterWorkerUrl = '',
|
||||
} = {}) {
|
||||
const segments = [
|
||||
['前端', frontendUrl],
|
||||
['后端', apiUrl],
|
||||
['后台', adminWebUrl],
|
||||
['数据库', spacetimeUrl],
|
||||
['bgfilter-worker', bgfilterWorkerUrl],
|
||||
]
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([label, value]) => `${label} ${value}`);
|
||||
return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`;
|
||||
}
|
||||
|
||||
// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。
|
||||
async function ensureAdminWeb({
|
||||
apiUrl,
|
||||
reservedPorts = [],
|
||||
env = process.env,
|
||||
enabled = readAdminWebEnabled(env),
|
||||
resolveEndpoint = resolveAgcAdminWebEndpoint,
|
||||
spawnAdminWeb = startAdminWeb,
|
||||
waitForExit = waitForChildTermination,
|
||||
warn = (message) => console.warn(message),
|
||||
} = {}) {
|
||||
if (!enabled) {
|
||||
return { endpoint: null, child: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = await resolveEndpoint({ env, reservedPorts });
|
||||
const child = spawnAdminWeb(apiUrl, endpoint, { env });
|
||||
waitForExit(child).then((failure) => {
|
||||
warn(
|
||||
`[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`,
|
||||
);
|
||||
});
|
||||
return { endpoint, child };
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[ai-game-creator-shell] 后台 Web 未能启动(${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}),AGC 继续运行。`,
|
||||
);
|
||||
return { endpoint: null, child: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let backendChild = null;
|
||||
let startedBackend = false;
|
||||
let viteChild = null;
|
||||
let adminWebChild = null;
|
||||
let shutdownSignal = '';
|
||||
const signalHandlers = new Map();
|
||||
|
||||
@@ -1100,7 +905,6 @@ async function main() {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(adminWebChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||
sweepStartedBackend();
|
||||
@@ -1133,25 +937,6 @@ async function main() {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
|
||||
}
|
||||
|
||||
const adminWeb = await ensureAdminWeb({
|
||||
apiUrl: backend.targets.apiUrl,
|
||||
// AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。
|
||||
reservedPorts: [endpoint.port],
|
||||
});
|
||||
adminWebChild = adminWeb.child;
|
||||
if (shutdownSignal) {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`);
|
||||
}
|
||||
console.log(
|
||||
formatStartupSummary({
|
||||
frontendUrl: endpoint.url,
|
||||
apiUrl: backend.targets.apiUrl,
|
||||
adminWebUrl: adminWeb.endpoint?.url ?? '',
|
||||
spacetimeUrl: backend.targets.spacetimeUrl,
|
||||
bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
const children = [backendChild, viteChild].filter(Boolean);
|
||||
if (children.length === 0) {
|
||||
return 0;
|
||||
@@ -1161,12 +946,10 @@ async function main() {
|
||||
children.map((child) => waitForChildTermination(child)),
|
||||
);
|
||||
stopChild(viteChild);
|
||||
stopChild(adminWebChild);
|
||||
stopChild(backendChild);
|
||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||
} catch (error) {
|
||||
stopChild(viteChild);
|
||||
stopChild(adminWebChild);
|
||||
stopChild(backendChild);
|
||||
console.error(
|
||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -1175,7 +958,6 @@ async function main() {
|
||||
} finally {
|
||||
await Promise.all([
|
||||
terminateChildTree(viteChild),
|
||||
terminateChildTree(adminWebChild),
|
||||
terminateChildTree(backendChild),
|
||||
]);
|
||||
sweepStartedBackend();
|
||||
@@ -1193,12 +975,9 @@ function isDirectModuleExecution() {
|
||||
}
|
||||
|
||||
export {
|
||||
agcDevAdminWebEnvKey,
|
||||
ensureAdminWeb,
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
formatOwnerLabel,
|
||||
formatStartupSummary,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
@@ -1206,7 +985,6 @@ export {
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readAdminWebEnabled,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
@@ -1215,7 +993,6 @@ export {
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
startAdminWeb,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
|
||||
@@ -22,6 +22,7 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const designDebugEnabled =
|
||||
process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1';
|
||||
|
||||
@@ -136,6 +137,7 @@ async function runTauriDev(
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
@@ -189,6 +191,7 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+2
@@ -4900,6 +4900,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,7 +14,7 @@ cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-i
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
@@ -50,7 +50,7 @@ portable-pty = "0.9"
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -2829,8 +2829,6 @@ impl CodexAppServerConnection {
|
||||
callback(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text: streamed_text.clone(),
|
||||
delta_text: delta,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
});
|
||||
}
|
||||
@@ -3135,7 +3133,6 @@ fn parse_game_creator_codex_app_server_text(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some(thread_id.to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -589,7 +589,6 @@ fn parse_game_creator_codex_cli_response(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id,
|
||||
usage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
@@ -133,7 +137,35 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
/// Render the prompt context for a UI design asset.
|
||||
///
|
||||
/// Keep this separate from the generic resource renderer so UI-specific
|
||||
/// instructions/metadata can evolve without changing other asset kinds.
|
||||
fn render_ui_design_reference_line(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset: &GameCreationAppAssetManifestEntry,
|
||||
resource_id: &str,
|
||||
label: &str,
|
||||
local_path: &str,
|
||||
source: &str,
|
||||
) -> String {
|
||||
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
asset_id: resource_id.to_string(),
|
||||
}) {
|
||||
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
|
||||
Err(error) => format!("生成代码遇到错误{error}"),
|
||||
};
|
||||
format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
) + "\n" + &context
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
@@ -149,6 +181,19 @@ fn render_resource_reference_line(
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let is_ui_design =
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
if is_ui_design {
|
||||
return Ok(render_ui_design_reference_line(
|
||||
root,
|
||||
manifest,
|
||||
asset,
|
||||
&resource_id,
|
||||
&label,
|
||||
&local_path,
|
||||
&source,
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
@@ -234,7 +279,7 @@ pub(crate) fn render_direct_codex_references_section(
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
render_resource_reference_line(root, &manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
|
||||
@@ -2020,7 +2020,7 @@ fn direct_taonier_art_asset_identity(
|
||||
local_asset_id: asset.id.clone(),
|
||||
source_sha256: validated.content_sha256,
|
||||
media_type: asset.media_type.clone(),
|
||||
canonical_asset_kind: asset.kind.clone(),
|
||||
canonical_asset_kind: asset.kind.to_string(),
|
||||
resource_id: asset
|
||||
.source
|
||||
.resource_id
|
||||
@@ -2546,7 +2546,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
|
||||
&source_asset.id,
|
||||
&format!("{:x}", Sha256::digest(&source_bytes)),
|
||||
&source_asset.media_type,
|
||||
&source_asset.kind,
|
||||
source_asset.kind.as_str(),
|
||||
)?;
|
||||
let principal = external_editor_binding_principal(&access)?;
|
||||
let Some(project_binding) =
|
||||
|
||||
@@ -1169,7 +1169,7 @@ fn bridge_registered_resource(
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
|
||||
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
|
||||
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
@@ -1828,7 +1828,7 @@ async fn bridge_create_or_derive_resource(
|
||||
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
|
||||
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
|
||||
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
|
||||
producer_task_id: source_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.source.task_id.clone()),
|
||||
@@ -2159,7 +2159,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options),
|
||||
)
|
||||
.await?;
|
||||
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
|
||||
let resources = bridge_art_resources(
|
||||
&state.root,
|
||||
std::slice::from_ref(&generated.asset.local_path),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-96
@@ -1074,102 +1074,6 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生,
|
||||
/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。
|
||||
///
|
||||
/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与
|
||||
/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey`
|
||||
/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时
|
||||
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
|
||||
///
|
||||
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
|
||||
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
|
||||
root: &Path,
|
||||
context: &PlatformArtGenerationRuntimeContext,
|
||||
legacy_run_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if !is_standalone_platform_art_generation_runtime_context(context)
|
||||
|| legacy_run_id == context.run_id
|
||||
|| !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份
|
||||
// 旧账本迁移到新路径,或与新建账本互相覆盖。
|
||||
let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_generate.runtime.claim",
|
||||
)?;
|
||||
if game_creator_agent_runtime_external_generation_exists(
|
||||
root,
|
||||
&context.agent_id,
|
||||
&context.run_id,
|
||||
) {
|
||||
// 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。
|
||||
return Ok(false);
|
||||
}
|
||||
let legacy_relative_path =
|
||||
platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id);
|
||||
let Some(legacy_state) =
|
||||
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
|
||||
root,
|
||||
&legacy_relative_path,
|
||||
"External Editor 生成账本",
|
||||
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if legacy_state.agent_id != context.agent_id
|
||||
|| legacy_state.run_id != legacy_run_id
|
||||
|| legacy_state.action_fingerprint != context.action_fingerprint
|
||||
{
|
||||
// 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。
|
||||
return Ok(false);
|
||||
}
|
||||
let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id);
|
||||
let legacy_context = PlatformArtGenerationRuntimeContext {
|
||||
task_id: legacy_identity.clone(),
|
||||
session_id: legacy_identity.clone(),
|
||||
run_id: legacy_run_id.to_string(),
|
||||
action_id: legacy_identity,
|
||||
..context.clone()
|
||||
};
|
||||
let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
migrated.run_id = context.run_id.clone();
|
||||
migrated.task_id = context.task_id.clone();
|
||||
migrated.session_id = context.session_id.clone();
|
||||
migrated.action_id = context.action_id.clone();
|
||||
migrated.updated_at = unix_timestamp();
|
||||
write_platform_art_generation_runtime_state(root, &migrated)?;
|
||||
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_operation_id_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> Option<&str> {
|
||||
state.operation_id.as_deref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_run_id_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> &str {
|
||||
&state.run_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
) -> &str {
|
||||
&state.action_fingerprint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
||||
root: &Path,
|
||||
|
||||
@@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
|
||||
output.push_str("- ");
|
||||
output.push_str(&asset.id);
|
||||
output.push_str(": ");
|
||||
output.push_str(&asset.kind);
|
||||
output.push_str(asset.kind.as_str());
|
||||
output.push_str(" / ");
|
||||
output.push_str(&asset.media_type);
|
||||
output.push_str(" / ");
|
||||
|
||||
@@ -409,8 +409,6 @@ where
|
||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text,
|
||||
delta_text,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason,
|
||||
});
|
||||
}
|
||||
@@ -501,7 +499,6 @@ mod tests {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "interaction-test".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("interaction-response".to_string()),
|
||||
usage: None,
|
||||
|
||||
-10
@@ -115,7 +115,6 @@ fn persist_tool_plan_handoff_repair_chain(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -147,8 +146,6 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
|
||||
platform_llm::LlmStreamDelta {
|
||||
accumulated_text: accumulated_text.to_string(),
|
||||
delta_text: delta_text.to_string(),
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
@@ -1006,7 +1003,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: private_response.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1120,7 +1116,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1307,7 +1302,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: format!("capacity response {loop_iteration}"),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1442,7 +1436,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1559,7 +1552,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "cleanup handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1631,7 +1623,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "terminal handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1727,7 +1718,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "已成功但尚未消费的回复".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
|
||||
@@ -798,7 +798,6 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
let response = platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "test-model".to_string(),
|
||||
reasoning: String::new(),
|
||||
text: "C:\\private\\response".to_string(),
|
||||
finish_reason: Some("completed".to_string()),
|
||||
response_id: Some("response-1".to_string()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -478,7 +478,7 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
|
||||
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
|
||||
///
|
||||
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
|
||||
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
let extension = Path::new(file_name)
|
||||
.extension()
|
||||
@@ -492,20 +492,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
|
||||
)
|
||||
{
|
||||
"audio"
|
||||
GameCreationAppAssetKind::Audio
|
||||
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
|
||||
"video"
|
||||
GameCreationAppAssetKind::Video
|
||||
} else if media_type.starts_with("font/")
|
||||
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
|
||||
{
|
||||
"document"
|
||||
GameCreationAppAssetKind::Font
|
||||
} else if media_type.starts_with("image/")
|
||||
|| matches!(
|
||||
extension,
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
|
||||
)
|
||||
{
|
||||
"image"
|
||||
GameCreationAppAssetKind::Image
|
||||
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|
||||
|| media_type.contains("javascript")
|
||||
|| media_type.contains("typescript")
|
||||
@@ -514,7 +514,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
|
||||
)
|
||||
{
|
||||
"code"
|
||||
GameCreationAppAssetKind::Code
|
||||
} else if media_type.starts_with("text/")
|
||||
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|
||||
|| matches!(
|
||||
@@ -532,9 +532,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
| "xml"
|
||||
)
|
||||
{
|
||||
"document"
|
||||
GameCreationAppAssetKind::Document
|
||||
} else {
|
||||
"asset"
|
||||
GameCreationAppAssetKind::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ pub(crate) fn upload_local_asset_at(
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
&relative_path,
|
||||
uploaded_asset_kind(file_name, media_type),
|
||||
uploaded_asset_kind(file_name, media_type).as_str(),
|
||||
media_type,
|
||||
"upload",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
|
||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||
}
|
||||
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||
let design_root = root.join("design_artifacts");
|
||||
if !design_root.exists() {
|
||||
return Ok(false);
|
||||
return Ok(0);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut directories = vec![design_root];
|
||||
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut changed = false;
|
||||
let mut registered = 0;
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
Some("yaml" | "yml") => "text/yaml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
||||
register_local_asset_at(
|
||||
root,
|
||||
&relative,
|
||||
"document",
|
||||
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
changed |= asset_changed;
|
||||
registered += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
Ok(registered)
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_asset_at(
|
||||
@@ -1876,22 +1876,13 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let kind = if kind.is_empty() { "asset" } else { kind };
|
||||
let kind = GameCreationAppAssetKind::parse_with_context(
|
||||
if kind.is_empty() { "unknown" } else { kind },
|
||||
"register_local_asset_entry",
|
||||
);
|
||||
let media_type = if media_type.is_empty() {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
@@ -1900,7 +1891,7 @@ fn register_local_asset_entry_with_change(
|
||||
let mut source_for_record = source.clone();
|
||||
source_for_record.prompt = None;
|
||||
|
||||
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
|
||||
let (id, record_type) = mutate_manifest_at(root, |manifest| {
|
||||
if let Some(existing) = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
@@ -1910,16 +1901,13 @@ fn register_local_asset_entry_with_change(
|
||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||
// 时才触发),于是这个资产永远停在错误栏目。
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
existing.kind = kind;
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind.as_str());
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
Ok((existing.id.clone(), "asset.update"))
|
||||
} else {
|
||||
let id = format!(
|
||||
"{id_prefix}-{}-{}",
|
||||
@@ -1928,16 +1916,16 @@ fn register_local_asset_entry_with_change(
|
||||
);
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: id.clone(),
|
||||
kind: kind.to_string(),
|
||||
kind,
|
||||
media_type: media_type.to_string(),
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
category: game_creation_app_asset_category_for_kind(kind.as_str()),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register", true))
|
||||
Ok((id, "asset.register"))
|
||||
}
|
||||
})?;
|
||||
append_agent_db_record(
|
||||
@@ -1946,21 +1934,18 @@ fn register_local_asset_entry_with_change(
|
||||
"recordType": record_type,
|
||||
"assetId": id.clone(),
|
||||
"localPath": normalized_path.clone(),
|
||||
"kind": kind,
|
||||
"kind": kind.as_str(),
|
||||
"mediaType": media_type,
|
||||
"source": source_for_record,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok((
|
||||
UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
},
|
||||
changed,
|
||||
))
|
||||
Ok(UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -2155,27 +2140,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn design_artifact_registration_reports_only_real_manifest_changes() {
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
assert!(register_design_artifacts_at(root).expect("register first time"));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root)
|
||||
.unwrap()
|
||||
.assets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -517,7 +517,11 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
let result = (|| {
|
||||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||||
prepare_game_creator_private_path_for_read(
|
||||
&project_root,
|
||||
true,
|
||||
"自动项目目录",
|
||||
)?;
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
init_local_game_project_at(
|
||||
@@ -2140,7 +2144,10 @@ pub(crate) fn create_ui_design_resource(
|
||||
let next_index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.filter(|asset| {
|
||||
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.count()
|
||||
+ 1;
|
||||
let resource_name = format!("UI 设计 {next_index}");
|
||||
@@ -2174,8 +2181,8 @@ pub(crate) fn create_ui_design_resource(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
"UI",
|
||||
"application/json",
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -4029,7 +4036,7 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: existing.id.clone(),
|
||||
local_path: existing.local_path.clone(),
|
||||
asset_kind: Some(existing.kind.clone()),
|
||||
asset_kind: Some(existing.kind.to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -4227,7 +4234,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: existing.id.clone(),
|
||||
local_path: existing.local_path.clone(),
|
||||
asset_kind: Some(existing.kind.clone()),
|
||||
asset_kind: Some(existing.kind.to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ fn user_selected_path_grants() -> &'static Mutex<HashMap<String, UserSelectedPat
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
||||
let path = normalize_windows_policy_path(path);
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
@@ -43,20 +42,6 @@ fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
||||
let value = path.to_string_lossy();
|
||||
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
|
||||
let Some(key) = normalize_user_selected_path_key(path) else {
|
||||
@@ -853,7 +838,6 @@ pub(crate) fn validate_game_creator_private_path_ancestors(
|
||||
/// separate, explicit user-selected scope below covers native picker/project
|
||||
/// root results, including projects stored outside the current profile.
|
||||
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||
let path = normalize_windows_policy_path(path);
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
@@ -862,10 +846,7 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let starts_with_path = |root: &Path| {
|
||||
let root = normalize_windows_policy_path(root);
|
||||
path == root || path.starts_with(root)
|
||||
};
|
||||
let starts_with_path = |root: &Path| path == root || path.starts_with(root);
|
||||
if game_creator_runtime_config_dir()
|
||||
.as_deref()
|
||||
.is_some_and(starts_with_path)
|
||||
@@ -1075,7 +1056,6 @@ pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result<WindowsAclRe
|
||||
|
||||
#[cfg(windows)]
|
||||
fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope {
|
||||
let path = normalize_windows_policy_path(path);
|
||||
let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root);
|
||||
if let Some(home) = std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
@@ -1319,10 +1299,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
#[cfg(all(windows, test))]
|
||||
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
||||
#[cfg(windows)]
|
||||
// This invocation created the directory: initialize it in
|
||||
// process first, with a narrowly-scoped managed-path fallback
|
||||
// only if Windows rejects that local ACL update.
|
||||
harden_new_game_creator_private_path(&directory, true, label)?;
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -1361,7 +1346,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
fs::create_dir(&directory).map_err(|retry_error| {
|
||||
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
||||
})?;
|
||||
harden_new_game_creator_private_path(&directory, true, label)?;
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
@@ -1426,34 +1419,15 @@ pub(crate) fn harden_new_game_creator_private_path(
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
// This invocation created the object, so local hardening is always
|
||||
// the first path. Some Windows configurations can nevertheless
|
||||
// reject the descriptor update (for example when an inherited ACL is
|
||||
// protected by the parent). Only a managed path may use the existing
|
||||
// one-shot repair in that exceptional case; ordinary new projects do
|
||||
// not prompt for UAC.
|
||||
if let Err(local_error) =
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path,
|
||||
is_directory,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
{
|
||||
if !game_creator_private_path_allows_auto_elevation(path)
|
||||
|| !windows_acl_error_may_need_elevation(&local_error)
|
||||
{
|
||||
return Err(local_error);
|
||||
}
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
path,
|
||||
is_directory,
|
||||
true,
|
||||
)
|
||||
.map_err(|repair_error| {
|
||||
format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}")
|
||||
})?;
|
||||
}
|
||||
// This invocation created the object, so its owner is the current
|
||||
// user. Tighten the inherited descriptor in-process; UAC repair is
|
||||
// reserved for existing, externally-owned objects.
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path,
|
||||
is_directory,
|
||||
true,
|
||||
true,
|
||||
)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -4532,23 +4506,6 @@ mod private_path_elevation_policy_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
|
||||
let root = std::env::var_os("LOCALAPPDATA")
|
||||
.or_else(|| std::env::var_os("APPDATA"))
|
||||
.map(PathBuf::from)
|
||||
.expect("local appdata");
|
||||
let packaged = root.join("world.genarrative.ai-game-creator");
|
||||
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
|
||||
|
||||
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
|
||||
assert_eq!(
|
||||
game_creator_runtime_config_repair_scope(&verbatim),
|
||||
WindowsAclRepairScope::Managed
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
|
||||
|
||||
@@ -905,7 +905,6 @@ mod tests {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "context-compaction-test".to_string(),
|
||||
text: summary.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("context-compaction-response".to_string()),
|
||||
usage: Some(platform_llm::LlmTokenUsage {
|
||||
|
||||
@@ -26,12 +26,6 @@ pub(crate) struct LocalProjectImagePreview {
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) pixel_width: u32,
|
||||
pub(crate) pixel_height: u32,
|
||||
/// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。
|
||||
///
|
||||
/// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」,
|
||||
/// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图
|
||||
/// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。
|
||||
pub(crate) has_alpha: bool,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
@@ -108,17 +102,12 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
false,
|
||||
)?;
|
||||
cancellation.check()?;
|
||||
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
|
||||
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
|
||||
// 的不透明图都不会因此变慢。
|
||||
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
|
||||
Ok(LocalProjectImagePreview {
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
pixel_width: image.pixel_width,
|
||||
pixel_height: image.pixel_height,
|
||||
has_alpha,
|
||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||
})
|
||||
}
|
||||
@@ -431,84 +420,6 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
|
||||
}
|
||||
}
|
||||
|
||||
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
|
||||
/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
|
||||
///
|
||||
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
|
||||
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
|
||||
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
|
||||
///
|
||||
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
|
||||
/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
|
||||
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`;
|
||||
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
|
||||
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
|
||||
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
|
||||
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
|
||||
match media_type {
|
||||
"image/png" => detect_png_has_alpha(bytes),
|
||||
"image/webp" => detect_webp_has_alpha(bytes),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
|
||||
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
|
||||
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
|
||||
return false;
|
||||
}
|
||||
if matches!(bytes[25], 4 | 6) {
|
||||
return true;
|
||||
}
|
||||
png_has_transparency_chunk(bytes)
|
||||
}
|
||||
|
||||
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
|
||||
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
|
||||
let mut offset = 8usize;
|
||||
loop {
|
||||
let Some(header_end) = offset.checked_add(8) else {
|
||||
return false;
|
||||
};
|
||||
if header_end > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let chunk_type = &bytes[offset + 4..header_end];
|
||||
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
|
||||
if chunk_type == b"tRNS" {
|
||||
return true;
|
||||
}
|
||||
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
|
||||
return false;
|
||||
}
|
||||
let chunk_len =
|
||||
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
|
||||
let Some(next) = header_end
|
||||
.checked_add(chunk_len)
|
||||
.and_then(|value| value.checked_add(4))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if next <= offset || next > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
offset = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
|
||||
return false;
|
||||
}
|
||||
match &bytes[12..16] {
|
||||
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
|
||||
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
|
||||
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
|
||||
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TiffByteOrder {
|
||||
LittleEndian,
|
||||
@@ -834,74 +745,6 @@ mod tests {
|
||||
.expect("valid 1x1 png")
|
||||
}
|
||||
|
||||
/// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。
|
||||
fn png_header(color_type: u8) -> Vec<u8> {
|
||||
png_header_with_size(color_type, 1, 1)
|
||||
}
|
||||
|
||||
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&width.to_be_bytes());
|
||||
ihdr.extend_from_slice(&height.to_be_bytes());
|
||||
ihdr.push(8);
|
||||
ihdr.push(color_type);
|
||||
ihdr.extend_from_slice(&[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IHDR", &ihdr);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。
|
||||
/// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明
|
||||
/// 判据没有解码像素。
|
||||
fn push_png_chunk(bytes: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
bytes.extend_from_slice(
|
||||
&u32::try_from(data.len())
|
||||
.expect("chunk length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
bytes.extend_from_slice(kind);
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。
|
||||
fn webp_vp8x(flags: u8) -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8X");
|
||||
bytes.extend_from_slice(&10_u32.to_le_bytes());
|
||||
bytes.push(flags);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes.extend_from_slice(&[0, 0, 0]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。
|
||||
fn webp_vp8l(has_alpha: bool) -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8L");
|
||||
bytes.extend_from_slice(&5_u32.to_le_bytes());
|
||||
bytes.push(0x2f);
|
||||
bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走
|
||||
/// `VP8X` 扩展格式(+ `ALPH` chunk)。
|
||||
fn webp_vp8_simple() -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"WEBP");
|
||||
bytes.extend_from_slice(b"VP8 ");
|
||||
bytes.extend_from_slice(&0_u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
if let Some(payload) = app1_payload {
|
||||
@@ -997,138 +840,6 @@ mod tests {
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
assert_eq!(preview.byte_len, png_bytes().len() as u64);
|
||||
assert!(preview.data_url.starts_with("data:image/png;base64,"));
|
||||
// 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」——
|
||||
// 资源卡据此才铺棋盘格底。
|
||||
assert!(preview.has_alpha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_alpha_follows_color_type_and_transparency_chunk() {
|
||||
let color_type_alpha = |color_type: u8| {
|
||||
let mut bytes = png_header(color_type);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
detect_raster_image_has_alpha(&bytes, "image/png")
|
||||
};
|
||||
|
||||
// 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。
|
||||
assert!(color_type_alpha(4), "colorType 4 应判为有 alpha");
|
||||
assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha");
|
||||
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
|
||||
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
|
||||
assert!(
|
||||
!color_type_alpha(2),
|
||||
"PNG-24(colorType 2)不应判为有 alpha"
|
||||
);
|
||||
assert!(
|
||||
!color_type_alpha(3),
|
||||
"colorType 3 无 tRNS 时不应判为有 alpha"
|
||||
);
|
||||
// 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。
|
||||
assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha");
|
||||
|
||||
// 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。
|
||||
for color_type in [0_u8, 2, 3] {
|
||||
let mut bytes = png_header(color_type);
|
||||
push_png_chunk(&mut bytes, b"tRNS", &[0]);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
assert!(
|
||||
detect_raster_image_has_alpha(&bytes, "image/png"),
|
||||
"colorType {color_type} + tRNS 也是真透明 PNG"
|
||||
);
|
||||
}
|
||||
|
||||
// tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。
|
||||
let mut late_trns = png_header(3);
|
||||
push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]);
|
||||
push_png_chunk(&mut late_trns, b"tRNS", &[0]);
|
||||
push_png_chunk(&mut late_trns, b"IEND", &[]);
|
||||
assert!(!detect_raster_image_has_alpha(&late_trns, "image/png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_and_webp_alpha_follow_container_flags() {
|
||||
// JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&jpeg_bytes(40, 20, None),
|
||||
"image/jpeg"
|
||||
));
|
||||
// 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。
|
||||
assert!(detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x10),
|
||||
"image/webp"
|
||||
));
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x00),
|
||||
"image/webp"
|
||||
));
|
||||
// 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8x(0x28),
|
||||
"image/webp"
|
||||
));
|
||||
// 无损 VP8L 的 alpha_is_used 位。
|
||||
assert!(detect_raster_image_has_alpha(
|
||||
&webp_vp8l(true),
|
||||
"image/webp"
|
||||
));
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8l(false),
|
||||
"image/webp"
|
||||
));
|
||||
// 简单有损格式不带 alpha 通道。
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&webp_vp8_simple(),
|
||||
"image/webp"
|
||||
));
|
||||
|
||||
// 头部被截断时失败关闭为「不透明」,且不得 panic。
|
||||
let truncated_webp = webp_vp8x(0x10);
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&truncated_webp[..18],
|
||||
"image/webp"
|
||||
));
|
||||
let truncated_png = png_header(6);
|
||||
assert!(!detect_raster_image_has_alpha(
|
||||
&truncated_png[..20],
|
||||
"image/png"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_judgement_never_decodes_pixels() {
|
||||
// 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法
|
||||
// deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功,
|
||||
// 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
let mut bytes = png_header_with_size(6, 4_096, 4_096);
|
||||
push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]);
|
||||
push_png_chunk(&mut bytes, b"IEND", &[]);
|
||||
fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image");
|
||||
|
||||
let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png")
|
||||
.expect("header-only preview");
|
||||
|
||||
assert_eq!(preview.pixel_width, 4_096);
|
||||
assert_eq!(preview.byte_len, bytes.len() as u64);
|
||||
assert!(preview.has_alpha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_preview_serializes_alpha_flag_for_the_shell() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
|
||||
|
||||
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
|
||||
.expect("load project preview");
|
||||
|
||||
// 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`);
|
||||
// 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。
|
||||
let serialized = serde_json::to_value(&preview).expect("serialize preview");
|
||||
assert_eq!(serialized["hasAlpha"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,8 +33,8 @@ use shared_contracts::game_creation_app::{
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
|
||||
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||||
@@ -244,7 +244,6 @@ macro_rules! app_log {
|
||||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||||
mod agent;
|
||||
mod agent_native_tools;
|
||||
mod asset_generation_tasks;
|
||||
mod assets;
|
||||
mod browser;
|
||||
mod builtin_plugins;
|
||||
@@ -290,7 +289,6 @@ mod windows;
|
||||
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
use assets::*;
|
||||
use browser::*;
|
||||
use cli::*;
|
||||
@@ -2672,7 +2670,6 @@ fn main() {
|
||||
hydrate_design_agent_session,
|
||||
reset_design_agent_session,
|
||||
get_design_agent_runtime_mode,
|
||||
is_design_agent_debug_enabled,
|
||||
set_design_agent_runtime_mode,
|
||||
debug_fast_forward_design_session,
|
||||
continue_design_agent_session,
|
||||
@@ -2739,8 +2736,6 @@ fn main() {
|
||||
ensure_ui_design_resource_for_prototype,
|
||||
generate_platform_art_asset,
|
||||
generate_local_project_asset,
|
||||
start_local_project_asset_generation,
|
||||
list_local_project_asset_generations,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
get_limited_local_commands,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user