Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a439b2cb34 | |||
| 1877aea33c | |||
| 2bdee990a2 | |||
| 330e4d6c5b | |||
| aeac6c7b74 | |||
| 2292ae1fc1 | |||
| b077cb5277 | |||
| 8ad0487b8d | |||
| 417b5ec630 | |||
| 5adc16fbda | |||
| 6c98c19c12 | |||
| 41c59a37a0 | |||
| a6de1b5570 | |||
| 444bf9bc24 | |||
| 21c85c4dd1 | |||
| a45172e055 | |||
| e6d92b2d98 | |||
| ec02c39a5d | |||
| 14a0286f70 | |||
| 2f47540eeb | |||
| a2b016bb9b | |||
| b898590c7d | |||
| 4e277798b8 | |||
| 928a0ea13e | |||
| cdc0e3d163 | |||
| 8ac77cb090 | |||
| 7240b3793f | |||
| 4fff733516 | |||
| 5d336bf147 | |||
| 3d6d51737c | |||
| 1de661df10 | |||
| 6b5f67d207 | |||
| 74b3760d0f | |||
| 53026fe8f3 | |||
| bb7cbae118 | |||
| 0b3bf3e52d | |||
| 6d5cd51649 | |||
| 181e364d80 | |||
| 3b8ecb02d4 | |||
| d02d0ed7a4 | |||
| ef3e783ab1 | |||
| 7ce4a3a9c6 |
@@ -113,6 +113,11 @@ 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,6 +9,10 @@ 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();
|
||||
@@ -99,13 +103,100 @@ 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,12 +14,14 @@ 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,
|
||||
@@ -32,6 +34,8 @@ 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();
|
||||
|
||||
@@ -200,36 +204,122 @@ function urlPort(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
// 端口归属探测脚本。历史实现用 `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),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
function readWindowsPortOwnerIdentities(
|
||||
ports,
|
||||
{ spawnImpl = spawnSync, env = process.env } = {},
|
||||
{
|
||||
spawnImpl = spawnSync,
|
||||
env = process.env,
|
||||
now = Date.now,
|
||||
commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS,
|
||||
commandLineCache = resolveCommandLineCache({ spawnImpl, env }),
|
||||
} = {},
|
||||
) {
|
||||
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
|
||||
if (uniquePorts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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 result = spawnImpl(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPortOwnerProbeCommand,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||
env: childEnv,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
@@ -240,9 +330,22 @@ function readWindowsPortOwnerIdentities(
|
||||
const owners = new Map();
|
||||
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
||||
const port = Number(entry?.port);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
owners.set(port, entry);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -879,10 +982,102 @@ 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();
|
||||
|
||||
@@ -905,6 +1100,7 @@ async function main() {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(adminWebChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||
sweepStartedBackend();
|
||||
@@ -937,6 +1133,25 @@ 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;
|
||||
@@ -946,10 +1161,12 @@ 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)}`,
|
||||
@@ -958,6 +1175,7 @@ async function main() {
|
||||
} finally {
|
||||
await Promise.all([
|
||||
terminateChildTree(viteChild),
|
||||
terminateChildTree(adminWebChild),
|
||||
terminateChildTree(backendChild),
|
||||
]);
|
||||
sweepStartedBackend();
|
||||
@@ -975,9 +1193,12 @@ function isDirectModuleExecution() {
|
||||
}
|
||||
|
||||
export {
|
||||
agcDevAdminWebEnvKey,
|
||||
ensureAdminWeb,
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
formatOwnerLabel,
|
||||
formatStartupSummary,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
@@ -985,6 +1206,7 @@ export {
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readAdminWebEnabled,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
@@ -993,6 +1215,7 @@ export {
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
startAdminWeb,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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;
|
||||
@@ -137,35 +133,7 @@ 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> {
|
||||
@@ -181,19 +149,6 @@ 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
|
||||
@@ -279,7 +234,7 @@ pub(crate) fn render_direct_codex_references_section(
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(root, &manifest, reference)?
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+96
@@ -1074,6 +1074,102 @@ 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2144,10 +2144,7 @@ pub(crate) fn create_ui_design_resource(
|
||||
let next_index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.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
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count()
|
||||
+ 1;
|
||||
let resource_name = format!("UI 设计 {next_index}");
|
||||
@@ -2181,8 +2178,8 @@ pub(crate) fn create_ui_design_resource(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
|
||||
@@ -27,6 +27,7 @@ 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()
|
||||
@@ -42,6 +43,20 @@ 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 {
|
||||
@@ -838,6 +853,7 @@ 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()
|
||||
@@ -846,7 +862,10 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let starts_with_path = |root: &Path| path == root || path.starts_with(root);
|
||||
let starts_with_path = |root: &Path| {
|
||||
let root = normalize_windows_policy_path(root);
|
||||
path == root || path.starts_with(root)
|
||||
};
|
||||
if game_creator_runtime_config_dir()
|
||||
.as_deref()
|
||||
.is_some_and(starts_with_path)
|
||||
@@ -1056,6 +1075,7 @@ 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"))
|
||||
@@ -4506,6 +4526,23 @@ 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() {
|
||||
|
||||
@@ -26,6 +26,12 @@ 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,
|
||||
}
|
||||
|
||||
@@ -102,12 +108,17 @@ 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)?,
|
||||
})
|
||||
}
|
||||
@@ -420,6 +431,84 @@ 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,
|
||||
@@ -745,6 +834,74 @@ 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 {
|
||||
@@ -840,6 +997,138 @@ 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]
|
||||
|
||||
@@ -244,6 +244,7 @@ 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;
|
||||
@@ -289,6 +290,7 @@ mod windows;
|
||||
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
use assets::*;
|
||||
use browser::*;
|
||||
use cli::*;
|
||||
@@ -2736,6 +2738,8 @@ 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,
|
||||
|
||||
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
let registered = register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2063,10 +2063,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("manifest json");
|
||||
assert_eq!(
|
||||
manifest["assets"][0]["kind"],
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||
);
|
||||
assert_eq!(manifest["assets"][0]["kind"], "UI");
|
||||
assert_eq!(manifest["assets"][0]["category"], "audio");
|
||||
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
|
||||
|
||||
@@ -2077,7 +2074,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
///
|
||||
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
|
||||
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
|
||||
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量,
|
||||
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`,
|
||||
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
|
||||
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
|
||||
#[test]
|
||||
@@ -2102,8 +2099,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2136,11 +2133,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
||||
assert_eq!(
|
||||
categories,
|
||||
vec![
|
||||
(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||
.to_string(),
|
||||
"ui-interaction".to_string(),
|
||||
),
|
||||
("UI".to_string(), "ui-interaction".to_string()),
|
||||
("font".to_string(), "document".to_string()),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19,10 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
|
||||
pub(crate) use shared_contracts::game_creation_app::{
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
|
||||
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
|
||||
@@ -325,7 +321,7 @@ fn ui_design_asset(
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
}
|
||||
normalize_relative_path(&asset.local_path)?;
|
||||
@@ -819,8 +815,8 @@ mod tests {
|
||||
let asset = register_local_asset_at(
|
||||
directory.path(),
|
||||
relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"test",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use crate::ui_editor::persistence::{
|
||||
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
|
||||
use crate::{
|
||||
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
|
||||
enforce_project_permission_policy, read_existing_manifest_for_project,
|
||||
@@ -92,8 +89,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
|
||||
}
|
||||
|
||||
if let Some(asset) = manifest.assets.iter().find(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
asset.kind == "UI"
|
||||
&& asset.media_type == "application/json"
|
||||
&& asset.source.reference_resource_ids.iter().any(|reference| {
|
||||
source_reference_ids
|
||||
.iter()
|
||||
@@ -121,8 +118,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -196,9 +193,7 @@ fn next_ui_design_path(
|
||||
let mut index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count()
|
||||
+ 1;
|
||||
loop {
|
||||
@@ -286,9 +281,11 @@ mod tests {
|
||||
};
|
||||
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
|
||||
assert!(first.created);
|
||||
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
|
||||
assert_eq!(first.asset.kind, "UI");
|
||||
// 写侧 → 分类的端到端断言:这条路径走的是与
|
||||
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。
|
||||
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`。
|
||||
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
|
||||
// 且派生值本身就是 unclassified,读时自愈也救不回来。
|
||||
assert_eq!(
|
||||
first.asset.category,
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
@@ -325,10 +322,7 @@ mod tests {
|
||||
.manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
|
||||
use crate::ui_editor::persistence::{
|
||||
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
|
||||
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
|
||||
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
|
||||
@@ -684,8 +683,8 @@ fn find_page_ui_resource(
|
||||
let Some(asset) = matches.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|
||||
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
|
||||
if asset.kind != "UI"
|
||||
|| asset.media_type != "application/json"
|
||||
|| asset.local_path != workflow_relative_path(source, &page.page_id)
|
||||
{
|
||||
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
|
||||
@@ -749,8 +748,8 @@ fn ensure_page_ui_resource(
|
||||
let registered = register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -1180,7 +1179,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
|
||||
}
|
||||
let next_kind = format!("ui-workflow.{stage}");
|
||||
|
||||
+1
-5
@@ -53,7 +53,6 @@ import { createPortal, flushSync } from 'react-dom';
|
||||
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import {
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
gameCreationAppAssetTags,
|
||||
type GameIterationVersion,
|
||||
@@ -1254,10 +1253,7 @@ function ResourcePickerThumbnail({
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
|
||||
}
|
||||
if (
|
||||
kind === 'ui' ||
|
||||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
|
||||
) {
|
||||
if (kind === 'ui' || mediaType === 'application/json') {
|
||||
return <FileText size={18} aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon size={18} aria-hidden="true" />;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。
|
||||
*
|
||||
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
|
||||
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
|
||||
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
|
||||
*
|
||||
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
|
||||
* 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
|
||||
*/
|
||||
.game-resource-type-dialog {
|
||||
width: min(480px, 100%);
|
||||
max-height: min(720px, calc(100dvh - 40px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/*
|
||||
* body 分三段:提示 / 选项列表 / 错误提示。
|
||||
*
|
||||
* `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表
|
||||
* (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。
|
||||
*/
|
||||
.game-resource-type-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。
|
||||
*/
|
||||
.game-resource-type-hint {
|
||||
margin: 0;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 纵向单选列表(`role="radiogroup"`):**一行一个选项**。
|
||||
*
|
||||
* 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。
|
||||
* 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行,
|
||||
* 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`),
|
||||
* 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。
|
||||
*/
|
||||
.game-resource-type-options {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-auto-flow: row;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
max-height: min(320px, 40dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/*
|
||||
* 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left /
|
||||
* 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。
|
||||
*
|
||||
* `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽
|
||||
* 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。
|
||||
* `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。
|
||||
*/
|
||||
.game-resource-type-option {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: rgb(255 255 255 / 62%);
|
||||
color: var(--platform-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-resource-type-option:hover:not(:disabled) {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。
|
||||
*
|
||||
* 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)`
|
||||
* 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`((0,2,0))会在悬停时
|
||||
* 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。
|
||||
*/
|
||||
.game-resource-type-options .game-resource-type-option[aria-checked='true'],
|
||||
.game-resource-type-options
|
||||
.game-resource-type-option[aria-checked='true']:hover:not(:disabled) {
|
||||
border-color: var(--platform-warm-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-resource-type-error {
|
||||
margin: 0;
|
||||
color: #b3261e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 第二入口:信息浮层「分类」行右侧的入口按钮。
|
||||
*
|
||||
* 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用,
|
||||
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
|
||||
*/
|
||||
.game-resource-info-field-action {
|
||||
align-self: start;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-info-field-action:hover,
|
||||
.game-resource-info-field-action:focus-visible {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
+61
-50
@@ -22,24 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
action: ResourceCanvasAssetToolAction;
|
||||
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
|
||||
export type ResourceCanvasAssetGenerationPanelDraft = {
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
function assetGenerationErrorMessage(error: unknown) {
|
||||
if (typeof error === 'string' && error.trim()) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '生成素材失败';
|
||||
}
|
||||
export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
action: ResourceCanvasAssetToolAction;
|
||||
/**
|
||||
* 上一次「点击瞬间就失败」带回来的草稿。
|
||||
*
|
||||
* 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。
|
||||
*/
|
||||
draft?: ResourceCanvasAssetGenerationPanelDraft;
|
||||
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
|
||||
error?: string | null;
|
||||
/**
|
||||
* 提交回调:**同步返回**,面板不等它的结果。
|
||||
*
|
||||
* 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开),
|
||||
* 面板自己不持有任何在途状态。
|
||||
*/
|
||||
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 /
|
||||
* 生成 UI 设计图共用)。
|
||||
*
|
||||
* 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有
|
||||
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。
|
||||
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。
|
||||
*
|
||||
* **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里
|
||||
* 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的
|
||||
* 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。
|
||||
*
|
||||
* 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着
|
||||
* `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。
|
||||
*
|
||||
* 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC
|
||||
* 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必
|
||||
@@ -48,57 +71,52 @@ function assetGenerationErrorMessage(error: unknown) {
|
||||
*/
|
||||
export function ResourceCanvasAssetGenerationPanelView({
|
||||
action,
|
||||
draft,
|
||||
error: initialError,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResourceCanvasAssetGenerationPanelViewProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [assetName, setAssetName] = useState(action.assetName);
|
||||
const [aspectRatio, setAspectRatio] = useState(action.aspectRatio);
|
||||
const [imageSize, setImageSize] = useState(action.imageSize);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
|
||||
const [assetName, setAssetName] = useState(
|
||||
draft?.assetName ?? action.assetName,
|
||||
);
|
||||
const [aspectRatio, setAspectRatio] = useState(
|
||||
draft?.aspectRatio ?? action.aspectRatio,
|
||||
);
|
||||
const [imageSize, setImageSize] = useState(
|
||||
draft?.imageSize ?? action.imageSize,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(initialError ?? null);
|
||||
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
|
||||
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
|
||||
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
|
||||
const canSubmit =
|
||||
!submitting && prompt.trim().length > 0 && assetName.trim().length > 0;
|
||||
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const normalizedPrompt = prompt.trim();
|
||||
const normalizedAssetName = assetName.trim();
|
||||
if (!normalizedPrompt || !normalizedAssetName || submitting) {
|
||||
if (!normalizedPrompt || !normalizedAssetName) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit({
|
||||
kind: action.assetKind,
|
||||
prompt: normalizedPrompt,
|
||||
assetName: normalizedAssetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
} catch (submitError) {
|
||||
// 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。
|
||||
setError(assetGenerationErrorMessage(submitError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
// 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定
|
||||
// (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。
|
||||
onSubmit({
|
||||
kind: action.assetKind,
|
||||
prompt: normalizedPrompt,
|
||||
assetName: normalizedAssetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={action.label}
|
||||
closeOnBackdrop={!submitting}
|
||||
closeOnEscape={!submitting}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
<header>
|
||||
@@ -108,7 +126,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${action.label}`}
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
@@ -120,7 +137,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<PlatformTextField
|
||||
aria-label="素材名称"
|
||||
maxLength={120}
|
||||
disabled={submitting}
|
||||
value={assetName}
|
||||
onChange={(event) => setAssetName(event.currentTarget.value)}
|
||||
/>
|
||||
@@ -132,7 +148,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
value={prompt}
|
||||
@@ -152,7 +167,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
columns="threeToSix"
|
||||
gap="sm"
|
||||
size="compact"
|
||||
disabled={submitting}
|
||||
onChange={setAspectRatio}
|
||||
/>
|
||||
<PlatformSegmentedTabs
|
||||
@@ -166,7 +180,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
columns="three"
|
||||
gap="sm"
|
||||
size="compact"
|
||||
disabled={submitting}
|
||||
onChange={setImageSize}
|
||||
/>
|
||||
</div>
|
||||
@@ -182,7 +195,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
subject={`素材生成提示词(${action.label})`}
|
||||
editKind="image-reference"
|
||||
prompt={prompt}
|
||||
disabled={submitting}
|
||||
applyPrompt={setPrompt}
|
||||
/>
|
||||
{error ? (
|
||||
@@ -194,14 +206,13 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton type="submit" disabled={!canSubmit}>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
{submitting ? '生成中…' : action.label}
|
||||
{action.label}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import './resourceCanvasAssetGenerationTasksSidebar.css';
|
||||
|
||||
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
resourceCanvasAssetGenerationElapsedLabel,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis,
|
||||
resourceCanvasAssetGenerationTaskIsTerminal,
|
||||
resourceCanvasAssetGenerationTaskTone,
|
||||
sortResourceCanvasAssetGenerationTasks,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
|
||||
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
|
||||
|
||||
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
|
||||
};
|
||||
|
||||
function taskRow(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
const focusable = task.status === 'completed' && Boolean(task.assetId);
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="game-resource-generation-task-card"
|
||||
data-task-status={task.status}
|
||||
>
|
||||
<div className="game-resource-generation-task-card-title-row">
|
||||
<strong className="game-resource-generation-task-card-name">
|
||||
{task.assetName}
|
||||
</strong>
|
||||
<span className="game-resource-generation-task-card-action">
|
||||
{task.actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-resource-generation-task-card-meta">
|
||||
<span
|
||||
className="game-resource-generation-task-badge"
|
||||
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
|
||||
>
|
||||
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
<span className="game-resource-generation-task-card-phase">
|
||||
{task.phaseDetail}
|
||||
</span>
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{focusable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-generation-task-locate"
|
||||
aria-label={`定位素材 ${task.assetName}`}
|
||||
onClick={() => onFocusTask(task)}
|
||||
>
|
||||
定位到素材
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
|
||||
*
|
||||
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
|
||||
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
|
||||
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
|
||||
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
|
||||
*
|
||||
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
|
||||
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
|
||||
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
|
||||
*
|
||||
* 分栏、条数、封顶、折叠语义都在这里;颜色与排版在同目录的 CSS 文件里,取值全部走
|
||||
* `--platform-*` 设计 token。
|
||||
*/
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
).length;
|
||||
const hasLiveTask = inFlightCount > 0;
|
||||
const ordered = useMemo(
|
||||
() => sortResourceCanvasAssetGenerationTasks(tasks),
|
||||
[tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const done = ordered.filter((task) =>
|
||||
resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const visibleDone = done.slice(
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
);
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
if (!hasLiveTask) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-theme platform-theme--light game-resource-generation-tasks-handle"
|
||||
aria-label="展开生成任务"
|
||||
aria-expanded={false}
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
<span className="game-resource-generation-tasks-handle-label">
|
||||
生成任务
|
||||
</span>
|
||||
{inFlightCount > 0 ? (
|
||||
<span
|
||||
className="game-resource-generation-tasks-handle-badge"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight size={13} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
|
||||
role="region"
|
||||
aria-label="生成任务"
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
>
|
||||
<header className="game-resource-generation-tasks-sidebar-header">
|
||||
<h2 className="game-resource-generation-tasks-sidebar-title">
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
生成任务
|
||||
<span
|
||||
className="game-resource-generation-tasks-sidebar-count"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-generation-tasks-sidebar-icon-button"
|
||||
aria-label="收起生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="game-resource-generation-tasks-scroll"
|
||||
data-resource-generation-task-scroll=""
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section
|
||||
className="game-resource-generation-tasks-section"
|
||||
aria-label="排队与生成中"
|
||||
>
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>排队/生成中</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{active.length}
|
||||
</span>
|
||||
</h3>
|
||||
{active.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
没有进行中的任务
|
||||
</p>
|
||||
) : (
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section
|
||||
className="game-resource-generation-tasks-section"
|
||||
aria-label="已完成"
|
||||
>
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>已完成</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{done.length}
|
||||
</span>
|
||||
</h3>
|
||||
{done.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
还没有完成的任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{done.length > visibleDone.length ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
|
||||
done.length - visibleDone.length
|
||||
} 条较早记录`}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="game-resource-generation-tasks-sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-generation-tasks-sidebar-icon-button"
|
||||
aria-label="关闭生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+8
-12
@@ -58,6 +58,10 @@ function resourceGenerationErrorMessage(error: unknown) {
|
||||
* 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome),
|
||||
* 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,
|
||||
* 面板只持有草稿、类型选择与失败重试状态。
|
||||
*
|
||||
* 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份
|
||||
* view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上),
|
||||
* 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。
|
||||
*/
|
||||
export function ResourceCanvasGenerationPanelView({
|
||||
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
|
||||
@@ -115,8 +119,8 @@ export function ResourceCanvasGenerationPanelView({
|
||||
} catch (submitError) {
|
||||
setError(resourceGenerationErrorMessage(submitError));
|
||||
} finally {
|
||||
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
|
||||
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住。
|
||||
// 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常
|
||||
// resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试。
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -125,13 +129,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={panelTitle}
|
||||
closeOnBackdrop={!submitting}
|
||||
closeOnEscape={!submitting}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
<header>
|
||||
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${panelTitle}`}
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
@@ -203,10 +200,9 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
{submitting ? '后台运行并关闭' : '取消'}
|
||||
</PlatformActionButton>
|
||||
{error ? (
|
||||
<PlatformActionButton type="submit" disabled={submitting}>
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
applyLocalProjectAssetGenerationRecords,
|
||||
type LocalProjectAssetGenerationTaskRecord,
|
||||
mergeLocalProjectAssetGenerationRecord,
|
||||
nextResourceCanvasAssetGenerationDispatch,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
|
||||
|
||||
/**
|
||||
* 账本里连续多少次找不到这条任务就放弃等待。
|
||||
*
|
||||
* 非终态记录一直存在时不能设上限(图片类生成最长 35 分钟),但记录**消失**是完全另一回事
|
||||
* (账本被删、项目被换掉),继续轮询只会永远转下去。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15;
|
||||
|
||||
/** 账本读回的形状不合法(`undefined` / 非数组)时的原因文案。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR =
|
||||
'生成任务状态返回值不是数组';
|
||||
|
||||
/**
|
||||
* 一次提交的终局。
|
||||
*
|
||||
* `record` 是后端账本里那条终态记录;任务连后端都没进去(IPC 抛错、后端拒绝入参)时为
|
||||
* `null`,此时 `error` 带着原因。
|
||||
*/
|
||||
export type ResourceCanvasAssetGenerationSettlement = {
|
||||
taskId: string;
|
||||
/**
|
||||
* 该任务所属项目。
|
||||
*
|
||||
* 宿主必须按它判断「这条终局还属不属于当前打开的项目」:任务可以跨项目切换收尾,拿当前项目
|
||||
* 的路径去刷新另一个项目的清单是错的。
|
||||
*/
|
||||
projectId: string;
|
||||
status: 'completed' | 'failed';
|
||||
record: LocalProjectAssetGenerationTaskRecord | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type ResourceCanvasAssetGenerationQueueDeps = {
|
||||
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
|
||||
/**
|
||||
* 当前项目路径。
|
||||
*
|
||||
* 队列实例要跨渲染保持同一份(`draining` 是它的内部状态),所以路径不能靠闭包捕获,
|
||||
* 只能在派发那一刻从宿主当前值读。
|
||||
*/
|
||||
projectPath(): string;
|
||||
/** 提交前的平台会话刷新(与既有生成入口同一条前置动作)。 */
|
||||
refreshPlatformSession?: <T>(operation: () => Promise<T>) => Promise<T>;
|
||||
listTasks(): readonly ResourceCanvasAssetGenerationTask[];
|
||||
replaceTask(task: ResourceCanvasAssetGenerationTask): void;
|
||||
onSettled(
|
||||
settlement: ResourceCanvasAssetGenerationSettlement,
|
||||
): void | Promise<void>;
|
||||
pollIntervalMillis?: number;
|
||||
wait?: (millis: number) => Promise<void>;
|
||||
nowMillis?: () => number;
|
||||
};
|
||||
|
||||
export type ResourceCanvasAssetGenerationQueue = {
|
||||
/**
|
||||
* 入队并推进。
|
||||
*
|
||||
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
|
||||
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**——这是本批的**提交节流**:
|
||||
* AGC 本地 durable 输出槽已按精确动作指纹分槽,不同 prompt / 素材名可以同时在途,所以排队
|
||||
* 不再是「远端会拒绝并发」的被迫行为;真并行派发需要并发收口设计(配对读 + manifest CAS +
|
||||
* 聚焦意图互不覆盖),留待下一批。
|
||||
*/
|
||||
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
|
||||
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
|
||||
drain(): void;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (typeof error === 'string' && error.trim()) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '生成素材失败';
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: string) {
|
||||
return status === 'completed' || status === 'failed';
|
||||
}
|
||||
|
||||
type SettlementWaiter = {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地排队 + 后端任务账本的驱动器。
|
||||
*
|
||||
* 顺序上只有一条规则:**下一条必须等上一条终态**(本批的提交节流,见 `submit`);上一条结束
|
||||
* (成功或失败)后由同一个循环自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
|
||||
* `list_local_project_asset_generations` 返回的后端记录。
|
||||
*/
|
||||
export function createResourceCanvasAssetGenerationQueue(
|
||||
deps: ResourceCanvasAssetGenerationQueueDeps,
|
||||
): ResourceCanvasAssetGenerationQueue {
|
||||
const pollIntervalMillis =
|
||||
deps.pollIntervalMillis ??
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS;
|
||||
const wait =
|
||||
deps.wait ??
|
||||
((millis: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, millis);
|
||||
}));
|
||||
const nowMillis = deps.nowMillis ?? (() => Date.now());
|
||||
const waiters = new Map<string, SettlementWaiter[]>();
|
||||
let draining = false;
|
||||
|
||||
function settleWaiters(settlement: ResourceCanvasAssetGenerationSettlement) {
|
||||
const pending = waiters.get(settlement.taskId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
waiters.delete(settlement.taskId);
|
||||
for (const waiter of pending) {
|
||||
if (settlement.status === 'completed') {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiter.reject(new Error(settlement.error ?? '生成素材失败'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceFromRecord(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
record: LocalProjectAssetGenerationTaskRecord,
|
||||
) {
|
||||
deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record));
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
): Promise<ResourceCanvasAssetGenerationSettlement> {
|
||||
const projectPath = deps.projectPath();
|
||||
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
|
||||
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
|
||||
const start = async () =>
|
||||
(await deps.invoke('start_local_project_asset_generation', {
|
||||
projectPath,
|
||||
projectId: task.projectId,
|
||||
taskId: task.taskId,
|
||||
kind: task.assetKind,
|
||||
prompt: task.prompt,
|
||||
aspectRatio: task.aspectRatio,
|
||||
imageSize: task.imageSize,
|
||||
assetName: task.assetName,
|
||||
outputPath: task.outputPath,
|
||||
})) as LocalProjectAssetGenerationTaskRecord;
|
||||
let started: LocalProjectAssetGenerationTaskRecord;
|
||||
try {
|
||||
started = await (deps.refreshPlatformSession
|
||||
? deps.refreshPlatformSession(start)
|
||||
: start());
|
||||
} catch (error) {
|
||||
return {
|
||||
taskId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
status: 'failed',
|
||||
record: null,
|
||||
error: errorMessage(error),
|
||||
};
|
||||
}
|
||||
replaceFromRecord(task, started);
|
||||
let missingRecordPolls = 0;
|
||||
let readFailed = false;
|
||||
let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
|
||||
for (;;) {
|
||||
let records: LocalProjectAssetGenerationTaskRecord[];
|
||||
try {
|
||||
records = (await deps.invoke('list_local_project_asset_generations', {
|
||||
projectPath,
|
||||
})) as LocalProjectAssetGenerationTaskRecord[];
|
||||
} catch (error) {
|
||||
// IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛——
|
||||
// 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。
|
||||
records = [];
|
||||
readFailed = true;
|
||||
lastReadError = errorMessage(error);
|
||||
}
|
||||
if (!Array.isArray(records)) {
|
||||
records = [];
|
||||
readFailed = true;
|
||||
lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
|
||||
}
|
||||
const record = records.find((item) => item.taskId === task.taskId);
|
||||
if (record) {
|
||||
missingRecordPolls = 0;
|
||||
readFailed = false;
|
||||
replaceFromRecord(task, record);
|
||||
if (isTerminalStatus(record.status)) {
|
||||
return {
|
||||
taskId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
status: record.status === 'completed' ? 'completed' : 'failed',
|
||||
record,
|
||||
error: record.error,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
missingRecordPolls += 1;
|
||||
if (
|
||||
missingRecordPolls >=
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
|
||||
) {
|
||||
// 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用,
|
||||
// 后者是记录被账本上限淘汰或项目被换掉。
|
||||
return {
|
||||
taskId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
status: 'failed',
|
||||
record: null,
|
||||
error: readFailed
|
||||
? `生成任务状态读取失败,已停止等待:${lastReadError}`
|
||||
: '生成任务账本里已找不到这条任务,已停止等待',
|
||||
};
|
||||
}
|
||||
}
|
||||
await wait(pollIntervalMillis);
|
||||
}
|
||||
}
|
||||
|
||||
function markSettled(settlement: ResourceCanvasAssetGenerationSettlement) {
|
||||
const settled = deps
|
||||
.listTasks()
|
||||
.find((task) => task.taskId === settlement.taskId);
|
||||
if (!settled || isTerminalStatus(settled.status)) {
|
||||
return;
|
||||
}
|
||||
// 连后端都没进去的任务(record === null)也必须收口为终态:留在「已派发但未终态」
|
||||
// 会让本地队列认为还有在途任务,后面的排队任务永远补发不出去。
|
||||
deps.replaceTask({
|
||||
...settled,
|
||||
status: settlement.status,
|
||||
phaseDetail:
|
||||
settlement.record?.phaseDetail ??
|
||||
`生成失败:${settlement.error ?? '未知原因'}`,
|
||||
error: settlement.error,
|
||||
finishedAtMillis: settlement.record?.finishedAtMillis ?? nowMillis(),
|
||||
});
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (draining) {
|
||||
return;
|
||||
}
|
||||
draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const next = nextResourceCanvasAssetGenerationDispatch(
|
||||
deps.listTasks(),
|
||||
);
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
deps.replaceTask({ ...next, dispatched: true });
|
||||
let settlement: ResourceCanvasAssetGenerationSettlement;
|
||||
try {
|
||||
settlement = await dispatch(next);
|
||||
} catch (error) {
|
||||
// `dispatch` 已经把可预期的失败(提交失败 / 账本读失败 / 记录丢失)收口成
|
||||
// settlement;这里是最后一道兜底:任何意外抛出都不能变成未处理的 Promise 拒绝,
|
||||
// 也不能让这条任务永远停在「已派发但未终态」把后面的排队任务卡死。
|
||||
settlement = {
|
||||
taskId: next.taskId,
|
||||
projectId: next.projectId,
|
||||
status: 'failed',
|
||||
record: null,
|
||||
error: errorMessage(error),
|
||||
};
|
||||
}
|
||||
markSettled(settlement);
|
||||
settleWaiters(settlement);
|
||||
try {
|
||||
await deps.onSettled(settlement);
|
||||
} catch {
|
||||
// 宿主收尾(配对读清单 / 提示条)失败不改变任务终局,也不能打断队列:
|
||||
// 任务本身的状态已经写进列表并通知了等待者。
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return {
|
||||
submit(task) {
|
||||
deps.replaceTask(task);
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const pending = waiters.get(task.taskId) ?? [];
|
||||
waiters.set(task.taskId, [...pending, { resolve, reject }]);
|
||||
});
|
||||
drain();
|
||||
return promise;
|
||||
},
|
||||
drain,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用后端记录刷新整个任务列表(重开项目后恢复历史任务时用)。 */
|
||||
export function mergeResourceCanvasAssetGenerationTasksWithRecords(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
records: readonly LocalProjectAssetGenerationTaskRecord[],
|
||||
): ResourceCanvasAssetGenerationTask[] {
|
||||
return applyLocalProjectAssetGenerationRecords(tasks, records);
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
resolveResourceCanvasBottomTools,
|
||||
type ResourceCanvasAssetToolAction,
|
||||
resourceCanvasBottomToolActions,
|
||||
} from './resourceCanvasBottomToolbarModel';
|
||||
|
||||
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
|
||||
export type ResourceCanvasAssetGenerationTaskStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
/**
|
||||
* Rust 账本里的一条记录(`list_local_project_asset_generations` 的元素)。
|
||||
*
|
||||
* `phaseDetail` **由后端拥有**:前端只渲染这个字符串,不自己拼阶段或造百分比进度。
|
||||
*/
|
||||
export type LocalProjectAssetGenerationTaskRecord = {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
kind: string;
|
||||
assetName: string;
|
||||
status: string;
|
||||
phaseDetail: string;
|
||||
createdAtMillis: number;
|
||||
startedAtMillis: number | null;
|
||||
finishedAtMillis: number | null;
|
||||
assetId: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
|
||||
export type ResourceCanvasAssetGenerationTask = {
|
||||
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
|
||||
taskId: string;
|
||||
actionId: string;
|
||||
actionLabel: string;
|
||||
assetKind: string;
|
||||
assetName: string;
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
|
||||
dispatched: boolean;
|
||||
status: ResourceCanvasAssetGenerationTaskStatus;
|
||||
phaseDetail: string;
|
||||
createdAtMillis: number;
|
||||
startedAtMillis: number | null;
|
||||
finishedAtMillis: number | null;
|
||||
assetId: string | null;
|
||||
error: string | null;
|
||||
/** 从账本恢复出来的历史任务(本地没有对应的草稿)。 */
|
||||
restored: boolean;
|
||||
};
|
||||
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
|
||||
ResourceCanvasAssetGenerationTaskStatus,
|
||||
string
|
||||
> = {
|
||||
queued: '排队中',
|
||||
running: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态 → 视觉 tone(中性 / 品牌 / 成功 / 危险)。
|
||||
*
|
||||
* 放在模型层而不是组件层有两个原因:① 它是纯映射,组件不该自己发明颜色语义;
|
||||
* ② 组件文件只导出组件与常量(`react-refresh/only-export-components`),导出函数会破坏热更新边界。
|
||||
* 具体样式在 `resourceCanvasAssetGenerationTasksSidebar.css` 里按 `[data-tone=…]` 落地,映射由测试钉住。
|
||||
*/
|
||||
export type ResourceCanvasAssetGenerationTaskTone =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export function resourceCanvasAssetGenerationTaskTone(
|
||||
status: ResourceCanvasAssetGenerationTaskStatus,
|
||||
): ResourceCanvasAssetGenerationTaskTone {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地排队的阶段文案。
|
||||
*
|
||||
* 只有**还没交给后端**的那条任务会用到它:单值槽换成任务列表后,第二条提交在前一条终态
|
||||
* 之前不发 IPC(见 `nextResourceCanvasAssetGenerationDispatch`),所以它在后端账本里不
|
||||
* 存在,没有后端阶段可渲染。一旦派发,阶段文案一律改由后端 `phaseDetail` 提供。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE = '排队中。';
|
||||
|
||||
const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 图片类生成 kind → 入口文案。
|
||||
*
|
||||
* 从工具栏模型派生而不是另抄一张表:kind 白名单只有一份(`resourceCanvasBottomToolbarModel`),
|
||||
* 抄第二份就会在加 kind 时漂移。同一 kind 出现在多个入口时取首次出现的那个。
|
||||
*/
|
||||
export function resourceCanvasAssetGenerationKindLabel(
|
||||
kind: string,
|
||||
): string | null {
|
||||
for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) {
|
||||
for (const tool of resolveResourceCanvasBottomTools(category)) {
|
||||
for (const action of resourceCanvasBottomToolActions(tool)) {
|
||||
if (action.route === 'asset' && action.assetKind === kind) {
|
||||
return action.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveResourceCanvasAssetGenerationTaskStatus(
|
||||
status: string,
|
||||
): ResourceCanvasAssetGenerationTaskStatus {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
case 'running':
|
||||
case 'completed':
|
||||
return status;
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function resourceCanvasAssetGenerationTaskIsTerminal(
|
||||
task: Pick<ResourceCanvasAssetGenerationTask, 'status'>,
|
||||
): boolean {
|
||||
return task.status === 'completed' || task.status === 'failed';
|
||||
}
|
||||
|
||||
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
|
||||
export function createResourceCanvasAssetGenerationTask(input: {
|
||||
taskId: string;
|
||||
action: ResourceCanvasAssetToolAction;
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
nowMillis: number;
|
||||
}): ResourceCanvasAssetGenerationTask {
|
||||
return {
|
||||
taskId: input.taskId,
|
||||
actionId: input.action.id,
|
||||
actionLabel: input.action.label,
|
||||
assetKind: input.action.assetKind,
|
||||
assetName: input.assetName,
|
||||
prompt: input.prompt,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
outputPath: input.outputPath,
|
||||
projectId: input.projectId,
|
||||
dispatched: false,
|
||||
status: 'queued',
|
||||
phaseDetail: RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
|
||||
createdAtMillis: input.nowMillis,
|
||||
startedAtMillis: null,
|
||||
finishedAtMillis: null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
restored: false,
|
||||
};
|
||||
}
|
||||
|
||||
const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50;
|
||||
|
||||
/** 账本记录 → 任务列表里的一条(重开项目后恢复显示)。 */
|
||||
export function restoreResourceCanvasAssetGenerationTask(
|
||||
record: LocalProjectAssetGenerationTaskRecord,
|
||||
): ResourceCanvasAssetGenerationTask {
|
||||
return {
|
||||
taskId: record.taskId,
|
||||
actionId: `restored:${record.kind}`,
|
||||
actionLabel:
|
||||
resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName,
|
||||
assetKind: record.kind,
|
||||
assetName: record.assetName,
|
||||
prompt: '',
|
||||
aspectRatio: '',
|
||||
imageSize: '',
|
||||
outputPath: null,
|
||||
projectId: record.projectId,
|
||||
dispatched: true,
|
||||
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
|
||||
phaseDetail: record.phaseDetail,
|
||||
createdAtMillis: record.createdAtMillis,
|
||||
startedAtMillis: record.startedAtMillis,
|
||||
finishedAtMillis: record.finishedAtMillis,
|
||||
assetId: record.assetId,
|
||||
error: record.error,
|
||||
restored: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地任务 + 后端记录 → 下一版任务列表。
|
||||
*
|
||||
* 两个方向都要覆盖:本地任务用后端记录刷新状态与阶段;后端有、本地没有的记录(重开项目、
|
||||
* 或本地列表被清空)恢复成一条历史任务。后端不再返回的本地任务保持原样——记录被账本上限
|
||||
* 淘汰不该让前端把一条已完成任务抹掉。
|
||||
*
|
||||
* `records` **在函数入口归一化**:宿主从 IPC 拿到的不一定是数组(旧壳没有这条命令、权限拒绝、
|
||||
* 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」
|
||||
* 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。
|
||||
* 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。
|
||||
*/
|
||||
export function applyLocalProjectAssetGenerationRecords(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined,
|
||||
): ResourceCanvasAssetGenerationTask[] {
|
||||
const safeRecords = Array.isArray(records)
|
||||
? records.filter(
|
||||
(record): record is LocalProjectAssetGenerationTaskRecord =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
typeof (record as LocalProjectAssetGenerationTaskRecord).taskId ===
|
||||
'string' &&
|
||||
(record as LocalProjectAssetGenerationTaskRecord).taskId.trim()
|
||||
.length > 0,
|
||||
)
|
||||
: [];
|
||||
const recordsByTaskId = new Map(
|
||||
safeRecords.map((record) => [record.taskId, record]),
|
||||
);
|
||||
const merged = tasks.map((task) => {
|
||||
const record = recordsByTaskId.get(task.taskId);
|
||||
return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task;
|
||||
});
|
||||
const knownTaskIds = new Set(tasks.map((task) => task.taskId));
|
||||
const restored = safeRecords
|
||||
.filter((record) => !knownTaskIds.has(record.taskId))
|
||||
.map(restoreResourceCanvasAssetGenerationTask);
|
||||
if (restored.length === 0) {
|
||||
return merged;
|
||||
}
|
||||
return [...merged, ...restored].slice(
|
||||
-RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 用后端记录刷新一条本地任务:状态、阶段、时间戳、资源 id 与失败原因都以后端为准。 */
|
||||
export function mergeLocalProjectAssetGenerationRecord(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
record: LocalProjectAssetGenerationTaskRecord,
|
||||
): ResourceCanvasAssetGenerationTask {
|
||||
return {
|
||||
...task,
|
||||
dispatched: true,
|
||||
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
|
||||
phaseDetail: record.phaseDetail,
|
||||
startedAtMillis: record.startedAtMillis ?? task.startedAtMillis,
|
||||
finishedAtMillis: record.finishedAtMillis ?? task.finishedAtMillis,
|
||||
assetId: record.assetId ?? task.assetId,
|
||||
error: record.error ?? task.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地排队的派发判据:**同一时刻只派发一条任务**(本批的提交节流)。
|
||||
*
|
||||
* AGC 本地 durable 输出槽已按**精确动作指纹**分槽,不同 prompt / 素材名可以同时在途,所以这条
|
||||
* 判据**不再是「远端会拒绝并发」的被迫排队**;本批前端仍按「同一时刻只派发一条」排队,作为提交
|
||||
* 节流。真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。
|
||||
*
|
||||
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
|
||||
* 没读回来的那一段(状态仍是 `queued`)同样算在途,漏掉这一档就会提前放出第二条。
|
||||
*/
|
||||
export function nextResourceCanvasAssetGenerationDispatch(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
): ResourceCanvasAssetGenerationTask | null {
|
||||
const inFlight = tasks.some(
|
||||
(task) =>
|
||||
task.dispatched && !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
if (inFlight) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
tasks.find((task) => !task.dispatched && task.status === 'queued') ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** 面板展示顺序:新提交的在上。 */
|
||||
export function sortResourceCanvasAssetGenerationTasks(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
): ResourceCanvasAssetGenerationTask[] {
|
||||
return [...tasks].sort(
|
||||
(left, right) =>
|
||||
right.createdAtMillis - left.createdAtMillis ||
|
||||
left.taskId.localeCompare(right.taskId),
|
||||
);
|
||||
}
|
||||
|
||||
/** 已耗时文案:排队中按创建时间算,已结束按结束时间算。 */
|
||||
export function resourceCanvasAssetGenerationElapsedLabel(
|
||||
elapsedMillis: number,
|
||||
): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
|
||||
}
|
||||
|
||||
export function resourceCanvasAssetGenerationTaskElapsedMillis(
|
||||
task: Pick<
|
||||
ResourceCanvasAssetGenerationTask,
|
||||
'createdAtMillis' | 'finishedAtMillis'
|
||||
>,
|
||||
nowMillis: number,
|
||||
): number {
|
||||
return Math.max(
|
||||
0,
|
||||
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
|
||||
);
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
/* 「生成任务」侧栏的样式。
|
||||
*
|
||||
* 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应:
|
||||
* 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。
|
||||
* 差别只有一处:**颜色全部换成 AGC 的平台设计 token(`--platform-*`)**,不再照抄网页端的固定色值,
|
||||
* 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。
|
||||
*
|
||||
* 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。
|
||||
*/
|
||||
|
||||
.game-resource-generation-tasks-sidebar {
|
||||
position: fixed;
|
||||
top: 4rem;
|
||||
bottom: 6rem;
|
||||
left: 0.75rem;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
width: min(300px, 80vw);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--platform-subpanel-fill);
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: game-resource-generation-tasks-enter 160ms ease-out;
|
||||
}
|
||||
|
||||
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */
|
||||
@keyframes game-resource-generation-tasks-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid var(--platform-line-soft);
|
||||
padding: 0.68rem 0.78rem;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-count {
|
||||
display: inline-flex;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-neutral-border);
|
||||
border-radius: 999px;
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-neutral-text);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-icon-button {
|
||||
display: grid;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--platform-button-ghost-text);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-icon-button:hover {
|
||||
border-color: var(--platform-subpanel-border);
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-icon-button:focus-visible,
|
||||
.game-resource-generation-tasks-handle:focus-visible,
|
||||
.game-resource-generation-task-locate:focus-visible {
|
||||
outline: 2px solid var(--platform-accent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.5rem 0.6rem;
|
||||
scrollbar-color: var(--platform-line-soft) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: var(--platform-line-soft);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-empty {
|
||||
padding: 1rem 0.5rem;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-section
|
||||
+ .game-resource-generation-tasks-section {
|
||||
margin-top: 0.65rem;
|
||||
border-top: 1px solid var(--platform-line-soft);
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-section-title-count {
|
||||
display: inline-flex;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-neutral-text);
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-list {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--platform-panel-fill);
|
||||
padding: 0.5rem 0.55rem;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card:hover {
|
||||
border-color: var(
|
||||
--platform-surface-hover-border,
|
||||
var(--platform-subpanel-border)
|
||||
);
|
||||
box-shadow: var(--platform-desktop-hover-shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-title-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-name {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-action {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--platform-neutral-border);
|
||||
border-radius: 999px;
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-neutral-text);
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-badge::before {
|
||||
content: '';
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
/* 四种状态的 tone 映射:中性 / 品牌 / 成功 / 危险,全部走平台 token,不硬编码颜色。 */
|
||||
.game-resource-generation-task-badge[data-tone='queued'] {
|
||||
border-color: var(--platform-neutral-border);
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-neutral-text);
|
||||
}
|
||||
|
||||
.game-resource-generation-task-badge[data-tone='running'] {
|
||||
border-color: var(--platform-accent);
|
||||
background: transparent;
|
||||
color: var(--platform-accent);
|
||||
}
|
||||
|
||||
.game-resource-generation-task-badge[data-tone='completed'] {
|
||||
border-color: var(--platform-success-border);
|
||||
background: var(--platform-success-bg);
|
||||
color: var(--platform-success-text);
|
||||
}
|
||||
|
||||
.game-resource-generation-task-badge[data-tone='failed'] {
|
||||
border-color: var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
/* 生成中只给「有在动」的呼吸感,不做百分比 —— 后端没有可播报的百分比。 */
|
||||
.game-resource-generation-task-badge[data-tone='running']::before {
|
||||
animation: game-resource-generation-task-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes game-resource-generation-task-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-phase {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-elapsed {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 750;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card-error {
|
||||
color: var(--platform-button-danger-text);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-locate {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.16rem;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-locate:hover {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--platform-line-soft);
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
/* 折叠态:贴边竖向把手 + 在途数量角标。 */
|
||||
.game-resource-generation-tasks-handle {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-left: 0;
|
||||
border-radius: 0 0.75rem 0.75rem 0;
|
||||
background: var(--platform-subpanel-fill);
|
||||
color: var(--platform-text-strong);
|
||||
padding: 0.6rem 0.3rem;
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease;
|
||||
animation: game-resource-generation-tasks-handle-enter 160ms ease-out;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle:hover {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
box-shadow: var(--platform-desktop-hover-shadow);
|
||||
transform: translateY(-50%) translateX(0.1rem);
|
||||
}
|
||||
|
||||
@keyframes game-resource-generation-tasks-handle-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) translateX(-0.5rem);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 850;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle-badge {
|
||||
display: inline-flex;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-accent);
|
||||
border-radius: 999px;
|
||||
color: var(--platform-accent);
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
/* 窄屏(含 360px):侧栏占满可用宽度,把手只占一条窄边,不挡画布操作。 */
|
||||
@media (max-width: 480px) {
|
||||
.game-resource-generation-tasks-sidebar {
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 5.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle {
|
||||
padding: 0.5rem 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.game-resource-generation-tasks-sidebar,
|
||||
.game-resource-generation-tasks-handle {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card,
|
||||
.game-resource-generation-tasks-handle,
|
||||
.game-resource-generation-tasks-sidebar-icon-button,
|
||||
.game-resource-generation-task-badge::before,
|
||||
.game-resource-generation-task-locate {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card:hover,
|
||||
.game-resource-generation-tasks-handle:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -21,7 +21,7 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod
|
||||
*/
|
||||
|
||||
/**
|
||||
* `generate_local_project_asset` 放行的无源生成类型。
|
||||
* `start_local_project_asset_generation` 放行的无源生成类型。
|
||||
*
|
||||
* 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发
|
||||
* 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`,
|
||||
@@ -72,7 +72,7 @@ type ResourceCanvasBottomToolActionBase = {
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
/** 图片类生成入口:走 `generate_local_project_asset`。 */
|
||||
/** 图片类生成入口:走 `start_local_project_asset_generation`(提交即返回、后台生成)。 */
|
||||
export type ResourceCanvasAssetToolAction =
|
||||
ResourceCanvasBottomToolActionBase & {
|
||||
route: 'asset';
|
||||
|
||||
@@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = {
|
||||
|
||||
const EMPTY_TAGS_TEXT = '暂无标签';
|
||||
|
||||
/**
|
||||
* 「分类」字段的行标识。
|
||||
*
|
||||
* 画布上的信息浮层用这一行接出素材类型设置入口(运行页签的「信息展示」不带入口,
|
||||
* 同一份字段清单在两处渲染);把字面量放在这里,模型与入口两侧不会各写一个。
|
||||
*/
|
||||
export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类';
|
||||
|
||||
/**
|
||||
* 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。
|
||||
*/
|
||||
@@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows(
|
||||
{ label: '名称', value: resource.label },
|
||||
{ label: '路径', value: resource.path },
|
||||
{ label: '类型', value: resource.mediaType },
|
||||
{ label: '分类', value: resourceCategoryLabel(resource.category) },
|
||||
{
|
||||
label: RESOURCE_INFO_CATEGORY_FIELD_LABEL,
|
||||
value: resourceCategoryLabel(resource.category),
|
||||
},
|
||||
{
|
||||
label: '标签',
|
||||
value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user